for 循环,所有人都写过 N 遍的东西,它到底有多复杂呢?
我们从最简单的 for 循环开始,让你看看它到底是有多复杂!
for (var i = 0; i < 5; ++i) {
console.log(i);
}
这,是一个简单的 for 循环,它包含四个部分:
第一部分是 var i = 0; 用于对循环的内容进行初始化,这里使用了 var 关键字来声明一个变量;第二部分是 i < 5; 作为循环判断的条件;第三部分是 ++i 是每次循环后都会固定进行的操作(通常被叫做“累加器”,当然,这只是个名字而已,你也不一定非要在这里做累加操作);最后一部分是 { console.log(i); } 是循环的主体部分,通常是一个语句块,也可以是单独的一条语句。
for 循环首先会执行第一部分,然后执行第二部分,在第二部分判断返回值是否为 true,若为 true 则继续执行循环体,最后执行第三部分累加器。然后会回到第二部分重新执行并判断返回值是否为 true,若为 true 则继续执行循环体……如此循环,直到在第二部分判断返回值为 false,则退出循环。
对于大部分读者来说,这太简单了,最终会输出 0、1、2、3 和 4。
但是,如果我们把代码改成这样:
for (var i = 0; i < 5; ++i) {
setTimeout(() => console.log(i));
}
会输出什么呢?
我们将输出的语句放进了一个闭包中,setTimeout 的第二个可选参数默认值为 0,但即便默认值为 0,其中的代码也将异步执行,所得到的结果总会是将代码放在循环结束之后执行。
但是,由于我们还是使用 var 来声明变量,所以由于 var 的提升效果,所以变量 i 被提升至循环外面上一层作用域中,致使整个循环中永远都只有一个 i,这个 i 被初始化为 0,然后在循环中创建了五个闭包,并异步输出,在循环结束之后,这个 i 的值最终会变成 5,然后 setTimeout 的异步代码执行,会输出此时 i 的值五次。也就是最终会输出 5、5、5、5 和 5。
对大部分已经入门 JavaScript 的读者来说,这个问题已经见过成千上万遍了,这是个再简单不过的问题了。
但是,接下来,我们进入 ES6 的时代,我们将变量的声明方式改为 let:
for (let i = 0; i < 5; ++i) {
setTimeout(() => console.log(i));
}
结果又会怎样呢?
结果将会变得正常,与第一个例子一样,会输出 0、1、2、3 和 4,但是,事情开始变得复杂。
虽然看起来,这个例子和第二个例子类似,虽然使用了 let 来声明变量,但是变量总是在 for 循环的入口处声明的,所以讲道理在循环结束之后,i 的值也会变成 5。虽然在循环结束之后你无法再访问到 i,但是在函数体中创建的闭包函数依旧可以正常访问。
与 var 不一样的是,JavaScript 会在每一次循环执行的时候,为 i 创建一个词法作用域(lexical scope),因此每一个闭包得到的 i 实际上都是这个词法作用域中的 i。或者简单的讲,在每一次循环中,都会创建一个全新的变量 i,因此每一个放入闭包的 i 都不相同,并且保存的当前的值。
事情开始变得有趣,如果我们继续修改代码,变成这样:
for (let i = 0; i < 5; i += 2) {
setTimeout(() => console.log(i));
--i;
}
结果又会怎样呢?
这里我们在每一次循环中,都将 i 的值减 1,把循环的累加器部分改为 i += 2 以保证每次循环 i 的值都会增加 1。
虽说每一次循环都将 i 减少了 1,但是 每次都是在 setTimeout 之后才去减少的,按照直观的感觉,在闭包中的 i 应该仍旧依次是 0、1、2、3 和 4 才对。
但是实际上,这个代码将会输出 -1、0、1、2 和 3。惊不惊喜?意不意外?
由于每一次修改的 i 都是循环体中创建的词法作用域中的 i,在循环结束之后,setTimeout 中的闭包函数打印的是每一个词法作用域中的 i 的值,似乎又回到了第二个例子中 var 的定义方式,结果输出的是每个词法作用域中 i 的最终值,因此会输出 -1、0、1、2 和 3。
瞧,使用 let 仅仅是避免了 var 被提升至外层作用域,但是为了确保每次循环得到的变量不同,会在循环体内会创建一个词法作用域,在词法作用域中对变量进行的修改会对词法作用域外面的变量生效,但是在离开词法作用域后对变量的修改则不会影响到之前创建的词法作用域。这样一来,它的行为看起来又像是和 var 一样了,只不过不是将变量提升到外层的函数作用域或是全局作用域,而是放在了每一次循环的词法作用域的最开始。
比如这样的代码:
for (let i = 0; i < 5; i += 2) {
let foo = 'bar';
const baz = 'qux';
setTimeout(() => console.log(i));
--i;
}
虽然这里是 for 循环的块级作用域,但是每次循环都会出现一个词法作用域,你在其中声明的变量、定义的常量是在每个词法作用域下的,互相隔离,因此代码正常运行,不会因为你在多次循环的过程中都在 for 循环的块级作用域下使用 let 或 const 而导致出现重复命名的问题。
现在,我们知道了,在 for 循环的循环体中会创建一个词法作用域,setTimeout 中的 console.log 会将这个词法作用域中 i 最终的值输出出来。
我们再修改一下代码:
for (let i = (setTimeout(() => console.log(i)), 0); i < 5; i += 2) {
setTimeout(() => console.log(i));
--i;
}
emmmmmm,这 TM 是啥???
解释一下,这里使用到了一个在日常开发中不常使用到的符号:,,虽说在日常开发中几乎不会用到,但是实际上在互联网上的 JavaScript 代码中却大量使用了这个符号,因为这个符号会被各种代码混淆/压缩工具所使用。
在 JavaScript 中,如果你想在一行中编写多个代码命令,你通常有多个选择,比如使用 ||、&&,或者你也可以在一行中使用 ; 来分隔多条语句,除了这些,你也可以使用 , 来分隔。他们之间存在着一些区别,|| 在执行当前代码命令时只有得到 falsy 值,才会继续执行后面的代码;而 && 则是只有当前代码命令返回 truthy 值,才会继续执行后面的代码;; 则没有什么限制,只要前面的代码不抛出异常就会继续执行;而 , 同样也是只要不抛出异常就会继续执行,并且最终会返回最后一段的结果。比如 1, Math.sin(2), 3 的返回值就是 3。
在这个例子中,我们把 for 循环的初始化器改成了一个逗号分隔的语句,先使用 setTimeout 设置一个异步执行的代码,然后跟着一个 ,0,因此 i 的值还是 0。
现在问题来了,setTimeout 在整个循环结束之后执行,那么会输出什么?
是不是有点懵了?
之前说过,在循环体中会创建一个词法作用域,如果在这个词法作用域中修改了 i,每次打印的值都是当前词法作用域中最终 i 的值。
那么回到这个代码,最终输出什么?i 的值最终被修改成了 5,所以会和 var 一样最终输出 5 吗?可是不是的,最终输出的结果是 0。
在初始化阶段创建的闭包函数中保存的值,不会跟着循环的执行而改变。这也就表明,在初始化阶段也存在一个词法作用域。
所以…… 真実はいつもひとつ!shi n ji tsu wa i tsu mo hi to tsu!
在进入 for 循环之后,首先会创建变量 i,然后创建一个词法作用域,复制出一个变量 i,然后让复制出来的这个 i 的值等于逗号运算符执行的结果,也就是 0,这会使得 for 最开始创建的变量 i 的值也变成 0,再然后进入 for 循环的第二个部分,判断 for 最开始创建的变量 i 的值是否小于 5,如果是的,则进入循环体,此时会再创建一个词法作用域,复制出一个新的变量 i,在其中将这个 i 的值减少 1,这会使得 for 最开始创建的变量 i 的值也减少 1,再然后进入 for 循环的累加器部分,将 for 最开始创建的变量 i 的值增加 1………………
嗯~ o( ̄▽ ̄)o
真相大白了呢!o( ̄▽ ̄)ブ
但是……你以为仅仅是这样吗?(✿◕‿◕✿)
啊?不是吗??!Σ(っ °Д °;)っ
我们再改一下代码:
for (
let i = (setTimeout(() => console.log('A', i)), 0);
(setTimeout(() => console.log('B', i)), i < 3);
(setTimeout(() => console.log('C', i)), i += 2)
) {
setTimeout(() => console.log('D', i));
--i;
}
来来来,我们在所有部分都加上“神奇测试语句”,哪位胆大的敢来猜猜它的执行结果是什么?
( ̄︶ ̄*))
答案是:
A 0
B -1
D -1
C 0
B 0
D 0
C 1
B 1
D 1
C 3
B 3
来,我们一行一行的看(TL;DR; 可以跳过):
- 首先创建了一个变量
i,为了方便,我们叫他i-0,然后创建了一个词法作用域,在这个词法作用域中复制出了一个变量i,我们叫他i-1,将i-1的值变成了0,然后词法作用域结束,而i-0的值也会受到影响,变成0。 - 进入判断阶段,这里又会产生一个词法作用域,并复制出了一个新的变量
i,我们叫他i-2,它的值和i-0一样,也是0。 - 进入循环体,实际上这里不会创造新的词法作用域,而是使用和判断语句同一个词法作用域。因此,这里使用的也是
i-2,将其值更改为了-1,同时i-0的值也变为-1。 - 进入累加器部分,这里会产生一个新的词法作用域,并复制出一个新的变量
i,我们叫他i-3,他的值和i-0一样,也是-1。此时我们将i-3的值变为1,同时i-0的值也变成了1。 - 再次来到判断阶段,这一次将不会创造新的词法作用域,而是使用和刚才累加器部分同一个词法作用域。因此,这里使用的也是
i-3。 - 再次进入循环体,还是不会创造新的词法作用域,依旧使用
i-3,这里将其值更改为了0,同时i-0的值也变为了0。 - 再次进入累加器部分,此时会再创建一个新的词法作用域,并复制出来一个新的变量
i,我们叫他i-4,他的值和i-0一样,也是0。此时我们将i-4的值变为2,同时i-0的值也变成了2。 - 再次来到判断阶段,这一次将不会创造新的词法作用域,而是使用和刚才累加器部分同一个词法作用域。因此,这里使用的也是
i-4。 - 再次进入循环体,还是不会创造新的词法作用域,依旧使用
i-4,这里将其值更改为了1,同时i-0的值也变为了1。 - 再次进入累加器部分,此时会再创建一个新的词法作用域,并复制出来一个新的变量
i,我们叫他i-5,他的值和i-0一样,也是1。此时我们将i-5的值变为3,同时i-0的值也变成了3。 - 再次来到判断阶段,这一次将不会创造新的词法作用域,而是使用和刚才累加器部分同一个词法作用域。因此,这里使用的也是
i-5。此时i-5的值是3,不满足条件,循环结束。
至此,我们得到了 5 个词法作用域,开始依次输出他们,也就是 A i-1, B i-2, D i-2, C i-3, B i-3, D i-3, C i-4, B i-4, D i-4, C i-5, B i-5。我们从后往前找找每一个词法作用域中 i 的最新值,就可以得到最终的输出结果了。
所以啊…… 真実はいつもひとつ!shi n ji tsu wa i tsu mo hi to tsu!
在 for 循环的初始化阶段,会创建第一个词法作用域;然后在判断阶段会创建第二个词法作用域,并与循环体共享;然后后续在每个累加器部分创建一个新的词法作用域,与判断部分、循环体部分共享。
ε=ε=ε=┏(゜ロ゜;)┛
瞧瞧,一个简单的 for 循环,竟然在这么短的时间内给你创造出这么多词法作用域,变得如此复杂。
那么,有没有一种……
有!
使用迭代器(iterator)和 for-of 循环吧……那会简单得多!
因为 for-of 循环就只是纯循环而已,虽然每一次循环还是一个词法作用域,但它不存在一遍又一遍的复制变量的问题,每一次循环都是一个独立的个体,所以你可以在循环头中使用 const 关键字来获取迭代器中的值,比如这样:
for (const i of [1, 2, 3, 4, 5]) {
setTimeout(() => console.log(i));
}
用了 for-of 循环,在查询 Mongo 数据库的时候,使用游标(cursor)是真的香:
import mongodb from 'mongodb';
(async () => {
const client = await mongodb.MongoClient.connect(MONGO_URI, MONGO_OPTION);
const table = client.db().collection(MONGO_TABLE);
const cursor = table.find(DB_QUERY, DB_QUERY_OPTION);
for await (const record of cursor) {
console.log(record);
}
await cursor.close();
await client.close();
})();