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();
})();
複製代碼
相關文章
相關標籤/搜索