Part 1: Execution Context - The core of Javascript

Part 1: Execution Context - The core of Javascript

Mastering Execution Context in JavaScript: A Deep Dive

·

7 min read

Introduction

JavaScript’s Execution Context is foundational yet complex concept for understanding how JavaScript runs your code. If you aim to write performant, error-free code and debug complex applications, mastering Execution Context is crucial.

In this guide, we’ll unpack the key components of Execution Context, its lifecycle, how it interacts with the call stack, and explore some nuances such as hoisting, closures, the mysterious this binding, and the scope chain.

1. What is Execution Context

At its core, an Execution Context (EC) is an environment where JavaScript code is evaluated and executed. Think of it as the space JavaScript creates to handle the code execution, where it manages variables, functions, and the context of this. JavaScript, as a single-threaded language, uses this context to manage the order and scope of code execution.

Types of Execution Contexts

There are three primary types of Execution Contexts in JavaScript:

  1. Global Execution Context (GEC): Created by default when JavaScript starts. This context is at the top of the scope chain and has two main functions:

    • It sets up the global window object (or global in Node.js).

    • It assigns a value to this in the global scope.

Some points to remember about GECs -

  • There's only one GEC per program

  • Forms the base execution context

  1. Function Execution Context (FEC): Created whenever a function is called. Each function has its own execution context, which is destroyed when the function completes execution. Each FEC has access to:

    • The function’s arguments and variables.

    • An optional this binding (set when called as an object method).

    • A reference to its outer environment, forming a scope chain.

Some points to remember about -

  • Each function call creates a new execution context

  • Multiple FECs can exist simultaneously

  • Managed via the call stack

  1. Eval Execution Context: Created when JavaScript’s eval() function is used, allowing execution of arbitrary code within a function or global scope. However, eval is generally discouraged due to security and performance issues.

The Lifecycle of Execution Contexts

The lifecycle of an Execution Context includes three phases:

  1. Creation Phase (Memory Creation):

    • JavaScript initializes the Execution Context and its environment.

    • During this phase, the Variable Object (VO) or Lexical Environment is created, hoisting variables and functions.

    • this binding and the scope chain are set.

        console.log(myVar); // undefined
        console.log(myFunc); // [Function: myFunc]
      
        var myVar = "Hello";
        function myFunc() {}
      
        /** 
        During the creation phase, the following happens - 
        1. Variable Environment Creation
           1.1 Creates memory for variables and functions
           1.2 Variables initialized with undefined
           1.3 Functions stored in their entirety
        2. Scope Chain Establishment
           2.1 Creates scope chain for variable lookup
           2.2 Links to outer environments
        3. this Binding
           3.1 Determines value of this
           3.1 Affected by call site and function type
        */
      
  2. Execution Phase:

    • JavaScript begins executing code, with variables and functions now fully accessible within their respective scopes.

        let x = 10;
        function foo() {
            let y = 20;
            function bar() {
                let z = 30;
                console.log(x + y + z);
            }
            bar();
        }
        foo();
      
        /** 
        During the creation phase, the following happens - 
        1. Code is executed line by line
        2. Variable assignments performed
        3. Function calls trigger new execution contexts
        */
      
  3. Destruction Phase:

    • After executing, the context is popped off the call stack, freeing up memory.

2. Call Stack and Execution Context

JavaScript’s Call Stack is a data structure used to manage function calls and track Execution Contexts. When a function is called, its Execution Context is created and added to the top of the Call Stack. As functions complete, they are removed from the stack.

For example:

function first() {
  console.log('First function');
  second();
}
function second() {
  console.log('Second function');
  third();
}
function third() {
  console.log('Third function');
}

first();

Breakdown of Execution Context Creation on the Call Stack:

  1. The Global Execution Context is created first and pushed onto the stack.

  2. The first() function is called, so a new Function Execution Context for first() is created and added to the stack.

  3. The second() function is called inside first(), so second()'s Execution Context is created and added to the top of the stack.

  4. Similarly, third() is called, creating third()'s Execution Context on top of the stack.

  5. As third() completes, its Execution Context is removed, followed by second() and then first(), until only the Global Execution Context remains.

Call stack management also performs the following

  • Maintains execution context order

  • LIFO (Last In, First Out) structure

  • Stack frame per function call

  • Maximum call stack size limits recursion

3. Creation Phase: Variable Environment, Scope Chain, and this Binding

Variable Environment and Hoisting

