[JS Behind The Scene] 從 for loop 理解 scope 和 event loop 的運作機制


Posted by Powerfultraveling 's Blog on 2021-08-06

前言

這是第十六周的第二個功課,內容上會以第一個功課作為延伸,如果還沒看過的也不用去看了,直接看下面這三個很棒的資源吧:

  1. what the heck is the event loop anyway?
  2. How JavaScript Works: An Overview of JavaScript Engine, Heap, and Call Stack
  3. JavaScript 中的同步與非同步(上):先成為 callback 大師吧!

了解 scope、execution context、variable object、Call Stack、event loop、web API、callback queue 後再回來吧!

題目

請說明以下程式碼會輸出什麼,以及盡可能詳細地解釋原因:

for(var i=0; i<5; i++) {
  console.log('i: ' + i)
  setTimeout(() => {
    console.log(i)
  }, i * 1000)
}

回答

答題策略

相較於第一個功課,這一題還有一個小陷阱,那就是 let 和 var 兩個 scope 的差別。我們可以先印出這一題的執行結果看看,一般而言我們可能會覺得跑出來會長這樣:

i: 0
i: 1
i: 2
i: 3
i: 4
0
1
2
3
4

沒想到印出來卻是長這樣:

i: 0
i: 1
i: 2
i: 3
i: 4
5
5
5
5
5

而知所以造成這樣的差別就在於 var 與 let 兩者在 scope 上的差別,接下來將會再次 cosplay 瀏覽器跑這段程式碼,找出問題出在哪裡!

我是瀏覽器

為了省去看文章上上下下的麻煩,我在這邊再放一次題目:

for(var i=0; i<5; i++) {
  console.log('i: ' + i)
  setTimeout(() => {
    console.log(i)
  }, i * 1000)
}

變成全域變數的 i

因為 var 的 scope 是以 function 為單位,而不是像 let 是以 block 為單位。所以呢,在這個例子裡,因為 i 宣告的地方不是在任何 function 裡,所以他會成為一個全域變數。

現在讓我們開始跑起來吧!

  1. 第一圈,console.log("i:" +i) 會先在 Call Stack 被執行,setTimeout(()=>{conosle.log(i)}, i*1000)則會被丟到瀏覽器另外創設的 thread 裡面執行計時。

  2. setTimeout(()=>{console.log(i)}, i*1000)在另外一個 thread 裡面等完 i*1000 毫秒之後,將裡面的 callback function 丟到 callback queue 裡面等待 Call Stack 清空。

  3. for 迴圈跑完,console.log("i:" + i)跑完,Call Stack 清空,並輸出:

    i:0
    i:1
    i:2
    i:3
    i:4
    

接著 event loop 發現 Call Stack 清空,並且 callback queue 裡面有等待中的 function,一需將 function 拉上去 Call Stack 執行。

因為一開始宣告 i 的時候是使用 var,所以 i 會成為全域變數 (global variable),因此,當這些 callback function 終於等到 Call Stack 清空,i 已經因為 for loop 裡面執行了五次 i++ 而變成 5。

所以呢,最後這些 callback function 所列印出來的值會是:

5
5
5
5
5

假如我要印 0, 1, 2, 3, 4 我該怎麼做?

將值存進 function 的作用域裡

如前面所述,一般我們這樣寫預期會印出來的應該是要長這樣:

i: 0
i: 1
i: 2
i: 3
i: 4
0
1
2
3
4

而我們現在知道,之所以造成預期以外的結果,是因為 var 作用域的特性。假如我們想要印出上面的結果,就必須將每一輪跑出來的 i 值存起來,讓 setTimeout() 裡的 callback function 在未來有辦法用到迴圈每一輪生產出的 i 值,而不是最後已經累加完的 i 值。

要實作這個功能首先就是要設計一個地方,可以存放每一輪跑出來的 i 值,讓他不受之後累加的值所影響,而且呢,還要讓之後的 callback function 拿的到這個值。

設定了這樣的目標後,再考量 var 的作用域是以 function 為最小單位,所以我決定將每一輪所跑出來的 i 值放在一個 function 裡面,將 setTimeout() 包在這個 function 裡面,這麼一來,setTimeout 就可以動用到這個我想要的 i 值。

根據這樣的思維,寫出來的程式碼會長這樣:

for(var i=0; i<5; i++) {
  console.log('i: ' + i)
  function test(value){
      setTimeout(()=>{
          console.log(value)
      }, value*1000)
  }
  test(i)
}

這麼做的話,我們就可以印出印出如我們所預期的值囉!

直接將 var 改成 let

我們已經知道造成這個問題的主要原因是 var 的作用域範圍,那如果我們將 var 改成 let 會怎麼樣呢?

for(let i = 0; i <5; i++){
    console.log("i:" + i);
    setTimeout(()=>{
        console.log(i);
    }, i*1000)
}

讓我們看看實際上會印出甚麼吧:

i: 0
i: 1
i: 2
i: 3
i: 4
0
1
2
3
4

很神奇吧?
好啦其實一點都不神奇,其實迴圈再運行的過程中是長這樣的:

{
    i = 0;
    console.log("i:" + i);
    setTimeout(()=>{
        console.log(i);
    }, i*1000)
}

{
    i = 1;
    console.log("i:" + i);
    setTimeout(()=>{
        console.log(i);
    }, i*1000)
}

.
.
.
{
    i = 4;
    console.log("i:" + i);
    setTimeout(()=>{
        console.log(i);
    }, i*1000)
}

每一圈都是一個 block,每個 block 裡面也都有一個各自的 i 值,而我們都知道 let 的作用域是以一個 block 為單位,所以這些 i 值就會存在 block 裡面,因此,callback function 最後所取用到的值自然就是對的值。

相對而言,var 的作用域並不是以 block 為基本單位,所以 i 值並不會存在 block 裡而是變成 global variable。


#For Loop #Event Loop #scope #let vs. var







Related Posts

 Python Table Manners 系列

Python Table Manners 系列

七天打造自己的 Google Map 應用入門 - Day05

七天打造自己的 Google Map 應用入門 - Day05

2019 年回顧 — 菜鳥網頁工程師的職涯分享

2019 年回顧 — 菜鳥網頁工程師的職涯分享


Comments