Understanding 'Hoisting' and 'Closures' through the JavaScript Engine Execution Process
JavaScript
JavaScript is one of the three core elements of a web page.
- HTML: A markup language that defines the format of web pages (documents).
- CSS: A language for the design elements of web pages (documents).
- JavaScript: Handles all interaction events between web pages (documents) and users.
JavaScript can be executed synchronously by simply declaring and calling functions, or it can be set to perform asynchronously at specific event times via callbacks.
- A single browser consists of an HTML/CSS engine and a JavaScript engine.
Various web browsers like Chrome, Internet Explorer, and Safari each have their own HTML, CSS, and JavaScript engines. Among them, a prominent JavaScript engine is V8, used in Chrome, which will be discussed in this article. Incidentally, Node.js is created by taking the JavaScript engine from a browser and combining it with libuv, an asynchronous event processing library, to form a server. It’s really that simple.
Interpreted Language
JavaScript is a scripting language, and because it undergoes interpretation, you can see the results line by line directly in the browser console. Like any script, it can also be made into a single file and executed like a batch script, undergoing a brief compilation followed by interpretation.
- JavaScript is a scripting language and an interpreted language. However, it does have a brief compilation process.
When JavaScript executes a .js file, it first goes through a JIT (Just-In-Time) compilation process that scans only variable and function declarations before execution. JIT compilation differs from the AOT (Ahead-of-Time) compilation process (https://dev.to/deanchalk/comment/8h32) that generates intermediate code in commonly known compiled languages like C++ and Java. Although JavaScript is taught as an interpreted language, leading some to believe it has no compilation phase, it strictly does have a compilation phase. However, merely having a compilation phase doesn’t make JavaScript a compiled language, as it doesn’t align with the definition of a compiled language. Thus, it’s more appropriate to call it an interpreted language.
JIT Compilation in V8
Similar to the compilation of other programming languages, the V8 engine compiles JavaScript by generating ASTs and converting them into bytecode. To improve efficiency, V8 has its own compiler solution that caches bytecode that would otherwise be repeatedly recompiled.
- Ignition: Converts ASTs generated by the Parser into bytecode.
- TurboFan: Caches bytecode (functions) that are repeatedly executed during bytecode execution.
- Reduces unnecessary compilation time by caching bytecode.
- References cached bytecode during compilation.
- Stores/tracks function call counts via a profiler.
- Reduces unnecessary compilation time by caching bytecode.
The process of ASTs and bytecode caching is beyond the scope of this article, so we will only briefly touch upon it.
JavaScript Engine and Runtime
The execution of a programming language naturally involves an engine that transforms it into executable code, loads it into memory, and manages its execution. The JavaScript engine acts as the JavaScript executor. When Web APIs (such as setTimeout, which uses the Kernel) are added to provide a rich JavaScript experience, it becomes the browser we use.
- A JavaScript engine is a JavaScript executor.
- A JavaScript runtime is the JavaScript engine augmented with Web APIs and other components.
JavaScript Engine
A JavaScript engine is divided into two memory components:
- Heap: The memory area where all variables and functions are stored.
- Stack (Call Stack): The memory area where pointers corresponding to the function execution order are stored.
Variables and functions are stored in the Heap, and the order of function calls is stored in the Stack, which are then executed sequentially by a single thread.
Here, the Stack in JavaScript is somewhat different from other programming languages. In other languages, the Call Stack stores not only function execution information but also local variables and parameter information for each function execution. This memory area for each function within the Call Stack is sometimes called a Context Scope, as it holds all the context needed for function execution. In contrast, JavaScript’s Call Stack only stores function execution order pointers, while all function and variable contexts corresponding to the Context Scope are stored in the Heap. Astute readers might notice that this implies all function variables are gathered indiscriminately in a single Heap space. This is the conceptual starting point for the concepts of Hoisting and Closures.
JavaScript Runtime
When we hear about JavaScript, asynchronous operations are often mentioned tirelessly, but how does a single thread handle asynchronous tasks? This is possible because the JavaScript runtime, which we’ll explain below, includes three components that help with ‘asynchronous execution,’ ‘storing asynchronous results,’ and ‘bringing those results back to the current single thread.’
The JavaScript runtime adds the following three components to the JavaScript engine:
- Web APIs: Library functions not native to JavaScript, such as DOM, Ajax, and
setTimeout.- These are implemented in various languages like C++ and provided by the browser or OS.
- Callback Queue: Where callback functions generated by the above Web APIs are steadily enqueued.
- Event Loop: A thread that moves functions from the Callback Queue one by one to the engine’s Stack for execution.
JavaScript Engine Execution Process
The JavaScript engine executes code in two phases: the ‘JIT Compilation Phase’ and the ‘Execution Phase.’ Now, based on what we’ve read and learned so far, we will examine how JavaScript code is executed within the engine. We’ll use the terms explained above, so if any terms are unclear, please review the preceding content.
Compilation Phase
To make the Compilation Phase easy to understand, we can think of it as the process of loading into the Heap. As previously explained, the Heap is where the JavaScript engine stores internal parameters, variables, and functions necessary for function execution (unlike languages that store them on the Stack). With so many functions defined and called during JavaScript execution, are all parameters and variables within those functions stored in “a single Heap”? How are they distinguished then?
When a function is executed (which we will learn about in the Execution Phase), a Scope is created for that function. Function parameters and local variables are defined and stored within this Scope during the compilation phase. Since the Scope of parameters and variables is defined during the compilation phase, it is later referred to as Lexical Scope.
- In the Compilation Phase, only variable declarations and function declarations are loaded into the Heap.
Let’s examine the compilation process using the following JavaScript file as an example.
var a = 2;
b = 1;
function f(z) {
b = 3;
c = 4;
var d = 6;
e = 1;
function g() {
var e = 0;
d = 3 * d;
return d;
}
return g();
var e;
}
f(1);
- For the initial execution of JavaScript, the Global Scope (window) area for the
main()function is created in the Heap.
When JavaScript starts, the very first function executed is main(), and it begins by creating a Global Scope, called window, in the Heap. You might recall using window for global variable definitions. This is possible precisely because it is the Global Scope itself. Henceforth, Scope areas loaded into the Heap will be represented as follows:
# Global Scope (window)
-
-
Next, variable and function declarations are loaded into the newly created Global Scope (window) area in the Heap.
var ais a variable declaration, soais loaded into the Global Scope (window) area.b = 1is a variable assignment, so it is not loaded into the Heap (Scope) at this stage.
# Global Scope (window)
- a = <-- var a = 2;
-
function f(z)is a function declaration, sofis loaded into the Global Scope (window) area.- When a function is loaded, a pointer to the bytecode (blob) of function
fis also loaded.
- When a function is loaded, a pointer to the bytecode (blob) of function
# Global Scope (window)
- a =
- f = a pointer on f functions bytecode <-- function f(z) {
The Compilation Phase for the main() function, the very first function to execute in JavaScript, concludes here. Once this function’s (main()) Compilation Phase is finished, execution immediately returns to the very first line of the function and performs the Execution Phase (i.e., function execution) using the Scope Heap that has just been loaded.
Execution Phase
- In the Execution Phase, variables loaded into the Heap are assigned values, and functions are executed.
While the preceding Compilation Phase only involved variable and function declarations, this phase handles variable assignments. This means it needs to find where a variable declaration was made previously and then assign a value to that found declaration. So, what happens if a variable declaration isn’t found?
Scope Chain
If a declaration for the variable to be assigned is not found within its own (function’s) Scope Heap, the following procedure occurs:
- It searches in the Scope of the parent function that called it.
- If not found there either, it continues to search up the chain, for example, in the parent’s parent Scope, and so on, calling upon its “grandparents.”
- Eventually, it reaches the Global Scope of the
main()function, the very first function executed in JavaScript, and searches there.
This process, of sequentially searching for a variable declaration across Scopes, is called the Scope Chain. If, after searching all the way up to the Global Scope Heap, the variable still doesn’t exist, a new variable is declared and assigned simultaneously in the Global Scope Heap. Ultimately, it becomes a global variable.
Continuing with the example, let’s explain the Execution Phase:
# Global Scope (window)
- a =
- f = a pointer for f functions bytecode
a = 2is a variable assignment, so it finds the variableaand assigns the value2.- Variable
aexists in the Global Scope (window) area.
- Variable
# Global Scope (window)
- a = 2 <-- var a = 2;
- f = a pointer for f functions bytecode
b = 1is a variable assignment, so it finds the variableband assigns the value1.- Variable
bdoes not exist in the Global Scope (window) area.- Therefore, a new variable
bis declared and assigned1simultaneously.
- Therefore, a new variable
- Variable
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1 <-- b = 1;
f(1)is a function call, so it checks for the declaration off()and executes it.- Function
f()exists in the Global Scope (window) area.- To execute the
f()function, another Compilation Phase and Execution Phase are required.- A new Local Execution Scope area for
f()is created in the Heap. - For the Scope Chain, it holds a pointer to the parent function’s Scope that called it.
(hidden) A pointer for previous scope
- A new Local Execution Scope area for
- To execute the
- Function
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1
# Local Execution Scope for f() <-- f(1);
+ (hidden) A pointer for previous scope (= Global Scope (window))
-
-
- When the
f(1)function is executed, in the newly created Local Execution Scope,- Variables and function declarations are loaded again through the Compilation Phase, resulting in the following:
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 1
# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z =
- d =
- e =
- After completing the Execution Phase of
f(1),- Variable assignments are made, and another Local Execution Scope for function
g()is created as shown below.
- Variable assignments are made, and another Local Execution Scope for function
# Global Scope (window)
- a = 2
- f = a pointer for f functions bytecode
- b = 3
# Local Execution Scope for function f()
+ (hidden) a pointer for previous scope (= Global Scope (window))
- z = 1
- d = 6
- e = 1
- c = 4
# Local Execution Scope for function g()
+ (hidden) a pointer for previous scope (= Local Execution Scope for function f())
- e =
From the g() function onwards, it would be beneficial for the reader to try writing it themselves as a review. (I’m definitely not just being lazy.)
JavaScript Variable Characteristics
In fact, the reason for learning about the JavaScript engine execution process was not only to understand the process itself but also as a build-up to explain the following concepts. Based on the JavaScript engine’s operation, several unique characteristics of JavaScript have emerged, let’s explore them all.
Lexical Scope
Programming languages differ in their Scope terminology depending on when variable scopes are defined. If defined at compile time, it’s called “Static Scope”; if at runtime, it’s “Dynamic Scope”. In JavaScript, since parameters and variables are all bound to the Scope of the function where they are located at the time of their definition during the compilation phase (not Execution Phase, which was likely a typo in the original Korean), it is also called “Lexical Scope”. The term Lexical refers to something being created, or the time of creation, and since parameters, variables, and functions all follow their Scope at the time they are created, i.e., defined, it is called Lexical Scope. Thus, Lexical Scope can also be referred to as Static Scope.
In the example below, function b() and variable var num = 1; are both defined within the same main() function’s Scope, the Global Scope (window). This is why function b() can access var num = 1; at any time.
var num = 1;
function b() {
console.log(num);
}
b();
The above example is easy to understand, but the example below, despite only adding one function, can cause momentary confusion.
var num = 1;
function a() {
var num = 10;
b();
}
function b() {
console.log(num);
}
a();
The execution result of a() is 1, not 10. The reason is that function b() does not care about
- “where it was called from” = “runtime”, but rather
- “where it was defined” = “compile (lexical)”.
Recalling this, function b(), defined in the Global Scope, will refer to var num = 1; which is also defined in the Global Scope. In general, with common programming languages, one might mistakenly think 10 would be the result due to thinking about runtime scope.
Variable Shadowing
Lexical Scope naturally leads to a situation where, even if variables with the same name are defined, only the closest function’s Scope (Local Execution Scope for function) will be used. Variables with the same name defined in a parent function (that called the current function) are ignored. If the variable being sought exists in the closest function’s Scope, there’s no need for a Scope Chain search. This implies that anything beyond the variable in the closest function’s Scope is not needed and will not be known, hence it’s called Variable Shadowing.
Hoisting: Variables, Functions
Because variable and function declarations are processed first during the compilation phase, followed by variable assignment and function execution during the execution phase, the code will run without errors even if a variable declaration appears below its assignment, or a function declaration appears below its call. This is because the variable and function declarations have already been processed. The concept that variable and function declarations seemingly “move up” regardless of where they are placed is called Hoisting.
console.dir(exampleV); // output: undefined
// → 'Variable declared', but no value assigned at this point.
console.dir(exampleF); // output: f exampleF(x)
console.log(exampleF(2)); // output: 2
var exampleV = 1;
function exampleF(x) {
return x;
}
Functions can be defined using ‘function declarations’ and ‘function expressions.’ If you’ve correctly understood the compilation process, you can determine which definition type is subject to hoisting and which is not.
console.dir(functionDeclare); // output: f functionDeclare(x)
console.dir(functionExpression); // output: undefined
// → 'Variable declared', but no function assigned at this point.
function functionDeclare(x) {
return x;
} // Function Declaration
var functionExpression = function (x) {
return x;
}; // Function Expression
In the case of a ‘function expression,’ only the var functionExpression variable is declared during compilation, so the function itself is undefined.
Block Scope: const, let
Previously, 1) Lexical Scope and 2) Hoisting were often sources of errors because they differ from how we commonly expect programming languages to behave.
- Function-level Scope (Lexical Scope + Scope Chain) = All variables declared outside the function are accessible.
var a = 1;
if (true) {
var a = 2;
}
console.log(a); // 2 - Variable pollution for the window function (like a global variable).
- Hoisting = Only declarations are hoisted; if an assignment hasn’t occurred,
undefinedis returned. - Duplicate Declarations Allowed =
varallows duplicate declarations likevar hello = 1; var hello = 2;. When combined with points 1 and 2, this can become extremely confusing.
To rescue our brains from this challenging world, ES6 introduced the golden variable keywords: const and let. Variables defined with const and let are scoped to a Block Scope, unlike the previous function-unit Scope. This means:
- Block-level Scope = Variables within a block are only accessible there, preventing pollution of variables in other Scopes.
let b = 1;
if (true) {
let b = 2;
}
console.log(b); // 1 - The 'let' variable is only valid within the if-block. It does not pollute variables outside the block.
- No Hoisting = Instead of showing
undefined(meaning “declared but not assigned”), it throws aUncaught ReferenceError: ... is not definederror, indicating that the declaration itself has not occurred yet. - No Duplicate Declarations = Attempting a duplicate declaration will throw a
Uncaught SyntaxError: Identifier ... has already been declarederror.
The advent of const and let enabled defining and using variables within block-level constructs like if and for loops. They prevent any pollution.
Garbage Collection
By now, it should be ingrained that in JavaScript, a function-unit Context Scope is created in the Heap every time a new function is called. Since a Context Scope is only valid for the duration of its function call, once that function’s call ends, its Scope is removed from the Heap. This memory cleanup is called Garbage Collection. When the JavaScript file execution is entirely finished, the main() function, which was called first, also ends, and consequently, the Global Scope (Window) disappears. Thus, JavaScript performs Garbage Collection purely based on the Reachability of functions (pointers). It’s sufficient to know that it employs a simple “Mark And Sweep” strategy, unlike languages like Swift or Java, which use a “Reference Count” strategy for Garbage Collection.
Closure
In Java, classes use private variables to achieve Encapsulation. This prevents external access to variables within a class; changes to variables are only possible through publicly exposed functions. Encapsulation is a fundamental concept not only in Object-Oriented Programming (OOP) but also in Domain-Driven Design (DDD). Unfortunately, JavaScript classes, unlike Java classes, do not natively support Encapsulation. Of course, there was a convention where variables prefixed with _ were implicitly considered private, but since objects could still be logged to the console, it was ultimately ineffective. More recently, JavaScript introduced the # prefix for class variables, which operates like a pseudo-private variable. However, because #variableName variables are defined as such, they can still be exposed via Object.getOwnPropertySymbols(), making it not a fundamental solution.
This is where JavaScript’s Closures come in handy. By leveraging Lexical Scope, where the variables a function can reference are determined by the Scope in which the function is defined, we can simply return the “function definition” itself. This allows us to achieve Encapsulation. Before explaining Encapsulation further, let’s understand how to define a Closure.
var closureTest = function () {
return function () {
console.log("This is innerFunction.");
};
};
Or
var closureTest = function () {
function innerFunction() {
console.log("This is innerFunction.");
}
return innerFunction;
};
This method of returning a function definition is called a Closure.
Variables defined within the Lexical Scope where a function is defined are accessible from that defined function. By defining the variable we want to keep private inside a Closure’s function definition, we can achieve Encapsulation.
var closureTest = function () {
var cannotBeAccessedFromOuter = "This is innerFunction.";
return function () {
console.log(cannotBeAccessedFromOuter);
};
};
var closure = closureTest();
closure(); // output: This is innerFunction.
console.log(closure.cannotBeAccessedFromOuter); // output: undefined
Private variables are also referred to as ‘unexposed variables.’ In the example above, the unexposed variable held a fixed value, but it can also be defined in a mutable state. This is a coding technique consistent with Java’s object-oriented programming style. Furthermore, let’s define not just one function, but several. Let’s define the counter function, which is cited countless times in Closure explanations. While one might always think of Closures as returning a function, they can also return an object as follows:
var counter = function () {
var count = 0;
return {
increase: function (number) { // Added 'number' parameter
count += number;
},
decrease: function (number) { // Added 'number' parameter
count -= number;
},
show: function () {
console.log(count);
},
};
};
var counterClosure = counter();
counterClosure.increase(1);
counterClosure.show(); // output: 1
counterClosure.decrease(1);
counterClosure.show(); // output: 0
The only way to modify the count private variable within counterClosure is by calling its functions, and it can only be viewed through its functions. This also means that the count variable is accessible from anywhere if accessed through the increase(), decrease(), or show() functions. This implies that the Reachability of the count variable is always open, and the JavaScript engine can never determine when to perform Garbage Collection.
Therefore, the counterClosure object will not be deleted from memory even after its execution is complete. The fact that the count variable is always ready for use means that while it exists within the counter function, it is also referenced globally. As a result, it continues to exist until the global scope where the counter function was defined is closed, leading to a memory leak.
Closures, as described, have the critical disadvantage of not being Garbage Collected. However, to resolve this, to allow for Garbage Collection, you can assign null to the counterClosure variable to remove references to both the variable and the function (deleting its Reachability).
counterClosure = null;
Another approach for functions that don’t require reusability is to use an IIFE (Immediately Invoked Function Expression).
(function hello() { /* ... */ })();
(function makePrivateFunc() {
const message = "private data";
const privateFunc = function () {
console.log(`${message} can also be implemented through an IIFE`);
};
privateFunc();
})();
In summary, we’ve briefly explored how the JavaScript engine operates in two phases, ‘compilation’ and ‘execution,’ along with the concepts of function execution (Context Scope) and variable definition (Lexical Scope). While going into more detail would make it infinitely complex, for web application developers, understanding these concepts should be sufficient for practical development and interviews.