iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 8
4
Modern Web

前端三十 - 成為更好的前端工程師系列 第 8

08. [JS] 請寫出間隔一秒印出 1, 2, 3, 4, 5 的程式碼。

circles

相信稍有經驗的開發者多少都看過這題了,應該是彈指之間便能輕鬆解決;但初步接觸 JavaScript 的朋友,可能會撰寫出類似下面範例的程式碼:

for(var i = 1; i <= 5; i++) {
  setTimeout(function() {
    console.log(i)
  }, 1000 * i)
}

然後就得到間隔一秒印出五次 6 的結果。

why

為什麼會這樣呢?今天就讓我們研究這現象背後的原因吧~

拆解問題

我們先把題目拆分簡化,先把傳入的回呼函式(Callback Function)遮掉的話,就只剩這樣:

for(var i = 1; i <= 5; i++) {
  setTimeout(/* callback */, 1000 * i)
}

可以明確看出這段程式碼,執行了五次 setTimeout,而 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

why

要理解這一切的原因,就必須要認識一下瀏覽器中 JavaScript 的 事件迴圈(Event Loop)

瀏覽器的事件迴圈

JavaScript 是一個單執行緒的程式語言,是為了網頁互動應運而生的語言;而為了避免執行緒被占用時造成使用者畫面卡死,JavaScript 發展出了事件迴圈的機制,參考下圖:

Visual representation

圖中有三個區塊,三種資料結構,用在三種不同的地方:

  • Stack:就是 Function 的 Call Stack,當執行函式時又呼叫了其他函式,便會往上堆積;反之在執行完成後從堆疊移除。
  • Queue:尚未處理的任務,當 Stack 清空時,將 Queue 的第一項搬去 Stack,並開始執行
  • Heap:代表其他記憶體位置,儲存一大堆變數、物件之類的

而事件迴圈,指的就是 JavaScript 如前述的底層機制,藉由反覆確認 Queue 的內容,當 Stack 被清空就接著做下一件 Queue 中的任務,如此反覆循環。

但,只靠這些是還是無法完成合理的使用者互動的,我們還需要瀏覽器提供的 Web API。

思考模擬

設想以下情境:

  1. 某天你去買鹽酥雞當消夜,現場人很多,你找好快速的夾一夾就交給老闆,隨後便在旁邊開始靜靜的等待,站到腿麻之際,老闆說了一句「帥哥好了喔這是你的共 150」,你才能結帳,並拿著鹽酥雞離開。

  2. 又有某天,你在百貨公司美食街買小南門豆花,老闆說「帥哥人很多喔要等一下」,並在結帳的同時給了你一個取餐令牌,於是你便繞去隔壁的薔薇派挑兩片女友愛吃的口味,再繞去文創小店逛一下。突然令牌開始瘋狂震動 & 閃紅光,你便開心的去向老闆領豆花。

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

就好比這些店家,瀏覽器提供了許多 Web API,例如今天題目中的 setTimeout昨天 聊到的 DOM、取得更多資源的 fetch 等等,JavaScript 可以透過這些 Web API,完成 JavaScript 本身機制做不到的事情;並傳遞回呼函式,當 Web API 執行完成後,便會將回呼函式放到 Queue 的隊列中,等待下一輪的事件循環,再接續未完成的任務。

實際運作

可以參考這個 視覺化的 JavaScript runtime 模擬,預設就已經撰寫了簡單的範例程式碼,可以直接點開來執行:

event loop

範例程式中,一開始的這一段註冊了一個事件監聽器:

$.on('button', 'click', function onClick() { 
  ...
})

當使用者點擊按鈕時,由於監聽器捕捉到使用者的點擊事件,將函式 onClick 放到 Queue 中;接著當 Stack 清空時,JavaScript 引擎 會將 Queue 中的第一項任務放到 Stack 中開始執行,執行內容是

setTimeout(function timer() {
  console.log('You clicked the button!');    
}, 2000);

setTimeout 被呼叫到 Stack 上,執行後建立了一個 Web API 的計時器,並開始等待兩秒,兩秒後將 timer 函式 放到 Queue 中。這時 setTimeout 執行完畢、onClick 執行完畢,Stack 再度被清空,Queue 的第一項 timer 被移到 Stack 開始執行﹐如此這般不斷的循環。

回到題目

清楚了事件循環,讓我們再重新看一次題目有問題的程式碼:

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,建立新的函式:

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 以及事件循環的機制,巧妙的讓執行緒不會被持續占用,並完成與使用者互動的需求。藉由逐步拆解、說明範例程式,希望能幫助讀者您理解 JavaScript 中至關重要的事件迴圈

今天的文章就到這裡啦~明天將會繼續認識 JavaScript 的機制,請大家敬請期待!如果內文有任何錯誤或不清楚的地方,都歡迎讀者您加入回應討論,一起往更強的前端工程師邁進!

參考資料

筆者

Gary

半路出家網站工程師;半生熟的前端加上一點點的後端。
喜歡音樂,喜歡學習、分享,也喜歡當個遊戲宅。

相信一切安排都是最好的路。


上一篇
07. [JS] 瀏覽器 DOM 元素的事件代理是指什麼?
下一篇
09. [JS] 什麼是閉包?
系列文
前端三十 - 成為更好的前端工程師31

1 則留言

1
ofcourse448
iT邦新手 5 級 ‧ 2019-11-21 21:05:53

謝大哥 你寫得最清楚 我看懂了30%

最近剛好遇到這個問題 其他人我只看懂10%

Gary iT邦新手 5 級 ‧ 2019-11-21 21:45:12 檢舉

很高興對你有幫助~
有不夠清楚的地方也歡迎你繼續提問反饋喔!

我要留言

立即登入留言