廢話前言
突然發覺我每篇都習慣有個前言,感覺有點冗,瀏覽了下其他文章的前言,內容都在解釋為什麼有這篇文章,其實也滿符合 WHY 的原則吧哈哈,所以不免俗的再來個前言吧
工作的關係最近半年開始接觸前端,在開發的過程中自然而然碰到了非同步的問題,避免自己日後忘記跟又換開發領域,跳轉來跳轉去的導致領域知識零碎,發一篇文章好好紀錄關於 JavaScript/TypeScript 中幫助開發者處理非同步情形的 Promise & async & await 的機制,是如何運作的
同步與非同步
先來講講在程式執行中什麼是同步跟非同步執行,其實這個在中文的定義裡滿容易搞混的,以前聽到這兩個名詞也混淆了一下,以為同步的意思平行執行,非同步就是照順序,但事實上定義是反過來
同步
同步 (synchronous) 在程式執行中的意思是,照程式碼的順序執行
可以想成這些程式碼在同個跑道上進行接力賽,所以叫做同步執行,只能一個接一個跑下去
非同步
也有人稱做「異步」
非同步 (asynchronous) 在程式執行中的意思是,不是按照程式碼的順序執行
可以想成這些程式碼不在同個跑道上進行賽跑,所以叫做非同步執行,可以一個在跑另一個也在跑
JS 程式執行基本認識
在 JavaScript 這個語言中常常會有非同步 (asynchronous) 的情形,然而 JavaScript 是 Single Threaded 的 programming language,所以只會有一個 call stack & memory heap,執行程式的時候會等待上一個程式區段完成才會執行下一個,這樣對於網頁來說不是件好事,如果網頁正在執行某個需要做 fetch 程式片段,而導致整個網頁都動彈不得,對使用者體驗來說會很糟糕
那到底 JavaScript 是如何在網頁(瀏覽器)中實現非同步的執行的?
Google 在 2008 年推出的開源軟體 V8 engine 給了 Solution:
透過瀏覽器自己的 Web API 提供一個 Queue 在背景來執行這些需要等待的程式片段,完成後再 push 回 main thread 的 call stack
其他瀏覽器也有自己的 JavaScript engine,像是 Apple 開發給 Safari 用的開源軟體 JavaScriptCore,或是 Microsoft 給 Edge 用的 ChakraCore 等等,都是運用同樣的概念讓 JS 能實現非同步的行為
這裡有個 JSConf 的 YouTube 影片 對於 JS 的 Call Stack 怎麼運作講得很好,聽完會對於 JS 跟 Browser Web API 是如何處理網頁的執行有個基本認識
Concurrent vs Parallel
又是一個在中文上容易混淆的 CS 名詞,Concurrent 的翻譯叫做「同時」,而 Parallel 叫做「平行」,乍看之下根本一樣,但事實上這兩個名詞分別代表兩個完成任務的方式
Concurrent
指的是同一時間只做一件任務,在多個任務需要做的情況下,可以想成一個人同時開始做了好幾個不同的任務,在一個時間內完成了很多任務,但事實上只是在各個任務間切換來切換去 (task switching)
就想成自己同時間打開了數學和英文作業,但只能用一隻手寫字,所以只是寫了點數學再換寫英文
Parallel
指的是同一時間做好幾件任務,在多個任務需要做的情況下,可以想成有多個人分別同時開始做了不同的任務
就是和同學兩個人同時打開數學和英文作業,兩人分工各自寫數學和英文
而 JS 在完成任務上是屬於 Concurrency 的語言
不同於 C++ 或是 Java 的 Concurrency 指的是將不同任務切成好幾個 Process or Thread,輪流跑在一個 CPU 上來完成任務
而是前面提過的
Call stack 與 WebAPI 的配合,讓 JS 不會因為一個任務還沒完成就不能執行下一個任務,所以可以 Concurrently 的進行這些任務,在不同任務間切換來切換去
Callback func
如同前面提到,Web API 會協助先暫時在背景執行需要時間的程式,讓 JS 能繼續往下執行,而 Callback function 就是一個例子,把函式當作另一個函式的參數,透過另一個函式來呼叫他
先來看一個簡單的例子
1 | // taskA & taskB function call another function |
常見的 window.setTimeout
也是一個經典例子,他的目的是隔了某段時間後,要執行某件事情
也就是 counter 結束後就會回頭執行包在 setTimeout 內的程式碼
1 | window.setTimeout(function taskA(){ |
Callback hell
如果 Callback function 在 JS 中大量地被使用,除了容易使開發者混淆順序不易維護程式碼之外,多巢結構也會使程式碼的可讀性降低,回調的時間點也都不一致,當 Callback 依賴於另一個 Callback 的 chain 不斷發生時就會很難處理錯誤情況,所以就有了 Callback hell 的戲稱,如下常見的圖所示
Event listener
跟 DOM (Document Object Model) 互動的 API
- addEventListener
- removeEventListener
- dispatchEvent
延伸閱讀: What is HTML DOM - w3school
最典型的例子就是在網頁上各類按鈕觸發的事件,或是滑鼠鍵盤的事件,都是一種接收的 event,但不會使網頁當掉,收到特定的訊號就做特定的事情
監聽 Click 事件例子
1 | const button = document.querySelector("button"); |
然而 Event Listener 對於很多情況還是不夠適用,如果我們想要等特定的事件結束後才做另外一件事,需要有通知告訴我們特定的件事已經結束
比如 loading image 的例子
1 | var img1 = document.querySelector('.img-1'); |
看起來好像我們可以在 image 載入後做事,但實際上程式執行時,有可能在我們開始監聽事件之前就已經載好 image
由此可見 event listener 沒有辦法 cover 所有需求,尤其是網頁通常會有向遠端伺服器載入資料的要求,有時候需要等載入特定資料後才能做其他相關的事情
Promise
Promise pattern 的設計是用來對付非同步的情況,將非同步的程式片段寫成像是同步,而它的參數就是一組 callback function,可以回傳不同狀態回來
Return state
- fulfilled - The action relating to the promise succeeded
- rejected - The action relating to the promise failed
- pending - Hasn’t fulfilled or rejected yet
- settled - Has fulfilled or rejected
以下我們直接以程式碼的範例來 go through Promise 怎麼運作跟使用
Resolving object
當 Promise 成功執行完需要的任務就會 call resolve()
回傳,then
的作用就是接收 Promise 任務完成後 callback 的結果
1 | console.log('start async task'); |
會印出
1 | start async task |
Error handling
可以利用 reject
callback 來處理 error,在 then
接收時搭配 catch
1 | console.log('start async task'); |
會印出
1 | start async task |
Chaining
Promise.then()
會 return 一個 Promise,所以一個 .then
的後面,可以再串接更多的 .then
做回傳,可以讓回傳的資料型態透過包在 Promise 內而有所變化
1 | let promise = new Promise((resolve, reject) => { |
會印出
1 | 1 |
Error handle with chaining
Promise rejections skip forward to the next then() with a rejection callback (or catch(), since it’s equivalent)
then(func1, func2)
func1
或func2
會被呼叫,或是都不會被呼叫
then(func1).catch(func2)
- 如果
func1
rejects,func1
和func2
都會被呼叫
- 如果
看以下例子
1 | asyncThing1().then(function() { |
Flow chart
- Green line: fulfilled
- Red line: rejected
Exception
Promise 的 rejected status 會吃 thrown Error,即便沒有特別寫 reject()
做 callback
1 | let jsonPromise = new Promise((resolve, reject) => { |
會印出
1 | It failed! SyntaxError: Unexpected token T in JSON at position 0 |
更多深入細節可以參考這個 Google 的官方文件
Promise all
Aggregating multiple Promise result. It is typically used after having started multiple asynchronous tasks to run concurrently and having created promises for their results,
如果我們今天想要讓一些任務全部都結束後才開始做事,要使用一個一個 Promise 然後不斷的 return 再 .then
會很崩潰
於是就有了 Promise.all()
,可以等指定的任務們都執行完成再統一收集結果,而 Promise.all()
的回傳值也是一個 Promise
目的就是等待所有任務完成(不管任務完成的先後順序),才開始做事
這邊示範一個聖誕派對的範例,等待朋友們給我禮物XD
朋友們基本上都不會準時,大家都會在不同時間抵達
而聖誕派對的規則是要確認所有朋友都抵達後才能開始交換所有禮物
1 | function friend1(gift: string) { |
會印出
1 | friend 4 is delivered. |
Fail-fast behavior
需要注意的是 Promise.all()
的其中一個 Promise 被 rejected 的話,即便其他 Promise 正常執行, Promise.all()
也會被rejected,我們可以使用 catch
來處理錯誤發生時的行為
把上面聖誕派對的範例程式其中的一小段改成
1 | function friend3(gift: string) { |
Friend 3 雷隊友沒有錢買禮物,沒有上繳禮物
就算其他人有上繳禮物,整個聖誕派對的交換禮物還是失敗了
會印出
1 | friend 4 is delivered. |
Promise 看起來是能解決 Callback hell 的好東西,但如果 Promise chaining 太多的話也是另一個 hell,於是 JavaScript 推出了 async & await 來搭配 Promise,讓一堆非同步的程式碼看起來像同步的同時還能提升可讀性,所以 async & await 可以說是 Promise 的 syntax sugar
async & await
在一個 function 前面加上 async
keyboard 就能使用 await
,而只有 Promise 物件能使用 await
keyword,await
也只能在 async
function 內被使用
async function 會確保在裡面的所有 Promise 任務都完成,當 async function 中的任務全都結束後,會返回一個 Promise,所以也可以用 then
來接收 async function 的 callback 狀態
如果該 async
函式回傳了一個值,視為 Promise 的 resolved,如果 async
函式拋出例外或某個值,則會視為 Promise 的 rejected
簡單的範例
1 | const foo = async () => { |
await keyword
在 async function 內加上 await
keyword 的功用就是暫停,會等待當前 Promise 完成後才進行下一個 Promise
假設 runner1 需要花 4 秒,runner2 需要花 2 秒
加上 async & await,runner 2 會等待 runner 1 結束任務後才開始
1 | function runner1(money: number) { |
會印出
1 | runner 1 is finished. |
有趣的是如果我們把 await keyword 放在 return,結果會不同
1 | async function asyncRunning(): Promise<number> { |
會印出
1 | runner 2 is finished. |
上面小改後的範例看起來像是平行運行,但其實並不是,如同前面說過的,在這個例子裡,runner1 和 runner2 的計時器同時被建立起來,但他在背後是連續執行(Concurrent) 不是平行運行 (Parallel)
後記
介紹到這邊應該差不多了,如果想要更深入了解可以直接去看看源碼或是多翻一些影片來看實例,我本身得靠實際遇到了才比較能體會他的運作,或是吃到虧碰到坑才大徹大悟的類型,這也是本人需要改進的地方
碰到一個新領域時,我其實不會先花時間深入了解它,而是先粗淺的看文件跟其他人的範例,然後直接試著做出需求,後來被同團隊的 Sr. engineer 在 review code 時指出我似乎並沒有完整的了解機制,讓我有機會審視自己,不應該先急著讓東西生出來,而是先靜下心花時間去看懂基本的原理跟運作才對,免得以後碰到大坑根本不知道怎麼跌進去的,也很難爬出來
References: