Understanding The Underlying Processes of JavaScript’s Closures And Scope Chain

Publikováno: 5.7.2018

When developers start exploring the JavaScript programming language, the concept of Scope and Closures can be expected to be a hurdle to their progress. The reason behind this behavior is the compl...

Celý článek

When developers start exploring the JavaScript programming language, the concept of Scope and Closures can be expected to be a hurdle to their progress. The reason behind this behavior is the complexity of the other concepts that lie under the hood of this feature in JavaScript.

However, a solid understanding of JavaScript’s Scope and Closures is so important that a good grasp of the concept will significantly augment the developer’s knowledge and prepare him/her for many years of writing elegant code.

In his book: You Don't Know JS: Scope & Closures, Kyle Simpson took the time to go over the concept in detail, treating each underlying logic as a single entity that merits intense study.

Why do we need Scopes?

For us to see the need for a scoping mechanism, we will use variables as a case study. The ability to store values in variables, and later retrieve and modify those values is a fundamental property of virtually every programming language.

The variable themselves need to be stored in a way that makes it easy for them to be found during run-time, and it is this idea that suggests the need for a scoping mechanism.

The Scope is a well-defined set of rules for storing variables in a location and finding those variables at a later time. It is a lookup list of all the declared identifiers (variables), and it enforces a strict set of rules that determine how these variables are accessed during code execution.

JavaScript is generally categorized as a dynamic or interpreted language. However, it is, in fact, a compiled language. The subtle difference here is that it is neither compiled in advance as the conventional compiled languages are, nor are the results of its compilation immediately portable across various distributed systems.

What happens in the case of JavaScript is: just before execution, tokenizing/lexing and parsing are carried out by the compiler. However, the process of generating executable code is carried out in a unique manner.

Let's consider this variable declaration snippet:

   var a;

Behind the scenes, the compiler checks that particular scope collection for a variable with the identifier a. If a already exists within the scope, the compiler ignores the declaration and moves on. Otherwise, the compiler declares a new variable with the identifier a for that scope collection.

Next, the compiler generates the code for the JavaScript engine — responsible for start-to-finish compilation and execution of the JavaScript program — to execute.

Let's consider this variable assignment snippet:

   a = 2;

Let's Imagine that this code is under the scope collection of the first snippet we looked at. For the compiler to execute this code, it first checks that the variable a is accessible within the current scope. If it is accessible, the compiler uses that variable and performs the assignment instruction, if it isn't accessible, the compiler traverses the scope chain (we will discuss nested scopes in the next session) until it finds a scope where a exists and is accessible.

For more information on how the JavaScript engine checks the scope and handles look up, you can read Kyle Simpson’s book here.

We will now look at different kind of scopes.

Nested Scope

As the name implies, the nested scope refers to a scope that is ‘placed’ within another scope. This is usually possible with functions.

Let’s consider this example:

  function foo(a) {
    console.log(a * b);
   }
  var b = 3;
  foo(1);//3

When we run this program, we notice that the value of b cannot be resolved inside the foo function but it can be resolved in the Scope surrounding it.

Lexical Scope

The lexical scope refers to where variables and blocks are authored by the programmer at write time, this is (usually) set in stone when the lexer (handles the tokenizing/lexing phase of compilation) processes the code.

Let’s consider this block of code:

function foo(a) {
  var b = a + 2;
  function bar(c) {
    console.log(a, b, c);
}
  bar(b * 2);
}

foo(3); // 3, 5, 10

There are three scopes present in this example:

  • the global scope, which has just one identifier in it - foo
  • the scope of foo, which includes the three identifiers - a, bar and b.
  • the scope of bar, and it includes just one identifier - c

Function and Block Scope

Function scope supports the idea that all variables belong to a function and can be re-used throughout the lifetime of a function (the variables are even accessible to the nested scopes).

Let’s consider this code snippet, the scope of foo(..) includes identifiers a, b, c and bar:

    function foo(a) {
      var b = 2;
      //some code

      function bar() {
        //...
      } 
      //more code
      var c = 3;
    }

It doesn’t matter where in the scope a declaration appears, the variable or function belongs to the containing scope, regardless. All identifiers directly inside foo(..) are accessible within its scope, and also available within bar(..) (assuming there are no shadow identifier declarations inside of bar(..)).

This design approach is really useful since it utilizes JavaScript's dynamic nature that enables it to take on values of different types as needed. However, without careful precautions, variables existing across the entirety of a scope can lead to some unexpected pitfalls.

Block scoping, on the other hand, refers to the idea that the variables within a particular block in a program are under the block’s scope. A block is JavaScript is usually a {..} pair.

