Understanding Hoisting , lexical environment and block scope in JavaScript with Var , let and const variables
Hey Everyone, if you have been working with JS for a while, you would wonder how things behave differently in JS compared to other programming languages. How can we access a variable or a function before its declaration? What are the scope chain and lexical environment of a function? We often hear about block scope in JS. But how var, let, and const variables behave differently in this context?
So after reading this article, you'll understand and would be able to explain the concepts of Hoisting, lexical environment, and block scope easily. And we'll follow this roadmap -
JavaScript behind the scenes (Execution Context and Call Stack)
Hoisting and Temporal Dead Zone (var, let, and const variables)
Block Scope, Scope Chain and Lexical Environment
JavaScript Execution Behind The Scenes
Let's consider the following code snippet :
var a=10;
function check()
{
let b=100;
console.log(b);
}
console.log(a);
check();
//output
// 10
// 100
Even before the code is executed, the JS engine creates a global execution context and place it inside the call stack. Inside Execution Context, there are 2 Phases - Memory Allocation and Code Execution . In the memory allocation phase, memory is allocated to variables and functions in the global object ( also represented by the window keyword). Variables are assigned undefined value until its initialized with a value(in code execution). And in the case of functions, the entire function body is stored in the memory.
In the code execution phase, the entire code is executed line by line(remember that JavaScript is synchronous and single-threaded) from top to bottom.
1.Line 1 is executed and in memory space, variable a's value is updated to 10.
2.The control moves to Line 7 and it prints 10 in the console.
3.The control moves to Line 8 and function check is called. Whenever a function is called, it creates its own execution context - which consists of local memory and code execution . This execution context is moved to the top of the call stack for execution. See the below image, a local memory is assigned to the check function.
Code Execution starts, control moves to line 4, and variable b is assigned a value of 100.
Control moves to Line 5 and 100(value of b) is printed in the console.
After each line inside the check function is executed, its execution context is destroyed and is removed from the call stack(control returns to global execution context)
Control reaches the end of the code and the Global execution context is also destroyed.
Now we've understood how a JavaScript program executes behind the hood. Let's move on to the next topic - Hoisting in JavaScript.
Hoisting and Temporal Dead Zone(var, let, and const variables)
In the previous example, we have seen how we can access a variable and function even before its declared. In other programming languages, it would simply throw an error, but in the case of JavaScript -
the variable and function declarations are put into memory during the compile phase but stay exactly where you typed them in your code. - MDN web docs
Let's understand this with an example. Consider below code snippet:
console.log(a);
check();
check2();
var a=10;
function check()
{
console.log("inside check function!");
}
const check2=()=>{
console.log("I am inside second function");
}
What do you think the output would be? Let's see.
In JavaScript, only declarations are hoisted but initialization is not hoisted. That's why we saw that value of a is printed as undefined in the console. You can see that I have used 2 types of function expressions in the above code, traditional expression and arrow function (introduced in ES6 and used for writing shorter syntax).
By default, var and functions are stored inside global memory space, but let and const declarations are stored inside a separate memory location. See below image for reference-
Many developers have this misconception that let and const are not hoisted, but they too are hoisted like var . One Important thing to know is that they are in a Temporal Dead Zone until initialized with a value.
But what is Temporal Dead Zone ?
In the case of let and const declarations, the phase between hoisting and initialization is known as the temporal dead zone and hence this error:
Reference Error: Can't access a particular const or let variable before it's initialized
Since arrow function expressions include assigning an anonymous function to a const variable, In our above example:
Traditional function is stored inside global memory and hence the relevant output is printed in the console.
But check2 being a const variable is not accessible before initialization and hence console throws the relevant error.
I hope this section would have cleared your misconceptions regarding Hoisting and how var , let , and const behave in this context. In the next section, I have explained what is the block scope, scope chain, and lexical environment of a function in JavaScript.
Block Scope, Scope Chain and Lexical Environment
Scope in JavaScript is directly related to lexical environment
Consider this code snippet:
var a=100;
function x()
{
console.log(a);
y();
function y()
{
console.log(a);
}
}
x();
console.log(a);
The output of the above code will be: 100 100 100 How? Let's see!
In general, the lexical environment of a function is defined as - local memory + its parent's lexical scope.
So in the above example, this is how code is executed behind the hood:
- Before code execution is started, a global execution context is created and is pushed into the call stack. Memory is allocated to variable a and function x in global space(window object).
2.Now code execution starts and control moves to line 1, variable a is initialized with a value 100.
3.After the above execution, the x function is called and a separate local memory is created for x.
4.Code execution moves to the next line and the function tries to find the value of a in local memory. Since it's not there, it looks for the value of a in its parent's lexical scope (in this case, its global space). Remember that we have initialized the value of a as 100 and its memory location is global space. So the value of a is found and the console prints 100 .
5.Code execution moves further and function y is called. A separate execution context is created for this function and it's moved to the top of the stack. Currently, the stack looks like this:
-Again, the function tries to find the value of a in local memory (a not found), then it tries to find the value of a in its parent's lexical scope(local memory of function x and its parent's lexical environment). a is not found, so it tries to find a in global scope, and the value of a is found to be 100.
-Now, both the execution contexts (function x and function y) are destroyed, and control returns back to GEC. And console.log() prints 100 to the console.
This whole process can be termed scope chaining. Also, we have understood what does the word lexical mean - It means in order or in the hierarchy.
Now we will look into a simple example to understand block and block scope.
Consider below code snippet:
var a=100;
let b=200;
const c=300;
{
var a=10;
let b=20;
const c=30;
console.log("a inside block : " +a);
console.log("b inside block : " +b);
console.log("c inside block : " +c);
}
console.log("a outside block : " +a);
console.log("b outside block : " +b);
console.log("c outside block : " +c);
The output of the above code is :
A block can be represented as some lines of code enclosed inside curly braces {}.
A block is used to combine multiple lines of code together to perform logical operations. Coming to the above example, instead of writing a long story, I tried to run this code in developer tools by putting a debugger, see below image:
So I have placed the debugger on line no 9 and we can see that there are 3 different scopes -
Global Scope (where variable a resides)
Script Scope (a special scope for let and const variables when they are not inside a block).
3.Block Scope (scope created specifically for a block of code).
Now, we will track the value of a,b, and c.
Value of a is initialized to 100 in the global scope but it is shadowed by the value of a inside the block scope (observe that a is not present inside the block ). This happened because a inside block scope is still pointing to the variable in the global scope. That's why it prints the value of a as 10 in both inside and outside the block.
Values of b and c doesn't override the values of b and c in Script Scope because let and const are block-scoped in nature. So when the control moves out of the block, block scope is removed and only Global and Script Scope is available.
This behavior is not limited to blocks and it applies to functions as well.
That's it, folks! I know that it was a long read, but I hope that you've now understood the concepts of Hoisting, JavaScript execution behind the scenes, scope chain, lexical environment, temporal dead zone, and block scope properly.
You can also watch https://www.youtube.com/watch?v=pN6jk0uUrD8&list=PLlasXeu85E9cQ32gLCvAvr9vNaUccPVNP for an in-depth explanation and more examples.
If you have any doubts or suggestions, let me know in the comment section!