這題稍有經驗的開發者多半看過。初接觸 JavaScript 時,常會寫出類似下方的程式碼:
for (var i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, 1000 * i);
}結果是間隔一秒印出五次 6。為什麼?
拆解問題#
先簡化問題,遮掉傳入的回呼(Callback)函式:
for (var i = 1; i <= 5; i++) {
setTimeout(/* callback */, 1000 * i)
}可以明確看出這段程式碼,執行了五次 setTimeout,分別在第 1, 2, 3, 4, 5 秒時執行回呼函式。
接著看回呼函式的內容:
function () {
console.log(i)
}就只是單純的印出 i,但印的不是執行 setTimeout 時的 i,而是執行 console.log(i) 時的 i;當迴圈跑完時,i 的值會停在 6(迴圈的中止條件),屆時才執行的 console.log(i) 當然印的就會是 6。
如果把 setTimeout 的等待時間改成 0:
for (var i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}
// 6, 6, 6, 6, 6
印出來的仍然是 6。要理解這一切的原因,就必須要認識瀏覽器中 JavaScript 的 事件迴圈(Event Loop)。
瀏覽器的事件迴圈#
JavaScript 是單執行緒的程式語言,為網頁互動而生。為避免執行緒被占用導致畫面卡死,JavaScript 發展出事件迴圈機制。
事件迴圈包含三個區塊:
- Stack:Function 的 Call Stack。函式呼叫其他函式時往上堆積,執行完成後從堆疊移除
- Queue:尚未處理的任務佇列。Stack 清空時,將 Queue 的第一項搬去 Stack 並開始執行
- Heap:其他記憶體位置,儲存變數與物件
事件迴圈即上述底層機制:反覆確認 Queue 的內容,Stack 清空時就執行 Queue 中的下一項任務。
僅靠事件迴圈仍無法完成合理的使用者互動,還需要瀏覽器提供的 Web API。
思考模擬#
設想以下情境:
- 某天你去買鹽酥雞當消夜,現場人很多,你找好快速的夾一夾就交給老闆,隨後便在旁邊開始靜靜的等待,站到腿麻之際,老闆說了一句「帥哥好了喔這是你的共 150」,你才能結帳。
- 又有某天,你在百貨公司美食街買小南門豆花,老闆說「帥哥人很多喔要等一下」,並在結帳的同時給了你一個取餐令牌;於是你便繞去隔壁逛逛。突然令牌開始瘋狂震動且閃紅光,你便開心的去向老闆領豆花。
在情境 1 中,使用者必須持續等待,直到事情完成才能離開;但在情境 2 中,即使不知道要等多久,但仍能先行離開,直到收到完成的訊息,再回來接續未完成的事情。
就好比這些店家,瀏覽器提供了許多 Web API(如 setTimeout、DOM、fetch 等);JavaScript 可以透過這些 Web API,完成 JavaScript 本身機制做不到的事情,並傳遞回呼函式。當 Web API 執行完成後,便會將回呼函式放到 Queue 的隊列中,等待下一輪的事件循環,再接續未完成的任務。
回到題目#
清楚了事件循環,讓我們再重新看一次題目有問題的程式碼:
for (var i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, 1000 * i);
}由於 setTimeout 會被放置到 Queue 中,等待下一個事件循環開始才執行,屆時 i 必然已經變成迴圈的結束狀態 6。必須要透過別的方法將執行 setTimeout 時 i 的數值保留下來才行。
例如最直接的,將 i 作為參數傳入:
for (var i = 1; i <= 5; i++) {
setTimeout(
function (x) {
console.log(x);
},
1000 * i,
i
);
}或著透過立即函式(IIFE, Immediately Invoked Function Expression),建立新的函式:
for (var i = 1; i <= 5; i++) {
(function (x) {
setTimeout(function () {
console.log(x);
}, 1000 * x);
})(i);
}不過現在應該沒有人在寫 var 了;由於 let 只存在於目前區塊的特性,每次迴圈都會留下塊作用域,也不會有印五次都是 6 的情況:
for (let i = 1; i <= 5; i++) {
setTimeout(function () {
console.log(i);
}, 1000 * i);
}結語#
Web API 的操作多半等待時間不確定。瀏覽器中的 JavaScript 透過 Web API 與事件循環機制,巧妙地避免執行緒被持續占用,同時完成與使用者互動的需求。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10220329