在 for 语句中使用 let 或 const 声明循环变量与使用 var 时有什么区别?

ECMAScript 中定义了四种 for 循环语句:forfor-infor-of 以及 for-await-of,它们都可以在其循环表达式中声明变量。而自 ES2015 之后有三种声明变量的方式:varletconst,这三种变量声明方式都可以使用在所有 for 语句中。那么,这三种变量声明方式在 for 语句中有什么区别,以及在实际编写代码时我们应该使用哪种声明方式?这篇文章将会聊一下这些问题。

注意:for-infor-of 以及 for-await-of 这三种循环语句对循环变量的处理逻辑是相同的,因此下面文章中将统一使用 for-of 来指代它们,代码示例中也仅使用 for-of

前言

不知道其它人或团队是否有要求应当使用哪种变量声明方式,但在我自己的项目中,通常使用的规则是「优先使用 const,仅在必须时改使 let,且绝不使用 var」。

当在使用 for-of 并声明其循环变量时,直接遵循该规则使用 const 即可:

const array = ['a', 'b', 'c']

for (const value of array) {
	console.log(`for of: value: ${value}`)
}

但是在使用 for 时,若依然使用 const 声明循环变量,则会在运行时抛出异常:

const array = ['a', 'b', 'c']

for (const i = 0; i < array.length; i++) {
	console.log(`for: index: ${i}, value: ${array[i]}`)
}

// => Uncaught TypeError: Assignment to constant variable.

这很容易理解,毕竟变量 i 是一个常量,当我们在增量表达式中去修改它的值时,自然会报错,那么改用 let 就好了:

const array = ['a', 'b', 'c']

for (let i = 0; i < array.length; i++) {
	console.log(`for: index: ${i}, value: ${array[i]}`)
}

但仔细想一下,letconst 都是块级作用域,那么请问在 for 语句中,整个循环语句是执行在一个单一的作用域中哪,还是每次循环体都有一个单独的作用域?若是执行在一个单一的作用域中,那么自然在 for 语句中是不能修改常量值的;但其实这是不可能的,因为在使用 letconst 声明循环变量时,我们可以安全地在循环体中定义闭包,并在闭包中使用循环变量:

const array = ['a', 'b', 'c']

for (let i = 0; i < array.length; i++) {
	setTimeout(() => {
		console.log(`for: index: ${i}, value: ${array[i]}`)
	}, 100)
}

好吧,通常遇到这种问题,最终的解决方式就是去查阅 ECMAScript 的规范文档。那么,让我们开始吧。

规范:for 语句

在规范文档中,所有语句相关的内容都定义在 第 13 章 Statements and Declarations 中。

for 语句的执行逻辑定义在 13.7.4.7 Runtime Semantics: LabelledEvaluation 这一节中。

对于使用 var 声明循环变量的 for 语句,其规范如下所示:

可以看到,在使用 var 声明循环变量时,会直接在 for 语句所在的作用域中执行初始化表达式,循环变量自然也就被声明到了该作用域中(第 1 步);若初始化表达式正常执行完毕(第 2 步),则紧接着就开始执行循环体了(第 3 步)。

而若使用 letconst 声明循环变量,则对应 for 语句的执行逻辑则变为如下所示:

显然整个流程复杂了很多:

  • 首先,基于 for 语句所在的作用域创建一个新的子作用域,称为 loopEnv第 1 ~ 3 步);
  • loopEnv 中创建所有循环变量(第 5 ~ 6 步);
  • loopEnv 设置为当前作用域(第 7 步);
  • loopEnv 中执行初始化表达式,为循环变量赋予初值(第 8 ~ 9 步);
  • 执行循环体,并在执行结束后还原当前作用域(第 10 ~ 11 步)。

而且注意一下第 10 步,可以看到若是使用 let 而非 const 声明循环变量,则会额外创建一个 perIterationLets 结构,其中会包含所有循环变量的绑定名称,该结构将会连同其实数据一并被传入循环体的执行逻辑中。而在使用 const 的时候,该结构被声明为空,和使用 var 声明循环变量时一样。

perIterationLets 又是用来做什么的,它和 let 又有什么关系,为什么使用 constvar 时不需要它?

带着这些疑问,让我们看一下循环体执行逻辑的定义:

