非同步一直是困擾著 Javascript 新手的小魔王,以前常常會有「為什麼這行先跑到沒有先執行?」這種困擾。
隨著時間過去,踩了幾次 bug,也漸漸抓到非同步的節奏,但在這個階段,往往也只是知道,非同步就是不斷地等:
setTimeout
就是等幾秒就會執行
addEventListener
就是要等事件觸發
fetch
就是要等後端回應
我們知道非同步「怎麼用」,卻不一定知道「為什麼」,讓我們來看看非同步的核心究竟是怎麼運作的。
Javascript Runtime
首先需要提到的是 Javascript Runtime,中文大概可以翻成 Javascript 的「執行環境」吧,比如 Chrome、Firefox、node,每個 runtime 提供的 API 都不同,所以不是所有地方都有
window
物件,
setTimeout
之類的 Web API。
Runtime 會隨著環境而不同,但有兩個機制,是屬於 Javascript 的機制,因此任何地方都一樣:
Call Stack(存放指令)、Memory Heap(存放資料)
另外還有兩個名詞也先介紹一下:
Callback Queue
:用來存放從 Web api 過來,準備要進入 Call Stack 的指令
Event Loop
:會不斷監看 Call Stack,如果空了就會把 Callback Queue 的指令放到 Call Stack 執行
程式碼在背景的處理順序
對於一段程式碼,Javascript engine 底層會依序做這些事:
把 JS 的指令一行一行放到 Call Stack,並且執行
途中如果遇到不屬於 JS 自身 (如: Web API) 的指令,因為 JS 看不懂,會交由 Web API 處理
Web API 處理後的(如:
setTimeout
秒數數完)程式碼會放到 Callback Queue
Event Loop 不斷地監看 Call Stack 是否空了,如果空了就會把 Callback Queue 的指令放到 Call Stack 執行
以下程式碼為例,用到
$.on
(類似
addEventListener
) 跟
setTimeout
這種非同步的程式碼,但中間也夾雜了一些
console
,在 background 會怎麼運作呢?
$.on('button', 'click', function onClick() {
setTimeout(function timer() {
console.log('clicked');
}, 2000);
console.log("Hi!");
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
console.log("Welcome");
把 $.on()
放到 Call Stack
把 $.on()
交由 Web API 處理(因為不是原生 JS)
Web API 開始等待按鈕點擊事件
把 console.log("Hi!")
放到 Call Stack,執行
把 setTimeout()
放到 Call Stack
把 setTimeout()
交由 Web API 處理(因為不是原生 JS)
Web API 開始等待 5 秒
把 console.log("Welcome")
放到 Call Stack,執行
至此,Call Stack 已淨空,Event Loop 會把 Callback Queue 裡面的指令搬到 Call Stack 執行
(過了 5 秒)
Web API 內的 setTimeout()
的 callback function 被搬到 Callback Queue
Event Loop 把 setTimeout()
的 callback function 搬到 Call Stack,執行
至此,Call Stack 再度淨空
(使用者點擊了按鈕)
Web API 內的 $.on()
的 callback function 被搬到 Callback Queue
Event Loop 把 $.on()
的 callback function 搬到 Call Stack,執行
把 setTimeout()
交由 Web API 處理(因為不是原生 JS)
Web API 開始等待 2 秒
至此,Call Stack 再度淨空
(過了 2 秒)
Web API 內的 setTimeout()
的 callback function 被搬到 Callback Queue
Event Loop 把 setTimeout()
的 callback function 搬到 Call Stack,執行
至此,Call Stack 再度淨空
如果我提早點擊按鈕?
上述的流程是比較順的正向流程,但真實情境下,哪會在那邊等 5 秒才按按鈕啊,如果我們提早點擊按鈕,會發生什麼事?
你會發現,因為你點擊,Callback Queue 很早就有指令了,但那個指令只能乖乖排隊,等程式碼跑完最後一行程式,才會輪到它,因為 Event Loop 要等 Call Stack 的指令都跑完,才會放 Callback Queue 的人進來。
setTimeout 0 秒算是同步還非同步?
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
console.log('3');
這問題真的很妙,「我等了你 0 秒,請問我算是有等你嗎?」,彷彿成了哲學思考問題XD
如果 setTimeout
只用一句很簡單的「等了幾秒就執行」來概括,就會在這個範例被卡住,因為這個範例一秒都不用等,那是不是就會立刻執行呢?
大家可以試著自己想想上述的程式碼會印出什麼,其實核心問題跟上一題提早點擊是一樣的。
最後答案是:
沒錯,只要是非同步(如 Web Api)的程式碼,就一定要進 callback queue 蹲著,不管有多快進去,都一定要等 call stack 的程式碼都跑完,才有可能輪到它。
對於新手比較容易理解的會是這樣:「要等同步都執行完,才會輪到非同步」。
視覺化的 Playground
這個網站是我極力推薦的地方,我的抽象思維不太好,沒辦法在腦袋中把同步跟非同步攪在一起,可以透過這個 playground,把你寫的 code 實際在 background 執行起來,連 background 在做的事情都清楚顯示給你看,特別適合像我一樣的視覺化動物(?)。
可以看到當你將上面的程式貼上去,按下 Save and Run,程式並不會咻一聲就跑完,而是用大約每一秒 2 個指令的速度,把這行程式碼是被放到 Call Stack 還是 Callback Queue,清楚顯示在畫面上,可以很清楚知道電腦現在正在處理哪個指令。如果還是嫌太快,作者也準備了 Pause 按鈕,按照自己的步調調整。
前面兩題關於「提早點擊」與「setTimeout 0 秒」的問題,你都可以在這個 playground 找到解答。
同步與非同步
如果你很有耐心,把上述一大串都看完,就會知道「非同步」誕生的原因,真的不是憑空誕生的,而是因為就像上面的 Web Api 那樣,對於 Javascript 以外的指令,透過背景運作的機制來完成,就是所謂的「非同步」。
看到這裡,下次被問到「setTimeout 為什麼是非同步?」的時候,你會怎麼回答呢?筆者曾經只能回答「因為要等」這種答案XD,現在才知道背後的學問可大了呢!
另外也有人會疑惑說:「我懂同步跟非同步的意思了,但我不明白,同步兩個字聽起來像是大家一起、同時的感覺,但是為什麼在 Javascript 卻是代表一個接一個、陸續的感覺?」
這比較是視角不同的緣故,或許可以改用這種方式重新理解:
同步:在同一個步道接力跑,第二棒一定要等第一棒結束才跑
非同步:在不(非)同步道同時跑,誰都不等誰,該跑就跑
非同步難歸難,背後的世界卻非常廣闊,當我們真的學會了 Javascript 是如何處理非同步程式碼,才更能在每一次程式運作不如預期時,一步步推敲出問題核心,就離更好的 developer 更近了一步!
在一個世界線
交織著兩個平行宇宙
踏著不同的步調
寫著同一個故事
the-call-stack-and-memory-heap