`let`和块级作用域在for循环中的解释

57

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

2
你在问题中已经说得很清楚了,let 在每次循环迭代时本质上都会重新评估。我不知道我是否应该称其为语法糖,这只是循环定义的工作方式。 - loganfsmyth
可能是重复的问题:Javascript - "let" 关键字 vs "var" 关键字 - user663031
请参阅https://dev59.com/pXRA5IYBdhLWcg3w9y50。 - user663031
阅读以下两篇文章:ECMAScript 6中的LET - 块级作用域变量ECMAScript 6中的常量 - Anton Temchenko
我理解的是,在这里:let i = 100; setTimeout(function () { console.log(i) }, i); // '100' after 100 ms 我们也可以使用 var i = 100 吗? - Marek
6个回答

71
这只是ES6的语法糖吗? 不,它不仅仅是语法糖。详细信息请参阅§13.6.3.9的CreatePerIterationEnvironment。 这是如何工作的? 如果在for语句中使用let关键字,它将检查它绑定的名称,然后: 创建一个新的词法环境,其中包含这些名称,用于a)初始化表达式b)每次迭代(之前评估增量表达式) 从所有具有这些名称的变量中复制值到下一个环境 您的循环语句for (var i = 0; i < 10; i++) process.nextTick(_ => 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 < 10; i++) process.nextTick(_ => 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++;
          …

1
这可能比我的答案更正确,而且肯定更简洁。 - ssube
1
@Bergi 很喜欢你的答案,但我仍然不能真正想象执行上下文会是什么样子。如果你有时间的话,能否请编辑你的回答以展示它将会是什么样子? - Kostas Dimakis
@KonstantinosDimakis 你指的是哪个上下文? - Bergi
@Bergi 我还有几个问题:1、在__status = {i};中,_status{i}是什么意思?2、在每次循环中,都会有一个新的名为i的变量吗?我无法理解。一开始,我以为在每次循环中,i会被更新并传递到函数process.nextTick()中。在函数process.nextTick中,会生成一个新的变量,并且具有与i相同的值?我错了吗?谢谢 - BAE
1
@Bergi,我不确定我是否正确理解了你的问题。我的意思是,你的解释并没有反映出这样一个事实:for (let i = 0; i < 10; ++i) { let i = 'a string'; process.nextTick(_ => console.log(i)) } 不会中断流程,而是产生了10个 a string。为了反映这个事实,你需要通过用括号将 let i = 'a string'; process.nextTick(_ => console.log(i)); 包含在自己的作用域中来嵌套它。 - Min-Soo Pipefeet
显示剩余13条评论

24

我认为Exploring ES6这本书中的这个解释是最好的:

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

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

三个箭头函数中的每个 i 引用相同的绑定,因此它们都返回相同的值。

如果您使用 let 声明一个变量,则为每个循环迭代创建新的绑定:

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

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

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


如果你将i的值赋为arr.length,并且当然删除了增量i++,它仍然能正常工作吗? - mjfneto
这个答案比被选中的那个更容易理解。 - Chinmay Ghule

9
let引入了块级作用域和等效绑定,就像函数使用闭包创建作用域一样。我认为规范的相关部分是13.2.1,其中的注释提到let声明是LexicalBinding的一部分,并且都存在于Lexical Environment中。第13.2.2节指出,var声明附加到VariableEnvironment而不是LexicalBinding。 MDN解释也支持这一点,指出:

它通过在单个代码块的词法作用域中绑定零个或多个变量来工作

这表明变量绑定到块,每次迭代需要一个新的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 大多数浏览器无法运行第一个代码片段,因为它们通常不支持let和箭头函数。 - ssube
1
我不得不多次阅读规范——我仍然认为我没有完全理解,但查看Babel的输出有所帮助。本质上,使用let在每个迭代中创建一个新作用域。我想这只是内置到语言中的。Babel也不喜欢混合声明 let i = 0, var j = 1,无论如何都是不兼容的。 - Explosion Pills
@ExplosionPills 有关作用域(/环境/绑定)的规范往往会变得有点晦涩。我仍然很难理解它,但是看起来有效的结果是每次迭代都有自己的绑定。Babel / ES6也不允许您混合“let a = 1,const b = 2”:您不能在语句中混合不同类型的声明。 - ssube

0

let 是块级作用域。 在 for 循环内部声明的 var 变量可以在 for 循环外部访问,因为 var 只是函数作用域。你无法从外部访问在函数内部定义的 var 变量。 每次迭代都会创建一个新的 let。但由于 var 是函数作用域并且在 for 循环外部可用,它有点像全局变量,并且随着每次迭代,相同的 var 变量会被更新。


欢迎来到 Stack Overflow!请确保您的回答中始终包含代码!如何撰写好的答案? - William Brochensque junior
你的回答可以通过提供更多支持信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人可以确认你的答案是正确的。您可以在帮助中心找到有关如何编写良好答案的更多信息。 - Community

0

最近我也对这个问题感到困惑。根据以上答案,这是我的理解:

for (let i=0;i<n;i++)
{
   //loop code
}

等同于

// initial
{
    let i=0
}
// loop
{
    // Sugar: For-Let help you to redefine i for binding it into current block scope
    let i=__i_value_from_last_loop__

    if (i<=n){
        //loop code
    }
    i++
}

0
让我们来看看在面试中经常被问到的setTimeout函数中的“let”和“var”。
(function timer() { 
   for (var i=0; i<=2; i++) 
       { setTimeout(function clog() {console.log(i)}, i*1000); } 
    })();

(function timer() { 
   for (let i=0; i<=2; i++) 
       { setTimeout(function clog() {console.log(i)}, i*1000); } 
    })();

让我们详细了解一下这段代码在JavaScript编译器中的执行过程。 “var”的答案是“222”,因为它具有函数作用域,而“let”的答案是“012”,因为它具有块级作用域。

现在让我们详细看一下当它编译“var”时的情况。(在代码上解释比在音频或视频上解释有点困难,但我会尽力给你最好的解释。)

var i = 0;

if(i <=2){
setTimeout(() => console.log(i));
}
i++;  // here the value of "i" will be 1

if(i <=2){
setTimeout(() => console.log(i));
}
i++;   // here the value of "i" will be 2

if(i <=2){
setTimeout(() => console.log(i));
}
i++;  // here the value of "i" will be 3

代码执行后,最终将打印所有控制台日志,其中“i”的值为6。因此,最终输出为:222

在“let i”中,将在每个作用域中声明。 需要注意的重要点是“i”将从上一个作用域获取值而不是从声明中获取值。(下面的代码只是演示编译器中的外观,尝试它不起作用)

{
    //Scope  1
    { 
    let i;  
    i= 0;
    
    
    if(i<=2) {
        setTimeout(function clog() {console.log(i)};);
    }
    i++;   // Here "i" will be increated to 1
    
    }
    
    //Scope 2  
    // Second Interation run
    {
    let i;
    i=0;
    
        // Even “i” is declared here i= 0 but it will take the value from the previous scope
    // Here "i" take the value from the previous scope as 1
    if(i<=2) {    
        setTimeout(function clog() {console.log(i)}; );
    }
    
    i++;   // Here “i” will be increased to 2
    
    }
    
    
    //Scope 3 
    // Second Interation run
    {
    let i;
    i=0;
    
    // Here "i" take the value from the previous scope as 2
    if(i<=2) {   
        setTimeout(function clog() {console.log(i)}; );
    }
    
    i++;   // Here "i" will be increated to 3
    
    }
    

}

所以,它将根据块作用域打印“012”值。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接