JavaScript is a high-level, interpreted programming language that runs in a web browser. A JavaScript engine is a program that executes JavaScript code, translating it from its human-readable form to machine-executable instructions.
Overview
Almost everyone has already heard of the V8 Engine as a concept, and most people know that JavaScript is single-threaded or that it is using a callback queue.
In this post, we’ll go through all these concepts in detail and explain how JavaScript actually runs. By knowing these details, you’ll be able to write better, non-blocking apps that are properly leveraging the provided APIs.
If you’re relatively new to JavaScript, this blog post will help you understand why JavaScript is so "weird" compared to other languages. And if you’re an experienced JavaScript developer, hopefully, it will give you some fresh insights on how the JavaScript Runtime you’re using every day actually works.
In this article, we will discuss the internal working of JavaScript in the run-time environment and the browser. This will be an overview walk-through of all the core components that are involved in the execution of JavaScript code. We will discuss the following components:
- JavaScript Engine
- JavaScript Runtime Environment
- The Callstack
- Concurrency and Event Loop
Javascript Engine
As you may heard before, JavaScript is an interpreted programming language. It means that source code isn’t compiled into binary code prior to execution.
How computer can understand what to do with a plain text script?
That’s the job for a JavaScript engine. JavaScript engine is simply a computer program that execute JavaScript code. JavaScript engines are inbuilt in all the modern browsers today. When the JavaScript file is loaded in the browser, JavaScript engine will execute each line of the file from top to bottom (to simplify the explanation we are avoiding hoisting in JS). JavaScript engine will parse the code line by line, convert it into machine code and then execute it.
The JavaScript engine consists of several components, including:
-
Parser: The parser reads the JavaScript code and creates an abstract syntax tree (AST), which is a data structure that represents the code.
-
Interpreter: The interpreter reads the AST and executes the code.
-
Compiler: The compiler optimizes the code by analyzing the code and generating machine code.
-
Garbage collector: The garbage collector is responsible for freeing up memory that is no longer needed.
The most popular JavaScript engine is the V8 engine developed by Google, which is used in Google Chrome and Node.js. Other popular JavaScript engines include SpiderMonkey (used in Firefox), JavaScriptCore (used in Safari), and Chakra (used in Microsoft Edge).
JavaScript engines are constantly being improved to provide better performance and support for new language features. For example, the introduction of just-in-time (JIT) compilers has significantly improved the performance of JavaScript execution. Additionally, new features like async/await and arrow functions have been added to the language to make it more powerful and expressive.
JavaScript Runtime Environment
JavaScript Runtime Environment is a term used to refer to the environment or context in which JavaScript code is executed. It includes the JavaScript engine, as well as other components such as the event loop, the call stack, and the heap.
The event loop is responsible for handling asynchronous operations, such as timers and network requests, while the call stack is used to keep track of the function calls in the current execution context. The heap is where objects and variables are stored in memory.
In addition to these core components, the JavaScript runtime environment may also include various APIs and libraries that provide additional functionality, such as the Document Object Model (DOM) for manipulating HTML documents in the browser, or the Node.js runtime for running JavaScript on the server-side.
Understanding the JavaScript runtime environment is important for developing efficient and reliable JavaScript code, as it provides insight into how the code is executed and how resources are managed.
The Callstack
The JavaScript Call Stack is a mechanism used by the JavaScript engine to keep track of the function calls in a program. It is a data structure that stores the execution context of each function call in a stack-like fashion.
When a function is called, its execution context, which includes the function's local variables and arguments, is pushed onto the top of the call stack. As the function executes, any nested function calls are also added to the top of the stack. When a function completes execution, its execution context is popped off the stack, and control returns to the calling function.
The call stack is important for understanding how JavaScript executes code and for debugging errors in code. For example, if a function calls itself recursively too many times, it can cause the call stack to exceed its maximum size and result in a stack overflow error.
JavaScript engines implement the call stack as part of their runtime environment, and developers can use tools like the browser's developer console or Node.js's built-in debugger to inspect and manipulate the call stack during program execution.
Let’s see an example. Take a look at the following code:
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function calculate(a, b) {
const sum = add(a, b);
const product = multiply(a, b);
return sum + product;
}
console.log(calculate(2, 3));
In this example, we have three functions: add
, multiply
, and calculate
. When we call calculate(2, 3)
, the following happens:
- The calculate function is added to the call stack.
const sum = add(a, b)
is called, and the add function is added to the top of the call stack.add
returns the sum of a and b, and is popped off the call stack.const product = multiply(a, b)
is called, and the multiply function is added to the top of the call stack.- multiply returns the product of
a
andb
, and is popped off the call stack. - The calculate function returns the sum of sum and product, and is popped off the call stack.
- The final result,
11
, is logged to the console.
As each function is called and returns, it is added and removed from the call stack in a last-in, first-out (LIFO) order. This allows the JavaScript engine to keep track of the execution context of each function call.
Concurrency and Event Loop
JavaScript is a single-threaded language, it can only perform one task at a time. However, it does have mechanisms for dealing with concurrency, such as the event loop. The event loop is responsible for handling events, callbacks, and I/O operations in a non-blocking way, allowing JavaScript to perform tasks in the background while still responding to user input.
Let's take a look at an example to better understand how the event loop works in JavaScript:
console.log("start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
Promise.resolve().then(function() {
console.log("Promise");
});
console.log("end");
This code logs four messages to the console:
start
end
Promise
setTimeout
When this code is run, the following happens:
-
The first
console.log()
statement is executed, and the message"start"
is logged to the console. -
The
setTimeout()
function is called with a callback function and a timeout of 0 milliseconds. This means that the callback function will be executed as soon as possible, but not necessarily immediately. -
The
Promise.resolve()
function creates a new resolved Promise, which means that its then() callback will be executed asynchronously in the next tick of the event loop. -
The second
console.log()
statement is executed, and the message "end" is logged to the console. -
The current task (i.e. the script) has completed, so the event loop moves on to the next tick.
-
In the next tick, the
then()
callback for the resolved Promise is executed, and the message"Promise"
is logged to the console. -
Finally, in the same tick, the
setTimeout()
callback function is executed, and the message"setTimeout"
is logged to the console.
So even though the setTimeout()
function was called before the Promise.resolve()
function, its callback function is executed after the Promise's then() callback because the Promise's callback is scheduled in the next tick of the event loop.