关于javascript:解释`let`并使用for循环阻止作用域

Explanation of `let` and block scoping with for loops

我知道,let防止重复声明,这很好。

1
2
let x;
let x; // error!

let声明的变量也可用于预期的闭包中。

1
2
let i = 100;
setTimeout(function () { console.log(i) }, i); // '100' after 100 ms

我有点难以理解的是let如何应用于循环。这似乎是特定于for循环的。考虑经典问题:

1
2
3
4
// prints '10' 10 times
for (var i = 0; i < 10; i++) { process.nextTick(_ => console.log(i)) }
// prints '0' through '9'
for (let i = 0; i < 10; i++) { process.nextTick(_ => console.log(i)) }

为什么在这种情况下使用let有效?在我的想象中,即使只有一个块可见,for实际上为每个迭代创建了一个单独的块,并且let声明是在该块内部完成的…但是只有一个let声明来初始化该值。这只是ES6的句法糖吗?这是怎么工作的?

我了解varlet之间的区别,并在上面进行了说明。我特别感兴趣的是理解为什么不同的声明使用for循环会导致不同的输出。


Is this just syntactic sugar for ES6?

不,这不仅仅是句法上的糖分。血淋淋的细节见第13.6.3.9节。CreatePerIterationEnvironment

How is this working?

如果在for语句中使用该let关键字,它将检查它绑定的名称,然后

  • 为a)初始化器表达式b)每次迭代(预先计算增量表达式)创建一个具有这些名称的新词汇环境。
  • 将具有这些名称的所有变量的值从一个环境复制到下一个环境

你的循环语句for (var i = 0; i < 10; i++) process.nextTick(_ => console.log(i));脱硫到一个简单的

1
2
3
4
5
6
7
8
9
10
// omitting braces when they don't introduce a block
var i;
i = 0;
if (i < 10)
    process.nextTick(_ => console.log(i))
    i++;
    if (i < 10)
        process.nextTick(_ => console.log(i))
        i++;
        …

for (let i = 0; i < 10; i++) process.nextTick(_ => console.log(i));对更为复杂的问题做了"脱硫"。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// using braces to explicitly denote block scopes,
// using indentation for control flow
{ let i;
  i = 0;
  __status = {i};
}
{ let {i} = __status;
  if (i < 10)
      process.nextTick(_ => console.log(i))
      __status = {i};
}   { let {i} = __status;
      i++;
      if (i < 10)
          process.nextTick(_ => console.log(i))
          __status = {i};
    }   { let {i} = __status;
          i++;
          …


let引入了块作用域和等效绑定,就像函数创建一个带闭包的作用域一样。我相信规范的相关章节是13.2.1,其中注释提到let声明是词汇结合的一部分,并且两者都生活在词汇环境中。第13.2.2节规定,var声明附在可变环境中,而不是附在词典中。

MDN解释也支持这一点,并指出:

It works by binding zero or more variables in the lexical scope of a single block of code

建议变量绑定到块,每次迭代都会有所不同,需要新的词汇绑定(我相信,在这一点上不是100%),而不是在调用期间保持不变的周围词汇环境或可变环境。

简而言之,当使用let时,闭包在循环体上,变量每次都不同,因此必须再次捕获。当使用var时,变量位于周围函数处,因此不需要重新关闭,每次迭代都传递相同的引用。

调整示例以在浏览器中运行:

1
2
3
4
5
6
7
8
9
// prints '10' 10 times
for (var i = 0; i < 10; i++) {
  setTimeout(_ => console.log('var', i), 0);
}

// prints '0' through '9'
for (let i = 0; i < 10; i++) {
  setTimeout(_ => console.log('let', i), 0);
}

当然会显示后一个值的打印。如果你看看巴别塔是如何产生这些的,它会产生:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (var i = 0; i < 10; i++) {
  setTimeout(function(_) {
    return console.log(i);
  }, 0);
}

var _loop = function(_i) {
  setTimeout(function(_) {
    return console.log(_i);
  }, 0);
};

// prints '0' through '9'
for (var _i = 0; _i < 10; _i++) {
  _loop(_i);
}

假设babel是相当一致的,这符合我对规范的解释。


我从ES6的书中找到了最好的解释:

var-declaring a variable in the head of a for loop creates a single
binding (storage space) for that variable:

1
2
3
4
5
const arr = [];
for (var i=0; i < 3; i++) {
    arr.push(() => i);
}
arr.map(x => x()); // [3,3,3]

Every i in the bodies of the three arrow functions refers to the same
binding, which is why they all return the same value.

If you let-declare a variable, a new binding is created for each loop
iteration:

1
2
3
4
5
6
const arr = [];
for (let i=0; i < 3; i++) {
    arr.push(() => i);
}

arr.map(x => x()); // [0,1,2]

This time, each i refers to the binding of one specific iteration and
preserves the value that was current at that time. Therefore, each
arrow function returns a different value.