Deep Dive into JavaScript: Lexical Scope, Closures and “this” keyword
Introduction
JavaScript, widely known as the programming language of the Web, proves to be a fundamental tool for creating interactive user interfaces and powering backend functionalities. As we delve deeper into the world of JavaScript, we come across intriguing concepts like lexical scope, closures, and the frequently misunderstood this
keyword. In this article, I aim to shed light on these three concepts and demonstrate how lexical scoping intertwines with closures and the dynamic resolution of the this
keyword.
Understanding Lexical Scope
When we talk about lexical scope in JavaScript, we’re referring to how variables and functions become accessible based on their placement within the code’s physical structure, such as nested blocks or functions. This concept is fundamental, since JavaScript uses lexical (static) scoping to resolve variable names at write time.
Lexical scope:
- Cares about where variables and functions are defined.
- Considers nesting of blocks or functions.
- Determines scope based on where variables and functions are defined within this nested structure, not just where the code physically exists in the file.
This leads to the following lexical scoping rules:
- Inner functions or blocks can access variables declared in their outer function or block.
- Inner scopes can reassign variables declared with
let
orvar
from outer scopes within the same scope or inner scopes. - Inner scopes cannot directly modify
const
variables declared in outer scopes by reassigning the entire variable reference. However, they can modify properties of objects and arrays declared withconst
if those properties are mutable. - When a variable is referenced, JavaScript looks for variables or functions starting from the current scope and then progressively through outer scopes until reaching the global scope, allowing for a hierarchical resolution of identifiers.
Let’s consider the following example:
function greet() {
const message = 'Hello';
function sayHello(name) {
console.log(`${message}, ${name}!`);
}
sayHello('John'); // output: Hello John!
}
greet();
In this example, message
is defined within the greet
function's scope, allowing the sayHello
function to access it due to lexical scope rules. When greet
is called, it invokes sayHello
with the argument 'John', resulting in the output "Hello, John!".
Now, how about the next one?
function greet() {
let greeting = 'Hello';
const person = { name: 'Alice' };
function updateGreeting(newGreeting) {
greeting = newGreeting;
console.log(greeting); // Output: Updated greeting
}
function updateName(newName) {
person.name = newName; // Modifying a property of an object declared with const
console.log(person.name); // Output: Bob
}
function updatePerson(newName) {
// Attempting to reassign the entire object reference for a const variable (will result in an error)
person = { name: newName }; // Error: Assignment to constant variable
}
updateGreeting('Updated greeting');
updateName('Bob');
updatePerson('Jane');
}
greet();
Let’s break down the behavior of the three inner functions:
updateGreeting
: This function takes a new greeting as an argument and updates thegreeting
variable declared withlet
. Sincelet
variables are mutable, we can reassign their values within the same scope or inner scopes. Therefore, we get the output as the updated value "Updated greeting".updateName
: This function takes a new name as an argument and modifies thename
property of theperson
object declared withconst
. Theconst
keyword allows the modification of properties if the properties themselves are mutable. Hence, this function successfully updates and logs the new name "Bob".updatePerson
: This function attempts to reassign the entire object reference of theperson
object to a new object with a different name. However, sinceperson
is declared withconst
, it cannot have its reference reassigned after initialization. This attempt to reassign the object reference leads to an error(TypeError: Assignment to constant variable)
.
Now that we have a solid understanding of lexical scoping rules, let’s delve into one of the most powerful concepts that stems from it: closures.
Exploring Closures
Closures in JavaScript are like a dynamic duo formed by a function and the environment it’s created in. They work hand in hand with lexical scoping, allowing a function to retain accessibility to its surrounding variables even after the outer function has finished executing.
Take a look at the following snippet:
function outerFunction() {
const message = 'Hello';
function innerFunction() {
console.log(message); // Accessing message from outerFunction
}
return innerFunction;
}
const myClosure = outerFunction();
myClosure(); // Output: Hello
In this example, innerFunction
forms a closure with the message
variable defined in outerFunction
. Even after outerFunction
completes its execution, myClosure
retains access to message
due to the closure mechanism, leading to the output "Hello".
It’s important to note that closure formation occurs during the definition of the inner function, and not during runtime.
Now that we’ve explored how closures work within the context of lexical scope, let’s shift our focus to another crucial aspect: the resolution of the this
keyword.
Decoding this
Keyword
In JavaScript, the this
keyword is a special identifier that refers to the current execution context within which a function is invoked. It plays a crucial role in determining the context for accessing properties and methods.
The resolution of this
varies depending on the type of function being used, particularly between regular functions and arrow functions.
Consider the following example:
const obj = {
name: 'Alice',
greet: function() {
return function() {
return this.name;
};
},
arrowGreet: function() {
return () => this.name;
}
};
const regularGreet = obj.greet(); // Returns a regular function
const arrowGreet = obj.arrowGreet(); // Returns an arrow function
console.log(regularGreet()); // Output: undefined
console.log(arrowGreet()); // Output: Alice
The greet
method returns a regular function that tries to access this.name
, but because it's a regular function, this
inside it refers to the global scope (or undefined in strict mode), resulting in the output being undefined.
On the other hand, the arrowGreet
method returns an arrow function. Arrow functions do not have their own this
context; instead, they inherit this
from the surrounding lexical scope, which in this case is the obj
object. Therefore, this.name
inside the arrow function correctly refers to the name
property of the obj
object, resulting in the output being "Alice".
Hence, we can observe that:
- Arrow functions maintain the lexical scope of
this
. - Regular functions have their own
this
context that depends on how they are invoked.
Conclusion:
In this journey through JavaScript’s foundational concepts, we’ve explored the workings of lexical scope, closures, and the dynamic resolution of the this
keyword.
Lexical scope, with its emphasis on variable and function accessibility based on code structure, provides a structured approach to managing scope and defining function behavior.
Closures, as dynamic pairs between functions and their environments, enable functions to retain access to variables even after their outer function execution completes, adding a layer of flexibility and persistence to our code.
The resolution of the this
keyword clarifies the context within which functions operate.
By mastering these concepts, JavaScript developers can gain a deeper understanding of the language’s inner workings, enabling them to write more efficient and robust code. I hope that this journey through JavaScript’s foundational concepts has provided valuable insights for developers. Happy coding!