for循环

语句,是 JavaScript 中组织代码的基础语法组件,包括函数声明等等在内的六种声明,其实都被归为“语句”的范畴。
所以,理解“语句”在 JavaScript 中的语义是重中之重。

尽管如此,实际上要了解的也无非是顺序、分支、循环这三种执行逻辑而已,相比于它们,其它语句在语义上的复杂性通常不值一提。而这三种逻辑中尤其复杂的就是循环。

在ES6之后,JavaScript实现了块级作用域。因此,现在绝大多数语句都基于这一作用域的概念来实现。
然而,事实上正好相反。
真正的状况是,绝大多数 JavaScript 语句都并没有自己的块级作用域。从语言设计的原则上来看,越少作用域的执行环境调度效率也就越高,执行时的性能也就越好。

基于这个原则,switch语句被设计为有且仅有一个作用域,无论它有多少个 case 语句,其实都是运行在一个块级作用域环境中的。

1
2
3
4
5
6
7
8
9
var x = 100, c = 'a';
switch (c) {
case 'a':
console.log(x); // ReferenceError
break;
case 'b':
let x = 200;
break;
}

在这个例子中,switch 语句内是无法访问到外部变量x的,即便声明变量x的分支case ‘b’永远都执行不到,但它里面的x却已经声明了,并且导致case ‘a’中的代码无法访问到外部的x = 100。这是因为所有分支都处在同一个块级作用域中(case ‘a’ 和 case ‘b’使用了同一个闭包),所以任意分支的声明都会给该作用域添加这个标识符,从而覆盖了全局的变量x。

一些简单的、显而易见的块级作用域包括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18


