前言
這是第十六周的第二個功課,內容上會以第一個功課作為延伸,如果還沒看過的也不用去看了,直接看下面這三個很棒的資源吧:
- what the heck is the event loop anyway?
- How JavaScript Works: An Overview of JavaScript Engine, Heap, and Call Stack
- 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 裡,所以他會成為一個全域變數。
現在讓我們開始跑起來吧!
第一圈,
console.log("i:" +i)
會先在 Call Stack 被執行,setTimeout(()=>{conosle.log(i)}, i*1000)
則會被丟到瀏覽器另外創設的 thread 裡面執行計時。setTimeout(()=>{console.log(i)}, i*1000)
在另外一個 thread 裡面等完i*1000
毫秒之後,將裡面的 callback function 丟到 callback queue 裡面等待 Call Stack 清空。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。