無痛理解 JS | 非同步怎麼運作?

Cover

總以為世界的形狀我們早已熟悉,但到頭來卻發現並不是。 筆者每每在寫 Javascript 時,處處都會發現驚喜。 下面想舉一個曾經印象深刻的例子給大家看:

console.log('Start');
console.log('Stop');

印出的結果為

Start
Stop

那中間如果加了

setTimeOut(()=>{
    console.log('0 sec later');
},0);

變成

console.log('Start');

setTimeOut(()=>{
    console.log('0 sec later');
},0);

console.log('Stop');

則結果竟會變成

Start
Stop
0 sec later

疑?這是什麼令人驚訝 奇葩 的結果?明明在中間加入了 0 sec later,為什麼 console 出來的順序會是這樣? 要搞懂這個結果,就必須先來了解 Javascript 擁有的非同步特性

阻塞(blocking) : 如果不存在非同步?

不過,我們先來試著思考一個問題:非同步真的很重要嗎?沒有非同步的世界會怎樣呢?

阻塞的概念可以想成每年過年高速公路必塞車的現象,高速公路 可以想成等等會提到的 Call Stack,而車子 相當於 程式

每跑一段程式就要花時間等待執行完畢,從 Call Stack 中跳走後,下一個程式才能執行,於是當前面某個程式很慢就會連帶影響之後的程式。在瀏覽器上我們會看到類似當機的畫面,什麼事都做不了。

想要避免造成當機的模樣,那麼我們來了解怎麼疏通高速公路吧!

一起來瞧瞧非同步的骨架

JS 的主要特點有兩個:

  • Single Thread (單線程)
  • Synchronous (同步)

說明 Javascript 一次只執行一件事,程式會逐行執行

那為什麼在網頁我們仍可以處理像是滑鼠點擊之類看起來「一次處理很多事件」的非同步 ( asynchronous ) 事件而不會阻塞塞車呢?原因是當 Javascript 在 V8 Engine 執行時,其實也會執行瀏覽器提供的 API ( Web APIs,這裡已經不是在 V8 Engine 的範疇 ),前文提及的滑鼠點擊 (click )即屬於 Web APIs。

Web APIs 讓我們可以非同步執行程式, 也就是說 JS 本身並無法非同步執行, 需要借助 Web APIs 才能達到非同步的效果。

那非同步是怎麼運作的呢?

首先,要先了解下面四點的定義:

Call Stack

Call Stack 本身為 Stack,
Stack 是資料結構的一種,會遵守 LIFO (Last In, First Out) 的原則。

Call Stack 會繼承 Stack 原本的特性,即「最後被 call 的 function 會在最上層,
而執行完後會最先從該 stack 離開(pop off)」。

而 Javascript 是 Single Thread, 所以只會有一個 Call Stack。

Stack

圖片來源 / wiki - Stack

Web APIs

瀏覽器提供的 API,例如常被使用的 setTimeout。
setTimeout 是瀏覽器所提供的計時器,常常搭配 callback function 使用。

Task Queue

又可以稱作 Callback Queue,
放在 Web APIs 的 function 在執行完後,會到 Task Queue 待命。
(e.g. setTimeOut 在秒數執行完後,會移至 Task Queue)

Event Loop

偵測到 Call Stack 為空時,
把在 Task Queue 裡等待的第一個 callback function 放到 stack 中去執行。

好啦!解說到此,應該可以理解下面這張圖:

Web APIs function 執行路線圖

Pic1

非同步可以解決的事

非同步的設計是讓需要執行較久時間的程式能夠移到別的地方去執行,
不要阻塞到 Main Thread(Call Stack),
讓程式能夠暢通在 Call Stack 上執行。

舉個實例,以前面提的滑鼠點擊為例

console.log('Started.')

$.on('button', 'click', function onClick () {
  console.log('Buttton Clicked!')
})

console.log('Done.')
  • Started console 會執行,會先進入 Call Stack 中,再來執行滑鼠事件 click function,最後執行 console Done

  • clickWeb APIs,所以之後會從 Call Stack 脫離到 Web APIs

  • 滑鼠點擊後,callback 就會到 Task Queue 中。Event Loop 在偵測 Call Stack 為空時,便會把 callback 放到 Call Stack 中去執行, onClick 這個 function 就會執行

Pic2

所以我們可以理解為在點擊滑鼠時,
onClick function 並不會立即執行,
會先到 Task Queue 中等待,
直到成為 Queue 中第一位且 Stack 為空時才會執行! 

R5gruqc

操作自 Loupe - by Philip Roberts: Loupe 是一個由 Philip 做的視覺化工具,對瞭解 Javascript function 執行流程很有幫助

以上為 JS 如何在 Single Thread 下實現看似擁有 Multi Thread 的效果。

再來幾個例子

筆者在學習這個概念查找資料時,發現了一場精彩演講What the heck is the event loop anyway? 講解頗為精闢,於是下面擇幾個非同步相關例子與讀者分享。

非同步的 AJAX Request

Philip Roberts 曾在 JSConf 舉使用屬於 WebAPIs 的 AJAX 抓取資料的例子:

console.log('Hi')

$.get('url', function cb(data) {
  console.log(data)
})

console.log('JSConfEU')

因為 AJAX 為 WebAPIs 其中一種,所以並不在 Javascript Runtime 中。

  • console Hi 會先執行,再來 AJAX 執行請求,這時 callback 移到了 Web APIs ,然後 console JSConfEU 緊接著執行

  • AJAX 發出的 request 回應後(success 或 error),callback function 會移到 Task Queue

  • Event Loop 在偵測 Call Stack 為空時且 AJAX callback 在 Task Queue 的第一位時,就會把 callback 丟到 Call Stack 中執行

所以執行結果為:

Hi
JSConfEU
data(類型可能為 xml、html、text、script、json)

Pic3

非同步讓 AJAX 從 sever 抓取資料到 client 的時間,可以讓 Javascript 並行(非同步)做別的事,減少阻塞程式運行的機率。

setTimeOut 不是真的就在那幾秒之後執行 callback function

setTimeout(function timeout() {
  console.log('5sec later?')
}, 5000)

點我看 Loupe 視覺化

這會怎麼執行呢?

  • setTimeOut 是屬於 WebAPIs,在執行 setTimeOut 時,會從 Call Stack 跳出至 WebAPIs 執行
  • callback function 經過 5 秒後會跳至 Callback Queue
  • 等待最後跳至 Call Stack 被執行

這整個過程中,其實已經花費不只 5 秒,也就是說 setTimeOut 只能保證最小執行時間是 5 秒,但並不能說 執行時間是 5 秒

Callback Queue 阻塞:scroll event

scroll 屬於 WebAPIsEvents 的一種,一般在捲動滑鼠時,短時間內會頻繁的送出 scroll 的 event,造成 DOM 節點不斷的重新運算, Callback Queue 阻塞,滑動的頁面會非常的遲緩。

要解決這個問題,lodash 有提供 throttledebounce 兩種 method 可以快速方便解決。

結語

講到這裡,我們可以理解文章開頭所舉的例子了吧!

點我看 Loupe 視覺化

我們也可以清楚的知道 Javascript 如何非同步運作,達成同時具有同步以及非同步的特性。正因為如此,Javacript 是一個極為靈活彈性的語言,能夠悠遊於前端與後端,無往不利!

參考資料: