let 与 var 在 for 循环中的区别

let 与 var 在 for 循环中的区别

场景

今天遇到的一个很有趣的问题,下面两段 js 代码执行的结果是什么?

1
2
3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}

1
2
3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}

嗯,乍看之下好像没什么区别,只有声明方式 letvar 不一样而已。

分析

这里先说一下吾辈两个关于 js 的认知

  1. js 里 setTimeout 如果延迟时间为 0 应该会立刻执行
  2. js 里的 for 循环和 java 应该差不多,for 循环内部是单独的作用域

图解如下

js for 循环和 setTimeout 理解

那么答案只有一个,两段代码执行的结果应该都是 0 1 2 才对!O(≧▽≦)O

然而当吾辈执行后的结果却是

  • let: 0 1 2
  • var: 3 3 3

发生了什么?吾辈表示很无语。。。┐( ̄ヮ ̄)┌

解答

然而,上面的两个认知全错了!

其一:js 里 setTimeout 如果延迟时间为 0 应该会立刻执行

好吧,异步没有 立刻执行 这个说法,js 中异步函数实际上是被 事件队列 所管理的。当使用 setTimeout 函数时,即便延迟为 0,函数 () => console.log(i) 也不会立刻执行,而是会被放到 事件队列 中去,然后等待浏览器空闲之后执行。

MDN 上有一段关于零延迟的描述

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数。
其等待的时间取决于队列里待处理的消息数量。在下面的例子中,”this is just a message” 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。

所以 setTimeout 实际上并没有立刻执行,而是等到整个 for 循环结束之后才执行的。

其二:js 里的 for 循环和 java 应该差不多,for 循环内部是单独的作用域

好吧,这个认知更是错的一塌糊涂,for 循环居然没有块级作用域?i 和 k 都是可以直接访问的,犹如直接声明到 for 循环外一样。

1
2
3
4
5
6
7
for (var i = 0; i < 3; i++) {
var k = 10 - i
}
console.log(`i: ${i}, k: ${k}`)

// 结果:
// i: 3, k: 8

相当于

1
2
3
4
5
6
var i = 0
var k
for (; i < 3; ) {
k = 10 - i
i++
}

如果换成 let 则两者都无法访问

1
2
3
4
5
6
7
for (let i = 0; i < 3; i++) {
let k = 10 - i
}
console.log(`i: ${i}, k: ${k}`)

// 结果:
// Uncaught ReferenceError: i is not defined

甚至还有一个更有趣的情况,在 for 的表达式和块中可以声明相同的变量,这只说明了一件事,let 声明的变量和循环内部声明的变量不在同一个作用域中!

1
2
3
4
5
6
7
8
9
10
11
12
for (var i = 0; i < 3; console.log('in for expression', i), i++) {
let i
console.log('in for block', i)
}

// 结果:
// in for block undefined
// in for expression 0
// in for block undefined
// in for expression 1
// in for block undefined
// in for expression 2

或许,i 只是加了新的作用域,就像下面这样,如此,循环外面就访问不到内部的值,循环内部和 for 的表达式也同样不在一个作用域了,每次循环结束就更新这个值

1
2
3
4
5
6
for (var i = 0; i < 3; i++) {
;(_i => {
setTimeout(() => console.log(_i), 0)
i = _i
})(i)
}

附:这里吾辈是根据 babel 编译的结果修改而来。而且 babel 真的很聪明,当迭代变量 i 没有更新时,就不会使用 _i 进行区分呢!

解决

重新建立了自己的认知之后,可以再对 let/var 在 for 循环进行分析了。

首先是 let + for

let + for

再看下面这段代码,可以对其进行分解

1
2
3
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
  1. 创建 for 循环,表达式中存在 let 变量,for 将会创建一个块级作用域(ES6 let 专用)
  2. 每次迭代时,会创建一个子块级作用域,迭代变量 i 也会重新生成
  3. 对 i 的任何操作,都会被记住并赋值给下一次的迭代

块级作用域只对 let 有效,var 声明的变量仍然能在 for 循环外使用,证明 for 循环并不是像函数作用域那样是连 var 都能封闭的作用域。

图解如下

let + for 图解

var + for

分析一下

1
2
3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
  1. 进入 for 循环
  2. 在这里创建了迭代变量 i,因为是函数作用域变量所以在 for 循环外可以访问,被提升到了函数作用域顶部声明
  3. setTimeout 函数执行,闭包绑定函数作用域外部变量 i,在循环结束输出 i 的值 3
  4. 继续迭代

var + for 图解


所以以后如果可能,还是要拥抱这些新特性呢!那么,关于 let/varfor 循环中的区别就到这里啦