你真的完全理解for中的let了吗,连Babel都不对!细说for的黑魔法

191 阅读5分钟

你的第一感觉以下代码会输出什么呢?

for(let i = 0 , _s = setTimeout(() => console.log('for1', i)); 
    setTimeout(() => console.log('for2', i)), i < 5; 
    setTimeout(() => console.log('for3', i))
    ) {
    setTimeout(() => console.log('i', i))
    i++
}

实际运行结果如下:

for1 0
for2 1
i 1
for3 2
for2 2
i 2
for3 3
for2 3
i 3
for3 4
for2 4
i 4
for3 5
for2 5
i 5
for3 5
for2 5

如果你预计for1、for2、for3都会输出5的话,恭喜你,Babel也是这么想的:

// 上述JavaScript代码的babel编译结果
"use strict";

var _loop = function _loop(_i, _s) {
  setTimeout(function () {
    return console.log('i', _i);
  });
  _i++;
  i = _i;
};

for (var i = 0, _s = setTimeout(function () {
  return console.log('for1', i);
}); setTimeout(function () {
  return console.log('for2', i);
}), i < 5; setTimeout(function () {
  return console.log('for3', i);
})) {
  _loop(i, _s);
}

Babel版运行结果:

for1 5
for2 5
i 1
for3 5
for2 5
i 2
for3 5
for2 5
i 3
for3 5
for2 5
i 4
for3 5
for2 5
i 5
for3 5
for2 5

与直接运行结果并不一致。 特别是直接运行结果最后连续输出5个5令人费解。

先说闭包

function f() {
	let i = 0
  setTimeout(() => console.log(i))
  i++
}

闭包在JavaScript中可以解释为函数以及函数引用的上级作用域中的变量,上述例子就形成了闭包。 这里的函数是指箭头函数() => console.log(i),引用的变量则是i 在本篇中通过使用闭包可以在for循环全部结束后仍然保留对当前作用域下i的引用,从而让我们窥视for循环中作用域的分布。

从头开始

起源是我看见知乎上的一篇文章我用了两个月的时间才理解 let 简单回顾一下let的基础知识:

for(var i = 0; i < 5; i++) {
  	// 依次输出5 5 5 5 5
    setTimeout(() => console.log(i))
}

for(let i = 0; i < 5; i++) {
  	// 依次输出0 1 2 3 4
    setTimeout(() => console.log(i))
}

这里无论是var还是let我们都只声明了一个变量,let本身并不能解释为何两者输出不一致。 真正的原因是for循环中的黑魔法。

var a = [];
for (let i = 0; i < 10; i++) {
a[i] = function () {
console.log(i);
};
}
a[6](); // 6

上面代码中,变量ilet声明的,当前的i只在本轮循环有效,所以每一次循环的i其实都是一个新的变量,所以最后输出的是6。                                ————《ECMAScript 6 入门》

for循环中可以近似为:

for(let i = 0; i < 5; i++) {
    // 每次循环重新声明
  	let _i = i
  	// 依次输出0 1 2 3 4
    setTimeout(() => console.log(_i))
}

但是我们都知道在循环体中对i重新赋值的话是可以影响到下一轮以及第二、三个表达式中的i的,所以在每轮循环后还需要将_i的值赋值给真实的i

for(let i = 0; i < 5; i++) {
    // 每次循环重新声明
  	let _i = i
    
  	// 依次输出0 1 2 3 4
    setTimeout(() => console.log(_i))
  	_i++
  
  	// 循环体结束后将块级作用域的变量重新赋值到真实的变量
  	i = _i
}

于是我猜测这里假设的真实变量就是我们在for语句中第一个表达式声明的let i = 0,而第二、三表达式中的i < 5; i++则跟第一个表达式中的变量是同一个声明。

《ECMAScript 6 入门》中写到:

另外,for循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。

for (let i = 0; i < 3; i++) {
let i = 'abc';
console.log(i);
}
// abc
// abc
// abc

上面代码正确运行,输出了 3 次abc。这表明函数内部的变量i与循环变量i不在同一个作用域,有各自单独的作用域。

