這題稍有經驗的開發者多半看過。初接觸 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。

思考模擬#

設想以下情境:

  1. 某天你去買鹽酥雞當消夜,現場人很多,你找好快速的夾一夾就交給老闆,隨後便在旁邊開始靜靜的等待,站到腿麻之際,老闆說了一句「帥哥好了喔這是你的共 150」,你才能結帳。
  2. 又有某天,你在百貨公司美食街買小南門豆花,老闆說「帥哥人很多喔要等一下」,並在結帳的同時給了你一個取餐令牌;於是你便繞去隔壁逛逛。突然令牌開始瘋狂震動且閃紅光,你便開心的去向老闆領豆花。

在情境 1 中,使用者必須持續等待,直到事情完成才能離開;但在情境 2 中,即使不知道要等多久,但仍能先行離開,直到收到完成的訊息,再回來接續未完成的事情。

就好比這些店家,瀏覽器提供了許多 Web API(如 setTimeoutDOMfetch 等);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。必須要透過別的方法將執行 setTimeouti 的數值保留下來才行。

例如最直接的,將 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