DEV Community

Cover image for JavaScript Engine: meaning and structure (part 2/7)
boolfalse
boolfalse

Posted on

JavaScript Engine: meaning and structure (part 2/7)

NGNTJS - part 2


In the previous part 1 of NGNTJS article series we talked about the history of browsers, the origins of JavaScript, the emergence of the ECMA standard.
In this part we will present the meaning and structure of JavaScript engines.

["Check Engine"]

All web browsers have a JavaScript Engine. It is a software component that optimizes and executes JavaScript code. At the beginning of the history of web browsers, this component was mainly just an interpreter, but modern engines are much more than an interpreter, they have additional components that perform optimization, and also use JIT compilation, which improves performance.
The function of JavaScript engine is to run JavaScript code regardless of whether it is run in a browser, Node.js, CLI environment or any IoT device.
We will consider JavaScript engines in more detail in part 7, and in this part we will try to get sufficient general information.

Currently, different browsers work with different engines. Some of the famous ones are:

  • SpiderMonkey - the first engine ever created by the same Brandon Eich that was first used by Netscape Navigator, then became open source. Now it's running Mozilla Firefox.
  • V8 - It is an open source engine released by Google (issues page). This can be used standalone, or it can be embedded in other C++ applications, such as the Node.js runtime (which runs JavaScript code on the server), that also take advantage of this.
  • ChakraCore - Microsoft's own open source engine used by the former IE and current Edge (before ECMA standards, the language was called JScript). One of its special features is that it compiles scripts on a separate CPU core, in parallel with the web browser.
  • JavaScriptCore (JSC) - It is an open source (own repo) engine created by Apple. This uses Safari as well as all other Apple WebKit engines (rendering engines). As with V8, this way JavaScriptCore can be used separately in Swift, Objective-C, and C-specific applications. It is also used as an engine in Bun (we will talk about this later).

Currently, there are other open source JavaScript engines that can be used in different environments.

  • Boa - Boa is an embeddable and experimental Javascript engine written in Rust. Currently, it has support for some of the languages.
  • Hermes - A JavaScript engine optimized for running React Native.
  • Yantra - JavaScript Engine for .NET Standard.

Although different browsers approach their JavaScript engines slightly differently, they all essentially do the same thing. Even though the solutions may have some differences, their differences create a natural competition that has led to significant changes and development over time.
Anyway, let's describe the general picture of the operations of the modern JavaScript Engine:

[Image built with draw.io]

The image above is only a very general view of the JavaScript engine, it describes how JavaScript code works. In addition to the fact that JavaScript engines differ from each other, their differences also change over time, as it undergoes certain changes during development. But just to understand, you can consider the scheme shown in the picture above.

Source code

In step 1 we have JavaScript/TypeScript code.
This can be code for different environments, such as a web browser, a server, a CLI, or any IoT device.

Parser

Parser is designed for transforming code into an abstract syntax tree. Parser checks the syntax of the code, and if there is an error, it returns an error and thus stops further work.
In case of valid syntax, Parser builds the Abstract Syntax Tree.
It is worth to listen to the following talk (the corresponding presentation) at JSConf EU 2017 about Parser work in V8. Here the speaker talks about the need for parsers, explains the work of the parser on an example, talks about caching, lazy and eager modes of parsing, about the work of parsing modes in the function context, about the problems that arise during the parsing stage, etc.

Abstract Syntax Tree (AST)

AST is also used to build the syntax tree of many other programming languages and tools (like CSS, HTML, GraphQL, Java, JSON, Markdown, PHP, Scala, SQL, Regular Expressions, etc.). A visual example of an implementation of this can be seen here: AST explorer.

Interpreters/Compilers

Hint. if necessary, you can get acquainted with the interpreter, compiler, JIT-compiler below.

After obtaining the abstract syntax tree, it is passed to the 4th step, where the interpreter must process the tree and obtain an intermediate bytecode representation from it.
Bytecode is the output state of the JavaScript engine when the code is represented as an instruction set. This is the stage when the programmer has no direct influence. At this stage, the variables and functions of the script code are stored and used in the registers (main memory) and in the accumulator (current memory), and they are used to fulfill the requirements of the instruction set.

Intermediate Representation, a.k.a. Bytecode

