JavaScript 中你所不知道的 for 循环

for 循环,全部人都写过 N 遍的东西,它到底有多复杂呢?javascript

咱们从最简单的 for 循环开始,让你看看它究竟是有多复杂!java

for (var i = 0; i < 5; ++i) {
  console.log(i);
}
复制代码

这,是一个简单的 for 循环,它包含四个部分:mongodb

第一部分是 var i = 0; 用于对循环的内容进行初始化,这里使用了 var 关键字来声明一个变量;第二部分是 i < 5; 做为循环判断的条件;第三部分是 ++i 是每次循环后都会固定进行的操做(一般被叫作“累加器”,固然,这只是个名字而已,你也不必定非要在这里作累加操做);最后一部分是 { console.log(i); } 是循环的主体部分,一般是一个语句块,也能够是单独的一条语句。数据库

for 循环首先会执行第一部分,而后执行第二部分,在第二部分判断返回值是否为 true,若为 true 则继续执行循环体,最后执行第三部分累加器。而后会回到第二部分从新执行并判断返回值是否为 true,若为 true 则继续执行循环体……如此循环,直到在第二部分判断返回值为 false,则退出循环。闭包

对于大部分读者来讲,这太简单了,最终会输出 01234异步

可是,若是咱们把代码改为这样:async

for (var i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i));
}
复制代码

会输出什么呢?函数

咱们将输出的语句放进了一个闭包中,setTimeout 的第二个可选参数默认值为 0,但即使默认值为 0,其中的代码也将异步执行,所获得的结果总会是将代码放在循环结束以后执行。工具

可是,因为咱们仍是使用 var 来声明变量,因此因为 var 的提高效果,因此变量 i 被提高至循环外面上一层做用域中,导致整个循环中永远都只有一个 i,这个 i 被初始化为 0,而后在循环中建立了五个闭包,并异步输出,在循环结束以后,这个 i 的值最终会变成 5,而后 setTimeout 的异步代码执行,会输出此时 i 的值五次。也就是最终会输出 55555测试

对大部分已经入门 JavaScript 的读者来讲,这个问题已经见过成千上万遍了,这是个再简单不过的问题了。

可是,接下来,咱们进入 ES6 的时代,咱们将变量的声明方式改成 let

for (let i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i));
}
复制代码

结果又会怎样呢?

结果将会变得正常,与第一个例子同样,会输出 01234,可是,事情开始变得复杂。

虽然看起来,这个例子和第二个例子相似,虽然使用了 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 应该仍旧依次是 01234 才对。

可是实际上,这个代码将会输出 -10123。惊不惊喜?意不意外?

因为每一次修改的 i 都是循环体中建立的词法做用域中的 i,在循环结束以后,setTimeout 中的闭包函数打印的是每个词法做用域中的 i 的值,彷佛又回到了第二个例子中 var 的定义方式,结果输出的是每一个词法做用域中 i 的最终值,所以会输出 -10123

瞧,使用 let 仅仅是避免了 var 被提高至外层做用域,可是为了确保每次循环获得的变量不一样,会在循环体内会建立一个词法做用域,在词法做用域中对变量进行的修改会对词法做用域外面的变量生效,可是在离开词法做用域后对变量的修改则不会影响到以前建立的词法做用域。这样一来,它的行为看起来又像是和 var 同样了,只不过不是将变量提高到外层的函数做用域或是全局做用域,而是放在了每一次循环的词法做用域的最开始。

好比这样的代码:

for (let i = 0; i < 5; i += 2) {
  let foo = 'bar';
  const baz = 'qux';
  setTimeout(() => console.log(i));
  --i;
}
复制代码

虽然这里是 for 循环的块级做用域,可是每次循环都会出现一个词法做用域,你在其中声明的变量、定义的常量是在每一个词法做用域下的,互相隔离,所以代码正常运行,不会由于你在屡次循环的过程当中都在 for 循环的块级做用域下使用 letconst 而致使出现重复命名的问题。

如今,咱们知道了,在 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; 能够跳过):

  1. 首先建立了一个变量 i,为了方便,咱们叫他 i-0,而后建立了一个词法做用域,在这个词法做用域中复制出了一个变量 i,咱们叫他 i-1,将 i-1 的值变成了 0,而后词法做用域结束,而 i-0 的值也会受到影响,变成 0
  2. 进入判断阶段,这里又会产生一个词法做用域,并复制出了一个新的变量 i,咱们叫他 i-2,它的值和 i-0 同样,也是 0
  3. 进入循环体,实际上这里不会创造新的词法做用域,而是使用和判断语句同一个词法做用域。所以,这里使用的也是 i-2,将其值更改成了 -1,同时 i-0 的值也变为 -1
  4. 进入累加器部分,这里会产生一个新的词法做用域,并复制出一个新的变量 i,咱们叫他 i-3,他的值和 i-0 同样,也是 -1。此时咱们将 i-3 的值变为 1,同时 i-0 的值也变成了 1
  5. 再次来到判断阶段,这一次将不会创造新的词法做用域,而是使用和刚才累加器部分同一个词法做用域。所以,这里使用的也是 i-3
  6. 再次进入循环体,仍是不会创造新的词法做用域,依旧使用 i-3,这里将其值更改成了 0,同时 i-0 的值也变为了 0
  7. 再次进入累加器部分,此时会再建立一个新的词法做用域,并复制出来一个新的变量 i,咱们叫他 i-4,他的值和 i-0 同样,也是 0。此时咱们将 i-4 的值变为 2,同时 i-0 的值也变成了 2
  8. 再次来到判断阶段,这一次将不会创造新的词法做用域,而是使用和刚才累加器部分同一个词法做用域。所以,这里使用的也是 i-4
  9. 再次进入循环体,仍是不会创造新的词法做用域,依旧使用 i-4,这里将其值更改成了 1,同时 i-0 的值也变为了 1
  10. 再次进入累加器部分,此时会再建立一个新的词法做用域,并复制出来一个新的变量 i,咱们叫他 i-5,他的值和 i-0 同样,也是 1。此时咱们将 i-5 的值变为 3,同时 i-0 的值也变成了 3
  11. 再次来到判断阶段,这一次将不会创造新的词法做用域,而是使用和刚才累加器部分同一个词法做用域。所以,这里使用的也是 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();
})();
复制代码