现在我们回到一开始的代码,Babel的编译完全符合我们至今的猜想:

  1. 使用每次循环体中重新声明变量
  2. 循环体结束后对真实的变量重新赋值
  3. for括号三个表达式中的变量为同一个声明
// 上述JavaScript代码的babel编译结果
"use strict";

// 使用函数作用域重新声明了_i
var _loop = function _loop(_i, _s) {
  setTimeout(function () {
    return console.log('i', _i);
  });
  _i++;
  // 循环体结束后对真实的i重新赋值
  i = _i;
};

// 三个表达式中的i为同一个声明
for (var i = 0, _s = setTimeout(function () {
  return console.log('for1', i);
}); setTimeout(function () {
  return console.log('for2', i);
}), i < 5; setTimeout(function () {
  return console.log('for3', i);
})) {
  _loop(i, _s);
}

分析真实运行结果

for(let i = 0 , _s = setTimeout(() => console.log('for1', i)); 
    setTimeout(() => console.log('for2', i)), i < 5; 
    setTimeout(() => console.log('for3', i))
    ) {
    setTimeout(() => console.log('i', i))
    i++
}

运行结果

for1 0
for2 1
i 1
for3 2
for2 2
i 2
for3 3
for2 3
i 3
for3 4
for2 4
i 4
for3 5
for2 5
i 5
for3 5
for2 5
  1. 使用每次循环体中重新声明变量

这点在此得到了确认。

  1. 循环体结束后对真实的变量重新赋值
  2. for括号三个表达式中的变量为同一个声明

而这两点在此得到了否定。 我们逐一分析。

第一个表达式中: 最初声明的i从未改变,可以看出每次循环使用的全都是重新声明过的变量,并且没有将修改过的值重新赋值回第一个表达式中的i。 第二、三个表达式中: 和循环体中的变量一样,也是使用了重新声明的变量,可以认为每次重新声明后第二、三表达式和循环体使用同一个新变量。 这里问题的关键是重新声明变量的时机,如果按照我们印象中的理解: 第二表达式 -> 循环体 -> 第三表达式 这个顺序,然后在最开头重新声明变量的话,输出应为:

for1 0
for2 1
i 1
for3 1
for2 2
i 2
for3 2
for2 3
i 3
for3 3
for2 4
i 4
for3 4
for2 5
i 5
for3 5
for2 5

与实际结果不符。 想得到正确的结果,可以有多种理解,一种简单的理解是(不一定与V8实际行为一致): 第三表达式 -> 第二表达式 -> 循环体 只需要第一次循环时不执行第三表达式即可。

使用代码描述如下:

let i = 0
setTimeout(() => console.log('for1', i))

let _next = i, first = true
while(true) {
    let _i = _next
    if(first) {
        first = false
    } else {
      	// 第三表达式
        setTimeout(() => console.log('for3', _i))
    }

  	// 第二表达式
    setTimeout(() => console.log('for2', _i))
    if(!(_i < 5)) break

  	// 循环体
    {
        setTimeout(() => console.log('i', _i))
        _i++
    }
    
    _next = _i
}

i为引用类型时也能保持一致:

for(let x = { i: 0, t: '' }, _s = setTimeout(() => console.log('for1', x)); 
    setTimeout(() => console.log('for2', x)), x.i < 5; 
    setTimeout(() => console.log('for3', x)), x.i++) {
    setTimeout(() => {
        console.log('x', x)
    })
    x = { i: x.i, t: x.t + 'a' }
}


let x = { i: 0, t: '' }
setTimeout(() => console.log('for1', x))

let _next = x, first = true
while(true) {
    let _x = _next
    if(first) {
        first = false
    } else {
        setTimeout(() => console.log('for3', _x))
        _x.i++
    }

    setTimeout(() => console.log('for2', _x))
    if(!(_x.i < 5)) break

    {
        setTimeout(() => console.log('x', _x))
        _x = { i: _x.i, t: _x.t + 'a' }
    }

    _next = _x
}