The bytecode received by the interpreter is actually the form that needs to be transformed into machine code (in block 7) and then passed directly to the CPU.
But, in fact, we see that the interpreter does not directly transfer it from the 4th block to the 7th block. That being said, the abstract tree is not immediately transformed into machine code. The reason is that before the machine code can be generated, there needs to be some intermediate representation (IR, also known as bytecode) on which some work needs to be done, such as JIT compiling (with block 6).
Bytecode is engine-specific, it means different engines have their own bytecode syntax and instruction sets.

One way to see the bytecode of the written JS code is to use the node cli's "--print-bytecode" argument. Let's look at an example to get a clear idea. We have an index.js file with the following content:

function sum() {
  var first = 10;
  var second = 5;
  var third = first + second;
  return third;
}

console.log(sum());
Enter fullscreen mode Exit fullscreen mode

To see the piece of bytecode associated with the sum function (the entire bytecode is quite large), we can run the following:

node --print-bytecode --print-bytecode-filter=sum index.js
Enter fullscreen mode Exit fullscreen mode

as a result we will get the following:

[generated bytecode for function: sum (0x095c31356c51 <SharedFunctionInfo sum>)]
Bytecode length: 13
Parameter count 1
Register count 3
Frame size 24
OSR urgency: 0
Bytecode age: 0
   31 S> 0x95c31357690 @    0 : 0d 0a             LdaSmi [10]
         0x95c31357692 @    2 : c4                Star0 
   50 S> 0x95c31357693 @    3 : 0d 05             LdaSmi [5]
         0x95c31357695 @    5 : c3                Star1 
   67 S> 0x95c31357696 @    6 : 0b f9             Ldar r1
   73 E> 0x95c31357698 @    8 : 39 fa 00          Add r0, [0]
         0x95c3135769b @   11 : c2                Star2 
   98 S> 0x95c3135769c @   12 : a9                Return 
Constant pool (size = 0)
Handler Table (size = 0)
Source Position Table (size = 12)
0x095c313576a1 <ByteArray[12]>
15
Enter fullscreen mode Exit fullscreen mode

The following can be understood from the extracted data (bold font is the meaning of the letters):

  • LdaSmi [10] loa*d* small integer 10 into the accumulator
  • Star0 store accumulator value to the r0 register
  • LdaSmi [5] loa*d* small integer 5 into the accumulator
  • Star1 store accumulator value to the r1 register
  • Ldar r1 load r1 register value into the accumulator
  • Add r0, [0] add whatever it is in r0 register into the 0 index of the accumulator
  • Star2 store accumulator value to the r2 register
  • Return return the current value of the accumulator

In the table below, let's show the sequence of instructions and the values of the accumulator and registers at each step:

| Instruction | Accumulator | Register r0 | Register r1 | Register r2 |
| ----------- | ----------- | ----------- | ----------- | ----------- |
| <START>     |             |             |             |             |
| LdaSmi [10] |     10      |             |             |             |
| Star0       |     10      |     10      |             |             |
| LdaSmi [5]  |      5      |     10      |             |             |
| Star1       |      5      |     10      |      5      |             |
| Ldar r1     |      5      |     10      |      5      |             |
| Add r0, [0] |     15      |     10      |      5      |             |
| Star2       |     15      |     10      |      5      |      15     |
| Return      |     15      |     10      |      5      |      15     |
| ----------- | ----------- | ----------- | ----------- | ----------- |
| <END>       |     15      |             |             |             |
Enter fullscreen mode Exit fullscreen mode

Regarding bytecode, there is an interesting article written by a member of the V8 team: Understanding V8's Bytecode, and here the bytecode of V8's Ignition interpreter is considered in detail on various examples.

JIT (just-in-time) compiler

Although step 6 is optional, most JavaScript engines currently have a JIT compiler component. Different engines apply their own optimization compilers, which may be more than one, to generate the bytecode and optimize performance.

[Images taken from the links below]

You can learn more about them from the employees of their own teams:

Briefly, a JIT compiler is a tool that generates machine code directly from bytecode, performing optimization. Currently, for example V8 engine uses TurboFan as a JIT compiler, SpiderMonkey engine uses WarpMonkey (formerly IonMonkey) as a JIT compiler.

Machine Code

It is necessary to take into account the fact that the machine code, being assembly language, is specific to the architecture of the machine (in the case of ARM, Intel-based or other processors), it means the machine code will be different for machines with different architectures, that is why it is necessary to have that so-called intermediate bytecode.

[Image from unsplash.com]