Before the advent of ES6 in 2015, there was a major issue with block scoping in JavaScript. This issue was due to the fact that the scope in which a variable was declared (using the var keyword) didn’t matter, because it always belonged to the enclosing scope.

Consider the example below:

var foo = true;

if (foo) {
  var bar = foo * 2;
  bar = something(bar);
  console.log(bar);
}

This snippet is essentially categorized as ‘fake’ block-scoping because the variable bar is still accessible outside the if statement. A workaround to this behavior would be declaring variables as close as possible, as local as possible, to where they will be used. Among the several block-scoping structures in JavaScript, which include: with, try/catch, let, const and garbage collection, we will focus on let and const which were introduced with ES6 as two ways of enforcing block-scoping.

The let keyword attaches the variable declaration to the scope of whatever block (usually a {..} pair) it is contained in:

var foo = true;

if (foo) {
  let bar = foo * 2;
  bar = something(bar);
  console.log(bar);
}

console.log(bar);//ReferenceError

Using let to attach a variable to an existing block is somewhat implicit. Hence attention should be paid to which blocks have their variables scoped to them as programmers develop and evolve their code. The creation of explicit blocks can tackle some of these issues, making it obvious where variables are attached and where they are not. This is preferable as it is easier to achieve, and fits more naturally with how block-scoping works in other languages:

var foo = true;

if (foo) {
  {//<--explicit block
    let bar = foo * 2;
    bar = something(bar);
    console.log(bar);
  }
}
console.log(bar); //ReferenceError

As shown in the code snippet above, we can create an arbitrary block for let to bind to, by simply including a {..} pair anywhere a statement is a valid grammar. The explicit block inside the if statement will make refactoring easier without affecting the position and semantics of the surrounding if statement. In addition to let, const also creates a block-scoped variable, but whose value is fixed (a constant). An attempt to change this value results in an error:

var foo = true;

if (foo) {
  var a = 2;
  const b = 3; //block-scoped to the containing if

  a = 3; //Just fine!
  b = 4; //error!
} 
console.log(a); //3
console.log(b);//ReferenceError

Hoisting

Consider the code below:

a = 2;
var a;
console.log(a);

The resulting output is 2, contrary to what is expected by basic programming logic where it should return undefined because the variable was initialized before it was declared.

Consider another piece of code:

console.log(a);
var a = 2;

This code outputs undefined.

The reason for this behavior can be explained by understanding how the JavaScript Engine executes code. The Engine first compiles the code before it interprets it. This means that variables and functions are processed first before any other part of the code is executed. The concept of ‘Hoisting’ refers to the idea that variable and function declarations are “lifted” from where they appear in the code flow to the top of the code.

It is important to note that function declarations are hoisted before variable declarations are.

Consider the code below:

foo(); //1
var foo;

function foo() {
  console.log(1);
}
foo = function() {
  console.log(2);
};

The resulting output is 1 instead of the expected 2

Closures

A Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope. Let’s look at some code to illustrate this definition:

function foo() {
  var a = 2;
  function bar() {
    console.log(a); //2
  }
  bar();
}
foo();

The function bar has a closure over the scope of foo() and other enclosing scopes, such as the global scope in this case. Put slightly differently, it’s said that bar() closes over the scope of foo(). Why? Because bar() appears nested inside of foo(). Let’s look at another example for a better understanding:

function foo(){
  var a = 2;

  function bar() {
    console.log(a);
  }
  return bar;
}
var baz = foo();
baz();//2

The function bar() has lexical scope access to the inner scope of foo(). bar() is passed as a value by returning the function object that bar references. After we execute foo(), we assign the value it returned (the inner bar() function) to a variable called baz, and then we invoke baz(), which is invoking the inner bar() function. This causes bar() to be executed, but in this case, outside its declared lexical scope.

bar has a lexical scope closure over that inner scope of foo() which keeps that scope alive for bar() to reference at any later time. bar() has a reference to that scope, and that reference is called a closure. Therefore, when baz is invoked (invoking the inner function labeled bar), it has access to its author-time lexical scope, so it can access the variable a. Closure makes it possible for a function to continue to access the lexical scope it was defined in at author-time.

Conclusion

We have taken a brief tour over the concept of Closures and Scopes in JavaScript. JavaScript programmers can’t afford to ignore this feature because it defines the process of variable lookup during code execution. Without the strict rules that are defined by the scope, the code we write will produce rather clumsy and unexpected results at run-time. We also looked at the scoping benefits that come with using the let and const keywords. If reading this article has piqued your interest on the topic of Scope and Closures, you can learn more about it here.

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace