Using let declarations of variables and features of the resulting closures in JavaScript

I was encouraged to write this note by reading the article on the Habré "Var, let or const? Problems of the scope of variables and ES6 ” and the comments thereto, as well as the corresponding part of Zakas N.'s book “ Understanding of ECMAScript 6 ” . Based on what I read, I came to the conclusion that not everything is so simple in assessing the use of var or let . Authors and commentators are inclined to believe that in the absence of the need to support older versions of browsers, it makes sense to completely abandon the use of var , as well as use some simplified constructions, instead of the old ones, by default.



Enough has already been said about the scope of these ads, including in the above materials, so I would like to focus only on some unobvious points.



To begin with, I would like to consider expressions of immediately called functions (Immediately Invoked Function Expression, IIFE) in loops.

let func1 = []; for (var i = 0; i < 3; i++) { func1.push(function(i) { return function() { console.log(i); } }(i)); } func1.forEach(function(func) { func(); }); /*    0 newECMA6add.js:4:59 1 newECMA6add.js:4:59 2 newECMA6add.js:4:59 */
      
      





or you can do without them using let :



 let func1 = []; for (let i = 0; i < 3; i++) { func1.push(function() { console.log(i); }); } func1.forEach(function(func) { func(); }); /*     0 newECMA6add.js:4:37 1 newECMA6add.js:4:37 2 newECMA6add.js:4:37 */
      
      





Zakas N. claims that both similar examples, giving the same result, also work exactly the same:
"This loop works exactly like the loop that used var and an IIFE but is arguably cleaner"
which, however, he himself, a little further, indirectly refutes.



The fact is that each iteration of the loop when using let creates a separate local variable i and at the same time, the binding in the functions sent to the array also goes on separate variables from each iteration.



In this particular case, the result is really no different, but what if we complicate the code a bit?

 let func1 = []; for (var i = 0; i < 3; i++) { func1.push(function(i) { return function() { console.log(i); } }(i)); ++i; } func1.forEach(function(func) { func(); }); /*    0 newECMA6add.js:4:59 2 newECMA6add.js:4:59 */
      
      





Here, adding ++ i, our result turned out to be quite predictable, since we called the function with i values ​​that were relevant at the time of the call even when the loop itself passed, therefore the subsequent operation ++ i did not affect the value passed to the function in the array, since it already was closed in function (i) with a specific value of i .



Now compare with let- version without IIFE

 let func1 = []; for (let i = 0; i < 3; i++) { func1.push(function() { console.log(i); }); ++i; } func1.forEach(function(func) { func(); }); /*    1 newECMA6add.js:4:37 3 newECMA6add.js:4:37 */
      
      





The result, as you can see, has changed, and the nature of this change is that we did not call the function with the value immediately, but the function took the values ​​available in the closures at specific iterations of the cycle.



To better understand the essence of what is happening, consider examples with two arrays. And for starters, let's take var, without IIFE :

 let func1 = [], func2 = []; for (var i = 0; i < 3; i++) { func2.push(function() { console.log(++i); }); func1.push(function() { console.log(++i); }); ++i; } func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*    5 newECMA6add.js:6:37 6 newECMA6add.js:6:37 7 newECMA6add.js:5:37 8 newECMA6add.js:5:37 */
      
      





Everything is obvious here so far - there is no closure (although we can say that it is, but to the global scope, although this is not entirely correct, since access to i is essentially everywhere), i.e., similarly, but with a local area apparently, variable i will have a similar entry:

 let func1 = [], func2 = []; function test() { for (var i = 0; i < 3; i++) { func2.push(function() { console.log(++i); }); func1.push(function() { console.log(++i); }); ++i; } } test(); func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*     5 newECMA6add.js:7:41 6 newECMA6add.js:7:41 7 newECMA6add.js:6:41 8 newECMA6add.js:6:41 */
      
      





In both examples, the following happens:



1. At the beginning of the last iteration of the cycle i == 2 , then incremented by 1 (++ i) , and at the end 1 more is added from i ++ , As a result, at the end of the whole cycle i == 4 .



2. The functions located in the func1 and func2 arrays are called one by one , and in each of them the same variable i is sequentially incremented, which is in closure relative to its scope, which is especially noticeable when we are dealing not with a global variable, but with a local one.



Add IIFE .

First option:
 let func1 = [], func2 = []; for (var i = 0; i < 3; i++) { func2.push(function(i) { return function() { console.log(++i); } }(i)); func1.push(function(i) { return function() { console.log(++i); } }(i)); ++i; } func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*    1 newECMA6add.js:6:56 3 newECMA6add.js:6:56 1 newECMA6add.js:5:56 3 newECMA6add.js:5:56 */
      
      



The second option:
 let func1 = [], func2 = []; for (var i = 0; i < 3; i++) { func2.push(function(i) { return function() { console.log(i); } }(++i)); func1.push(function(i) { return function() { console.log(i); } }(++i)); ++i; } func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*    2 newECMA6add.js:6:56 1 newECMA6add.js:5:56 */
      
      