可以看到在每次循环中,大体的处理逻辑还是比较简单的:

  • 首先执行测试表达式,若返回 false 则退出循环(第 3.a 步);
  • 执行循环体语句,若根据执行结果判断是否退出循环(第 3.b ~ 3.d 步);
  • 最后执行增量表达式(第 3.f 步);

但这里需要注意的是在循环开始之前,以及每次循环中最后执行增量表达式之前,都会拿我们刚才说到的 perIterationLet  去执行一个创建每次迭代环境(CreatePerIterationEnvironment)的逻辑,其定义如下:

在这里面,我们大部分的问题就都有了答案了。

显然,该逻辑是用于为下次循环创建作用域环境的。

perIterationLets 中有值,则会基于当前作用域的父级创建一个新的子作用域,称为 interationEnv第 1.a ~ 1.f 步,额外注意第 1.c 步,在这一步中获取到的 outer 将用于在第 1.e 步中创建子作用域)。

这里的「当前作用域」在循环开始前时是 loopEnv,而在每次循环中最后执行增量表达式之前时,是当次循环对应的 interationEnv,它们的父作用域,自然就是 for 语句所在的作用域了。

而在 interationEnv 被创建之后,会根据 perIterationLets 在其中创建所有循环变量,并从当前作用域中复制这些循环变量的值。

看着很麻烦是吧?其实之所以这么做,就是为了在每次循环时,都会有一个单独的作用域用于绑定此次循环变量的值,以支持我们上面所说的在循环中创建闭包的问题:

const array = ['a', 'b', 'c']

for (let i = 0; i < array.length; i++) {
	setTimeout(() => {
		console.log(`for: index: ${i}, value: ${array[i]}`)
	}, 100)
}

上面的示例代码在执行时的作用域关系如下图所示:

而若 perIterationLets 为空(也就是使用 varconst 声明循环变量时),则不会创建 interationEnv。那么若是使用 var 声明的循环变量,则在整个循环语句的执行过程中,当前作用域将一直都是 for 语句所在的作用域,而若是使用 const,则一直都是 loopEnv

这么处理的原因也很容易就能理解。

当使用 var 声明变量时,这么处理的原因可以很容易就能理解,毕竟使用 var 声明的变量都会被定义在函数作用域中,那么 for 语句中自然不再需要创建 interationEnv 来存放这些变量了。

但在使用 const 时,想必是为了遵循其「常量」的语义,既然作为常量其值不可变,自然也就没必要为其创建 interationEnv,那么仅提供一个 loopEnv 来存放它们就好了。但如此一来,感觉 for 语句支持使用 const 定义循环变量就没有任何意义了。

补充于 2021-01-12

今天正好遇到一个场景,有一个按时间升序排列的数组,需要移除在某个时间点之前的所有元素,而且预期需要移除的元素不会很多,甚至大部分时候都只需要移除第一元素,在实现的时候正好想起了这篇文章,于是就有了下面这段代码:

for (const i = 0; i < array.length;) {
	if (array[i].time < minTime) {
		array.shift()
	} else {
		break
	}
}

😂

规范:for-in 语句

接下来我们再看看 for-of,它的执行逻辑是和 for-in 以及 for await of 定义在一起的,都在 13.7.5.11 Runtime Semantics: LabelledEvaluation 这一节中:

可以看到,它们的逻辑是一样的,都是先执行迭代表达式获取迭代器,然后执行循环体。循环体执行逻辑中的条件分支比较多,但与循环变量声明类型相关的处理逻辑的是在第 gh 这两步中,如下图所示:

可见若是使用 var 声明循环变量,或只是赋值给当前作用域中的某个变量,那么不会做任何额外的事情;但若是使用 letconst 声明循环变量,则会为每次循环单独创建一个作用域,其父级为当前 for 语句所在的作用域环境。该作用域中将会创建循环变量,并使用从迭代器中获取到的下一个迭代项的值作为其初始值,在这个过程中并不会修改循环变量,因此我们可以在 for-of 中可以使用 const 来声明循环变量。

性能

可以看到,当在 for 语句中使用 letconst 声明循环变量时,需要额外执行很多作用域相关的操作,尤其是在 for  语句中,如果这些操作全套都执行下来那么可能会比使用 var 时多出不少的性能开销。但这里的问题是,这些大部分操作可能都是用来处理「在循环体中声明闭包,并在闭包中引用了循环变量」这一个场景的,若脱离这个场景,那么实际上是不需要为每次循环都创建一个作用域的。