During the creation phase, JavaScript performs hoisting, moving all variable and function declarations to the top of the scope. JavaScript essentially creates placeholders for these variables before code execution.

  • Function Declarations are fully hoisted, allowing functions to be called before their declaration.

  • Variable Declarations with var are hoisted but initialized with undefined.

  • Variables declared with let and const are also hoisted but remain uninitialized until their definition, placing them in a temporal dead zone (TDZ).

var x = 1;
let y = 2;

function varVsLet() {
    var x = 10;  // Function-scoped
    let y = 20;  // Block-scoped

    if (true) {
        var x = 100; // Same variable
        let y = 200; // New variable
        console.log(x, y); // 100, 200
    }

    console.log(x, y); // 100, 20
}

// Variable Environment stores var declarations
// Lexical Environment stores let/const declarations

Scope Chain

Each Execution Context has access to a Scope Chain, linking it to the parent execution context. This hierarchy is essential for closures and determines how variables are accessed in nested scopes.

Consider the example:

function outer() {
  let a = 10;
  function inner() {
    console.log(a);
  }
  inner();
}
outer();

Here, inner() has access to a due to the scope chain, which connects inner() to outer()’s lexical scope.

Block Scoping with let and const

function blockScopeDemo() {
    let x = 1;

    if (true) {
        let x = 2;  // Different variable
        const y = 3; // Block-scoped
        console.log(x, y); // 2, 3
    }

    console.log(x); // 1
    // console.log(y); // ReferenceError
}

this Binding

The this keyword behaves differently in various Execution Contexts:

  • In the Global Execution Context, this refers to the global object (window in the browser).

  • In a Function Execution Context, this refers to the object calling the function.

  • Arrow functions inherit this from the outer scope due to lexical scoping.

For example:

const obj = {
  name: 'JavaScript',
  regularFunction: function() {
    console.log(this.name);
  },
  arrowFunction: () => {
    console.log(this.name);
  }
};

obj.regularFunction(); // 'JavaScript'
obj.arrowFunction(); // undefined (inherits from global scope)

4. Execution Context and Closures

A closure is formed when an inner function retains access to the lexical scope of an outer function, even after the outer function has completed. This retained scope allows inner functions to access variables in their parent scopes.

Example:

function createCounter() {
  let count = 0;
  return function() {
    return ++count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

Here, counter retains access to count, forming a closure.

5. Practical Insights: Hoisting, TDZ, and let vs. var

Hoisting in Depth

Understanding hoisting can prevent unexpected behaviors in JavaScript. For example:

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

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 10;

Using let and const offers better control and avoids issues from hoisting and the temporal dead zone (TDZ), which prevents access before variable initialization.

6. Context Loss and Preservation

class MyClass {
    constructor() {
        this.value = 42;
    }

    regularMethod() {
        console.log(this.value);
    }

    arrowMethod = () => {
        console.log(this.value);
    }
}

const instance = new MyClass();
const regular = instance.regularMethod;
const arrow = instance.arrowMethod;

regular(); // undefined (context lost)
arrow();   // 42 (context preserved)

7. Context Loss and Preservation

class AsyncHandler {
    constructor() {
        this.data = 'Initial';
    }

    async regularAsync() {
        setTimeout(function() {
            console.log(this.data); // undefined
        }, 100);
    }

    async arrowAsync() {
        setTimeout(() => {
            console.log(this.data); // 'Initial'
        }, 100);
    }
}

8. this Binding and Context

8.1 Default Binding

function showThis() {
    console.log(this);
}

// In non-strict mode
showThis(); // window/global

// In strict mode
'use strict';
showThis(); // undefined

8.2 Implicit Binding

const obj = {
    name: 'Object',
    showThis() {
        console.log(this.name);
    }
};

obj.showThis(); // 'Object'

8.3 Explicit Binding

function display() {
    console.log(this.name);
}

const person = { name: 'John' };

display.call(person);   // John
display.apply(person);  // John
const bound = display.bind(person);
bound();               // John

Conclusion

Understanding JavaScript's Execution Context is crucial for writing robust and maintainable code. It affects everything from variable scope to this binding and is fundamental to features like closures and the module pattern. Mastering these concepts allows developers to write more predictable and efficient JavaScript code.

The complexity of execution contexts demonstrates why JavaScript is both powerful and sometimes counterintuitive. By understanding these mechanisms, developers can better anticipate behavior and avoid common pitfalls in their applications.

Did you find this article valuable?

Support Utkarsh Rajput by becoming a sponsor. Any amount is appreciated!