When adding IIFE in the first case, we simply called the fixed values ​​of i in function (i) ( 0 and 2 , during the first and second pass of the cycle, respectively), and incremented them by 1, each function is separate from the other, since here is the closure to a common variable there is no loop, due to the fact that the i value was transmitted immediately during the loop passes. In the second case, there is also no closure, but there the value was transmitted with simultaneous increment, so at the end of the first pass i == 4 , and the cycle did not go any further. But, I draw attention to the fact that separate closures for each function are still present in the first version:

 let func1 = [], func2 = []; for (var i = 0; i < 3; i++) { func2.push(function(i) { return function() { console.log(++i); } }(i)); func1.push(function(i) { return function() { console.log(++i); } }(i)); ++i; } func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*    1 newECMA6add.js:6:56 3 newECMA6add.js:6:56 1 newECMA6add.js:5:56 3 newECMA6add.js:5:56 2 newECMA6add.js:6:56 4 newECMA6add.js:6:56 2 newECMA6add.js:5:56 4 newECMA6add.js:5:56 */
      
      



note: even if you frame the cycle with a function, common closures naturally will not.



Now consider the let statement, without IIFE, respectively.

 let func1 = [], func2 = []; for (let i = 0; i < 3; i++) { func2.push(function() { console.log(++i); }); func1.push(function() { console.log(++i); }); ++i; } func1.forEach(function(func) { func(); }); func2.forEach(function(func) { func(); }); /*    2 newECMA6add.js:6:41 4 newECMA6add.js:6:41 3 newECMA6add.js:5:41 5 newECMA6add.js:5:41 */
      
      





And here, we again formed a closure, and not one, but two, and not separate, but common, which is logical, given the well-known principle of let in cycles.



As a result, we have that in the first closure, before calling the functions in the arrays, the value is i == 1 , and in the second i == 3 . These are the values ​​that the variable i received before i ++ and the loop iteration, but after all the instructions in the loop block, and they are closed for each specific iteration.



Next, the functions located in the array func1 are called and they increment the corresponding variables in both closures and as a result in the first i == 2 , and in the second i == 4 .



The subsequent call to func2 increments further and gets i == 3 and 5, respectively.



I deliberately put func2 and func1 inside the block in such a way that independence from their location was more clearly visible, and to emphasize the reader’s attention on the fact of closure.



In conclusion, I will give a trivial example aimed at reinforcing the understanding of closures and the scope of let :

 let func1 = []; { let i = 0; func1.push(function() { console.log(i); }); ++i; } func1.forEach(function(func) { func(); }); console.log(i); /* 1 newECMA6add.js:5:34 ReferenceError: i is not definednewECMA6add.js:10:1 */
      
      





What do we have in total



1. Invoking expressions of immediately called functions is not equivalent to using iterable let variables in functions in loops, and, in some cases, leads to different results.



2. Due to the fact that when using the let declaration for an iterator, a separate local variable is created in each iteration, the question arises about the disposal of unnecessary data by the garbage collector. At this point, I admit, I wanted to initially focus attention, suspecting that creating a large number of variables in large, respectively, loops would slow down the compiler, however, when sorting a test array using only let variable declarations, it showed a gain in execution time of almost two times for an array of 100,000 cells:

Option with var:
 const start = Date.now(); var arr = [], func1 = [], func2 = []; for (var i = 0; i < 100000; i++) { arr.push(Math.random()); } for (var i = 0; i < 99999; i++) { var min = arr[i]; var minind = i; for (var j = i + 1; j < 100000; j++) { if (min > arr[j]) { min = arr[j]; minind = j; } } var temp = arr[i]; arr[i] = arr[minind]; arr[minind] = temp; func1.push(function(i) { return function() { return i; } }(arr[i])); } func1.push(function(i) { return function() { return i; } }(arr[99999])); for (var i = 0; i < 100000; i++) { func2.push(func1[i]()); } const end = Date.now(); console.log((end - start)/1000); // 9.847
      
      







And the option with let:
 const start = Date.now(); let arr = [], func1 = [], func2 = []; for (let i = 0; i < 100000; i++) { arr.push(Math.random()); } for (let i = 0; i < 99999; i++) { let min = arr[i]; let minind = i; for (let j = i + 1; j < 100000; j++) { if (min > arr[j]) { min = arr[j]; minind = j; } } let temp = arr[i]; arr[i] = arr[minind]; arr[minind] = temp; func1.push(function() { return arr[i]; }); } func1.push(function() { return arr[99999]; }); for (let i = 0; i < 100000; i++) { func2.push(func1[i]()); } const end = Date.now(); console.log((end - start)/1000); // 5.3
      
      







At the same time, the execution time was practically independent of the presence / absence of instructions:



with IIFE
 func1.push(function(i) { return function() { return i; } }(arr[i]));
      
      





or
without IIFE
 func1.push(function() { return arr[i]; });
      
      





and
function call
 for (var i = 0; i < 100000; i++) { func2.push(func1[i]()); }
      
      







Note: I understand that the information on speed is not new, but for completeness I think these two examples were worth giving.



From all this we can conclude that the use of let declarations instead of var , in applications that do not require backward compatibility with earlier standards, is more than justified, especially in cases with loops. But, at the same time, it is worth remembering the features of behavior in situations with closures and, if necessary, continue to use expressions of immediately called functions.



All Articles