那么 JS 引擎是否会在执行代码前分析 for 语句中是否有声明闭包,并据此进行一些优化哪?如果要实际确认,那么可能真的是要去查阅一下如 V8 等 JS 引擎的源代码了,但这样做多少有点费时费力,而且我 C++ 的水平也不高,也真不见得能查的出来。但也有其它简单的方式,比如我们可以执行一些基准测试,看一下它们的执行耗时:

$ node -v
v10.19.0

$ node test.js
for, var x 1,347,923 ops/sec ±2.22% (81 runs sampled)
for, let x 1,353,264 ops/sec ±2.67% (79 runs sampled)
for, let, closure x 821,317 ops/sec ±3.61% (78 runs sampled)
forof, var x 1,161,228 ops/sec ±1.71% (82 runs sampled)
forof, const x 1,124,124 ops/sec ±1.92% (78 runs sampled)
forof, const, closure x 831,651 ops/sec ±2.43% (82 runs sampled)
Fastest is for, var,for, let

可以看到,其实在实际执行时使用 varletconst 是没有多大差别的,但若是在循环体中声明了闭包,那么性能就变得慢很多。因此在实际开发中,我们真正应当避免的反而是这一点。

下面是我用来做基准测试的代码,如果你不感兴趣,那么跳过它就好:

const Benchmark = require('benchmark')

function log(message) {
    log.msglen = message.length
}

function noop() {}

const suite = new Benchmark.Suite()

suite

    .add('for, var', function _for() {
        var array = ['a', 'b', 'c']

        for (var i = 0; i < 3; i++) {
            log(`value: ${array[i]}`)
            setTimeout(noop)
        }
    })

    .add('for, let', function _for() {
        const array = ['a', 'b', 'c']

        for (let i = 0; i < 3; i++) {
            log(`value: ${array[i]}`)
            setTimeout(noop)
        }
    })

    .add('for, let, closure', function _for_closure() {
        const array = ['a', 'b', 'c']

        for (let i = 0; i < 3; i++) {
            setTimeout(() => {
                log(`value: ${array[i]}`)
            })
        }
    })

    .add('forof, var', function _forof() {
        var array = ['a', 'b', 'c']

        for (var val in array) {
            log(`value: ${val}`)
            setTimeout(noop)
        }
    })

    .add('forof, const', function _forof() {
        const array = ['a', 'b', 'c']

        for (const val in array) {
            log(`value: ${val}`)
            setTimeout(noop)
        }
    })

    .add('forof, const, closure', function _forof_closure() {
        const array = ['a', 'b', 'c']

        for (const val in array) {
            setTimeout(() => {
                log(`value: ${val}`)
            })
        }
    })

    .on('cycle', function (event) {
        console.log(String(event.target))
    })

    .on('complete', function () {
        console.log('Fastest is ' + this.filter('fastest').map('name'))
    })

    .run({ async: true })

注意:因为 setTimeout 的执行耗时会让测试结果出现较大的偏差,因此在上面的代码中,特意在每个 for 语句中都加了一个 setTimeout 调用。

总结

据我所知,在 ECMAScript 中,除了变量声明、函数声明以及导入导出这三种声明语句之外,就只有这四种 for 语句支持额外声明变量了,当然也只有它们需要为这些变量处理作用域的问题。

但总之,聊到这里应该也差不多了,下面让我们来总结一下吧:

在 for 语句中,若使用 var 声明循环变量,或直接使用当前作用域中的某些变量作为循环变量,那么都不会做任何额外的处理,所有循环表达式及循环体语句都将在语句所在的作用域中执行。

但若使用 letconst 声明循环变量,那么所有 for 语句都会在执行时为每次循环单独创建一个对应的作用域,该作用域的父级所指向的是语句所在的作用域。

另外对于普通 for 语句:

  • 将会在循环开始之前创建一个临时作用域(loopEnv),用于执行初始化表达式,并存放循环变量的初始值;
  • 在每次循环开始之前,都会从上一次循环的作用域中复制循环变量的值到这次循环的作用域中(第一次循环是从 loopEnv 中复制循环变量的值);
  • 而且若在增量表达式中修改循环变量的值,则不能使用 const 声明循环变量。

至于性能方面,目前的 JS 引擎应该已经有了相应的优化,因此无论使用 var 还是 letconst 来声明循环变量,在性能上都不会有太多差别,但应当避免在循环体中创建闭包,这会让循环执行变慢很多。