// 例1
try {
// 作用域1
}
catch (e) { // 表达式e位于作用域2
// 作用域2
}
finally {
// 作用域3
}
// 例2
//(注:没有使用大括号)
with (x) /* 作用域1 */; // <- 这里存在一个块级作用域
// 例3, 块语句
{
// 作用域1

除了这三个语句和“一个特例”之外,所有其它的语句都是没有块级作用域的。例如if条件语句的几种常见书写形式

1
2
3
4
5
6
7
8
9
10
if (x) {
...
}
// or
if (x) {
...
}
else {
...
}

这些语法中的“块级作用域”都是一对大括号表示的“块语句”自带的,与上面的“例 3”是一样的,而与if语句本身无关。

循环语句中的块

并不是所有的循环语句都有自己的块级作用域,例如 while 和 do…while 语句就没有。而且,也不是所有 for 语句都有块级作用域。在 JavaScript 中,有且仅有:

for (<let/const> …) …

这个语法有自己的块级作用域。当然,这也包括相同设计的for await和for .. of/in ..。

for await (<let/const> x of …) …
for (<let/const> x … in …) …
for (<let/const> x … of …) …

在es6中,其实for只要写大括号就代表着块级作用域。所以只要写大括号,不管用let 还是 var,一定是会创建相应循环数量的块级作用域。
如果不用大括号,在for中使用了let,也会创建相应循环数量的块级作用域。

特例

所有在语句内可以存在的东西只有四种:表达式、其它语句、标识符声明(取决于声明语句或其它的隐式声明的方式),以及一种特殊的语法元素,称为“标签(例如标签化语句,或 break 语句指向的目标位置)”。

所谓“块级作用域”,本质上只包括一组标识符。因此,只有当存在潜在标识符冲突的时候,才有必要新添加一个作用域来管理它们。例如函数,由于函数存在“重新进入”的问题,所以它必须有一个作用域来管理“重新进入之前”的那些标识符。这个东西被称为“闭包”。

NOTE: 在语言设计时,有三种需求会促使语句构建自己的作用域,标识符管理只是其中之一。其它两种情况,要么是因为在语法上支持多语句(例如 try…catch…finally 语句),要么是语句所表达的语义要求有一个块,例如“块语句{ }”在语义上就要求它自己是一个块级作用域。

特例中的特例

“var 声明”是特例中的特例。

这一特性来自于 JavaScript 远古时代的作用域设计。在早期的 JavaScript 中,并没有所谓的块级作用域,那个时候的作用域设计只有“函数内”和“函数外”两种,如果一个标识符不在任何(可以多层嵌套的)函数内的话,那么它就一定是在“全局作用域”里。

而在这个时代,变量也就只有“var 声明”的变量。由于作用域只有上面两个,所以任何一个“var 声明”的标识符,要么是在函数内的,要么就是在全局的,没有例外。

1
2
for (var x = ...)
...

按照早期的设计,变量x是不应该出现在for 语句所在的”块级作用域中的。

它应该出现其外层的某个函数作用域,或者全局作用域中。这种越过当前语法范围,而在更外围的作用域中登记名字行为就称为“提升“。

  • 所有”var 声明“和函数声明的标识符都登记为 varNames,使用“变量作用域”管理;
  • 其它情况下的标识符 / 变量声明,都作为 lexicaNames 登记,使用“词法作用域”管理。

NOTE: 考虑到对传统 JavaScript 的兼容,函数内部的顶层函数名是提升到变量作用域中来管理的。 > > NOTE: 我通常会将“在变量声明语句前使用该变量”也称为一种提升效果(Hoisting effect),但这种说法不见于 ECMAScript 规范。ES 规范将这种“提前使用”称为“访问一个未初始化的绑定(uninitialized mutable/immutable binding)”。而所谓“var 声明能被提前使用”的效果,事实上是“var 变量总是被引擎预先初始化为 undefined”的一种后果。

所以,语句for (<const/let> x …) …语法中的标识符x是一个词法名字,应该由for语句为它创建一个(块级的)词法作用域来管理之。

第二个作用域

1
2
3
4
5

var x = 100;
for (let x = 102; x < 105; x++)
console.log('value:', x); // 显示“value: 102~104”
console.log('outer:', x); // 显示“outer: 100”

因为for语句的这个块级作用域的存在,导致循环体内访问了一个局部的x值(循环变量),而外部的(outer)变量x是不受影响的。

1
2
3

for (let x = 102; x < 105; x++)
let x = 200;

如果循环体(单个语句)允许支持新的变量声明,那么为了避免它影响到循环变量,就必须为它再提供另一个块级作用域。在这里,JavaScript 是不允许声明新的变量的。上述的示例会抛出一个异常,提示“单语句不支持词法声明”:

SyntaxError: Lexical declaration cannot appear in a single-statement context

这个语法错误并不常见,因为很少有人会尝试构建这样的特殊代码。然而事实上,它是一个普遍存在的语法禁例。如:

1
2
3
4
5
6
7

// if语句中的禁例
if (false) let x = 100;
// while语句中的禁例
while (false) let x = 200;
// with语句中的禁例
with (0) let x = 300

所以,现在可以确定:循环语句(对于支持“let/const”的 for 语句来说)“通常情况下”只支持一个块级作用域。更进一步地说,在上面的代码中,我们并没有机会覆盖 for 语句中的“let/const”声明。

但是对于下面这个例子来说,“只有一个块级作用域”就不够了:

1
2

for (let i in x) ...;

在这个例子中,“let i …”在语义上就需要被执行多次——因为在静态结构中它的多次迭代都作用于同一个语法元素。而你是知道的,let 语句的变量不能重复声明的。所以,这里就存在了一个冲突:“let/const”语句的单次声明(不可覆盖)的设计,与迭代多次执行的现实逻辑矛盾了。

这个矛盾的起点,就是“只有一个块级作用域”。所以,在 JavaScript 引擎实现“支持 let/const 的 for 语句”时,就在这个地方做了特殊处理:为循环体增加一个作用域。

这样一来,“let i”就可以只执行一次,然后将“i in x”放在每个迭代中来执行,这样避免了与“let/const”的设计冲突。

for 循环的代价

块语句在每个迭代中都会都会创建一次它自己的块级作用域副本。这个循环体越大,支持的层次越多,那么这个环境的创建也就越频繁,代价越高昂。再加上可以使用函数闭包将环境传递出去,或交给别的上下文引用,这里的负担就更是雪上加霜了。

也就是所谓“循环与函数递归在语义上等价”。所以在事实上,上述这种 for 循环并不比使用函数递归节省开销。在函数调用中,这里的循环变量通常都是通过函数参数传递来处理的。因而,那些支持“let/const”的 for 语句,本质上也就与“在函数参数界面中传递循环控制变量的递归过程”完全等价,并且在开销上也是完全一样的。

因为每一次函数调用其实都会创建一个新的闭包——也就是函数的作用域的一个副本。