Pic Credit @thoughtco

In the world of JavaScript, understanding hoisting and function execution contexts is essential for writing clean and efficient code. In this blog post, we’ll explore how JavaScript hoists variables and functions and how function execution contexts work, using practical examples.

Hoisting Variables and Functions

JavaScript is known for its behavior of hoisting, which is the process of moving variable and function declarations to the top of their containing scope during compilation. Let’s dive into some code to see how this works:

console.log(x); // undefined 
var x = 10;
console.log(x); // 10

In the above code, x is hoisted to the top of its scope, so it is defined but has an initial value of undefined until it's assigned the value 10.

Function Declarations Are Hoisted

Function declarations are also hoisted to the top of their containing scope. This means you can call a function before its declaration:

getData(); // hello from get data
function getData() {
console.log('hello from get data');
}

Variables Declared with let and const

Variables declared with let and const are also hoisted but have a concept called the "temporal dead zone." This means that while they are hoisted, you cannot access them before their declaration:

function modifyX() {
console.log(x); // undefined
var x = 20;
console.log("inner", x); // 20
console.log("inner", y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;
}
modifyX()

In the modifyX function, trying to access y before its declaration results in an error, while accessing x before its declaration returns undefined.

Execution Contexts and the this Keyword

JavaScript uses execution contexts to manage the scope and this keyword inside functions. Understanding this is crucial when dealing with function calls like call() and apply():

let personObj = {
name: "Ajay",
age: 20
};
function getDetails(name, age) {
return `Person name is ${name}, age is ${age}.`;
}
console.log(getDetails.call({}, personObj.name, personObj.age)); // "Person name is Ajay, age is 20."
console.log(getDetails.apply({}, [personObj.name, personObj.age])); // "Person name is Ajay, age is 20."

In the above code, we use call() and apply() to invoke the getDetails function with a different this context (an empty object {}), allowing us to access the name and age properties of personObj.

Custom Map Function for Arrays

You can even create your custom array methods, like map(). Here's an example of creating a custom map() function for arrays:

Array.prototype.customMap = function (callback) {
for (let i = 0; i < this.length; ++i) {
this[i] = callback(this[i], i);
}
};

const arr = [1, 3, 4];
arr.customMap((item, idx) => {
return item * item;
});
console.log(arr); // [1, 9, 16]

In this code, we add a custom customMap() function to the Array prototype and use it to square each element in the arr array.

Avoiding Variable Re-declaration

Finally, it’s important to note that re-declaring a variable with let or const in the same scope is not allowed:

let greeting = "say Hi"; 
let greeting = "say Hello instead"; // SyntaxError: Identifier 'greeting' has already been declared

But following will work

let greeting = "say Hi"; 
//let greeting = "say Hello instead"; // SyntaxError: Identifier 'greeting' has already been declared

function test () {
let greeting = "hello";
console.log(greeting)
}
test()
console.log(greeting)

Conclusion:

Understanding hoisting, function execution contexts, and how JavaScript manages variable and function declarations is fundamental to writing clean, efficient, and error-free code. By grasping these concepts, you’ll be better equipped to navigate JavaScript’s unique behavior and produce more reliable code.