Compiler, Interpreter, JIT-compiler; real life analogies

  • Compiler This is software that translates source code written in a high-level language (for example, C++) into machine code (specific to the machine architecture) "once and at all". To translate, it performs a static analysis of the entire source code, checks for errors, and generates an executable or binary file that can be executed by the machine independently of the compiler. That file can already be considered as a separate object, which will not need to be compiled in order to be executed later.
  • Interpreter This is mostly a program already implemented with an interpreted language, or in the case of JavaScript, its engine. In the classic case, when each line contains one instruction, it reads each command line by line, translates it into machine language and executes the machine code corresponding to that command (with a slight difference in the case of JavaScript). In the case of JavaScript, the difference is that the interpreter turns the source code line by line into an intermediate language (IR - intermediate representation) instead of immediately doing it. Once the source code has been fully interpreted into an intermediate language (that's bytecode), it is then fully translated into machine language and executed. Modern interpreters analyze the source code not in its entirety, but on the principle of receiving the next command, and can perform optimization at the time of receiving the next command. In the Java language, source code is also translated into bytecode (for example, using the javac compiler), then the bytecode is translated into machine code by the interpreter and the JIT compiler for the JVM in the given environment to be executed.
  • JIT (just-in-time) compiler JIT compiler can be considered as a hybrid of compiler and interpreter. This is software that dynamically analyzes the source code and compiles block by block into machine language before executing, making optimizations between blocks. When the source code is completely compiled and there is complete machine code, execution comes.

[Image from americangirl.com]

  • Compiler - real life analogy Imagine you have a cake recipe written in a language you don't understand. You give the recipe to a translator who reads the entire recipe, understands the instructions, and translates them into a language you understand. The translated recipe is then given to you and you can bake the cake by following the instructions. So you can bake the cake with all the translated instructions.
  • Interpreter - real life analogy Imagine you have a recipe for baking a cake written in a language you don't understand, and your friend knows that language. This time you call to your friend for help. Your friend reads the instructions line by line and translates them to you sequentially. After listening to the translation of each instruction, you follow the current instruction, and the resulting cake is baked as a result of the steps taken sequentially. Not necessarily, but in modern life it is possible that your friend also has some cooking knowledge. In this case, for example, when the N-th instruction is "add 3 spoons of sugar" and the N+1 instruction is "add another 2 spoons of sugar", your friend considers these two instructions as one instruction and formulates the optimized instruction as "add 5 spoons of sugar" for you, thereby saving the number of operations and reducing the preparation time.
  • JIT compiler - real life analogy Imagine you have a recipe that is written in an unknown language, but you have a personal assistant who can simultaneously translate that language and also has excellent culinary knowledge. Your assistant reads it block by block, translates it and writes it down, performs some optimizations on it (more active than your friend in the case of interpreters), and tells you the optimized version of the next few instructions. At each subsequent instruction, while you follow the instruction, your assistant continues to make notes in his notebook, perform calculations, think, and prepare to tell you the next block of instructions. For example, your assistant might say "wash 50 strawberries, remove the tails, cut them in half, and place those 100 strawberry halves cut side up on the cake layer", whereas in the non-optimized case you would wash 1 strawberry, remove the tail, cut it in half, and place the 2 strawberry halves cut side up on the cake layer, and this sequence of instructions would do 50 times because you don't know the future instructions. However, if you know that in the next 49 steps you have to wash strawberries, you wash those 50 strawberries in a container at once. In a theoretical case, it is possible to imagine that a recipe with 20 instructions can be translated for you by your assistant and presented as an example in the form of 5 instructions. Thus, your assistant helps you to optimize calculations, save resources and reduce time.

Below are links to useful resources that did not appear directly in this section, but are related to this topic:

Appendix: for those who are interested in low-level topics (compilers, interpreters, etc.), useful resources available on YouTube such as MIT 6.172 Performance Engineering of Software Systems, Fall 2018 and Compilers and Interpreters 2022 lectures, and DragonBook 2nd edition for a professional approach.

This part of the NGNTJS article series introduced the meaning and structure of JavaScript engines.
Next, in part 3. We'll cover terms like the call stack, variable types, hoisting, and more.


If you liked this article, feel free to follow me here. 😇

To explore projects working with various modern technologies, you can follow me on GitHub, where I actively publicize much of my work.

For more information, you can visit my website: https://boolfalse.com/

Top comments (0)