【问题标题】:Explanation of `let` and block scoping with for loops用 for 循环解释 `let` 和块作用域
【发布时间】:2020-11-24 19:47:52
【问题描述】:

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

let x;
let x; // error!

let 声明的变量也可以用在可以预期的闭包中

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

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

// 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 循环时不同的声明会导致不同的输出。

【问题讨论】:

标签: javascript ecmascript-6


【解决方案1】:

这只是 ES6 的语法糖吗?

不,它不仅仅是语法糖。血淋淋的细节埋在§13.6.3.9 CreatePerIterationEnvironment

这是如何工作的?

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

  • 使用这些名称创建一个新的词法环境 a) 初始化表达式 b) 每次迭代(之前评估增量表达式)
  • 将具有这些名称的所有变量的值从一个环境复制到下一个环境

您的循环语句 for (var i = 0; i &lt; 10; i++) process.nextTick(_ =&gt; console.log(i)); 简化了

// 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 &lt; 10; i++) process.nextTick(_ =&gt; console.log(i)); 对更复杂的事物进行“脱糖”

// 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++;
          …

【讨论】:

  • 这可能比我的回答更正确,当然也更简洁。
  • @Bergi 喜欢您的回答,但我仍然无法真正想象执行上下文的样子。如果您有时间,您能否编辑您的答案以显示它的样子?
  • @KonstantinosDimakis 你指的是哪个上下文?
  • @Bergi 那个let
  • 它只是一个包含一个变量i的范围。它被闭包引用,它引用循环所在的范围作为其外部链接。
【解决方案2】:

我发现this explanation from Exploring ES6 book 最好:

var-在 for 循环的头部声明一个变量会创建一个 该变量的绑定(存储空间):

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

三个箭头函数体内的每个 i 都指向同一个 绑定,这就是为什么它们都返回相同的值。

如果你 let-declare 一个变量,每个循环都会创建一个新的绑定 迭代:

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

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

这一次,每个 i 指的是一个特定迭代的绑定,并且 保留当时的当前值。因此,每个 箭头函数返回不同的值。

【讨论】:

  • 如果您将arr.length 的值归因于i 并且当然删除了i++ 的增量,它仍然有效吗?
  • 这个答案比选择的答案容易理解。
【解决方案3】:

let 引入了块作用域和等效绑定,就像函数创建带闭包的作用域一样。我相信规范的相关部分是13.2.1,其中注释提到let 声明是LexicalBinding 的一部分,并且都存在于Lexical Environment 中。 13.2.2 部分指出 var 声明附加到 VariableEnvironment,而不是 LexicalBinding。

MDN explanation 也支持这一点,声明:

它通过在单个代码块的词法范围内绑定零个或多个变量来工作

建议将变量绑定到块,每次迭代都需要新的 LexicalBinding (我相信,在这一点上不是 100%),而不是周围的 Lexical Environment 或 VariableEnvironment 在持续时间内保持不变打电话。

总之,在使用let的时候,闭包在循环体,每次的变量都不一样,所以必须重新捕获。使用var时,变量在环绕函数,所以不需要重新关闭,每次迭代都传递相同的引用。

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

// 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);
}

当然显示后者打印每个值。如果你看看 Babel 是如何编译它的,它会产生:

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 相当一致,这符合我对规范的解释。

【讨论】:

  • @TinyGiant 大多数浏览器无法运行第一个 sn-p,因为它们往往不支持 let 和 arrow 函数。
  • 我不得不多次阅读规范——我仍然认为我没有完全理解,但查看 Babel 的输出会有所帮助。本质上使用let 确实会在每次迭代中创建一个新范围。我想这只是内置在语言中。 Babel 也不喜欢混合声明 let i = 0, var j = 1,这无论如何都是不兼容的。
  • @ExplosionPills 范围(/environments/bindings)的规范往往有点迟钝。我仍然很难理解它,但似乎有效的结果是每次迭代都有自己的绑定。 Babel/ES6 也不允许你混合let a = 1, const b = 2:你只是不能在一个语句中混合不同的声明类型。
猜你喜欢
  • 2019-04-26
  • 2016-05-13
  • 2015-12-13
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多