Javascript Asynchronous Code
不論是撰寫前端 Javascript還是後端 NodeJS,異步操作都是無法避免的,像是向後端請求資料、處理瀏覽器事件、計時器等等都是常見的異步操作。
異步操作和我們熟悉的同步不同,當異步操作執行時,瀏覽器/NodeJS 不會等待當前操作的結果回傳後才執行下一行程式碼,他會繼續不回頭的執行下去,也就是我們常聽到的無阻塞特性 (non-blocking)。等遠端資料回傳後,瀏覽器或 NodeJS 才會通知 Javascript 引擎去呼叫使用者當初指定的 callback 做處理。
以下面程式碼為例。先定義一個函式 getRemoteData,其目的是用 Ajax 技術發出GET請求。我們運用 Javascript 中 function 可以當成變數使用的特性,將成功和失敗要執行的函式當成參數傳入 getRemoteData,並在裡面定義兩個函式的呼叫時機。這種撰寫方式稱為 callback pattern,被當成參數傳入的函式就叫callback,之後這個異步函式會在本文後面的例子不斷被使用到。
我們知道 Ajax 請求為異步操作,所以這裡程式不會等待 getRemoteData 完成後才繼續執行,而是會馬上接著跑 showStudents,此時 showStudents 的參數 students 仍為 null,故造成程式錯誤。
要解決此問題,必須將 showStudents 放入 callback 函式,讓Ajax在資料回傳後執行 success callback 時,才執行 showStudents。
這種寫法會讓熟悉多執行緒的 C#、JAVA 使用者一開始很不習慣,其中也有許多優點與缺點,本篇專注在討論缺點:callback hell,和如何利用 Promise 來解決 callback hell 的問題。
什麼是 callback hell? 如何做初步重構?
如果有異步動作需要有順序性的呼叫,在程式撰寫上常常需要一層包一層,如下面的例子,click 事件的 callback 包了 getRemoteData 這個異步操作,getRemoteData 的 callback 又註冊了 mouseover 事件,mouseover 的 callback 又包了 getRemoteData,這種寫法會導致程式的可讀性變差,我們稱這叫做callback hell。
為了讓程式更可讀,我們利用 javascript 的 function 是 first class citizen 的特性,將這些 callback 命名成一個個易解讀的變數並分離出來,這種做法叫做 continuation-passing style (CPS)。像上面的例子,可以被重構成如下。
在這裡要順道提醒,異步操作和同步操作混用時要多多注意 closure 的特性,他會導致一些超出預期的結果,以下面 loop 內的異步呼叫為例子。
本程式的結果不是大家預期的:
而是
我們會發現,雖然後面的平均成績對了,但其學號都是 students 陣列中最後一位學生的學號。
這個原因是因為 showStudents 並不是馬上執行,而是等到 Ajax 請求的 grades 回傳後才被呼叫,此時的 student 變數已經不是當時請求送出時的 student了,而是被最後一次給值的 student,也就是學號為 111113 的 student。所以我們必須要用 curry 寫法來避免這種問題產生,如下。
利用 closure 的特性將 student 變數鎖在 showGrade 的 closure 之中,這樣呼叫 callback 時就會使用到當時指定的 student 變數,並達到我們原本預期的結果。
雖然 CPS 有改善程式的可讀性,但仍遠不夠應付更加複雜的應用程式操作,這時就來到本篇的重點,Promise!
初解Promise
我們常把 Promise 用來包裝異步函式,因為它能做到以下幾點:
- 用 pipeline 的方式解決 callback hell 問題
- 好的 Error handling 方式
Promise 擁有三種狀態: Pending, Fulfilled, Rejected,Pending 代表此 Promise 內的程式還未完成;Fulfilled 代表程式已執行成功,到達此狀態時 Promise 會呼叫 resolve 函式;Rejected 代表程式執行失敗,到達此狀態時 Promise 會呼叫 reject 函式。
我們可以親自建構 Promise 要何時到達 Pending, Fulfilled, Rejected,如下面是我們用 ES6 Promise 做的展示,ES6 Promise 採用的是 Promises/A+ 的標準。
我們設定 Promise 中異步請求的結果如果回傳成功,就到達 Fulfilled 狀態並執行 resolve(result);請求結果如果回傳失敗,到達 Rejected 狀態並執行 reject 函式。
根據上面的範例,resolve 和 reject 函式都是”非同步操作完成”後才執行,所以在執行到 resolve 或 reject 之前,此 Promise 會一直保持在 Pending 狀態等待遠端的回傳結果,由此達成”異步操作完成後才會繼續執行”的效果。
之後,我們便可以使用 Promise 的 then 指定 resolve 函式,catch 指定 reject 函式,如下:
resolve 和 reject 的回傳值會被包裝成新的Promise回傳,之後我們就能利用新的 Promise 繼續做串接,達到 pipeline coding 的效果,讓程式可讀性獲得提升。
Promise串接的圖解可以參考如下,每個操作都是接續同步執行的!
Promise 在 Function Programming 裡類似於 Monad,關於 Monad 的圖解說明可以看這裡。簡單來說,Monad是一個值或是函式的 Wrapper,這個 Wrapper 擁有兩點定義:
- unit :: a -> Monad a (將 a 值用 Monad 包裝)
- flatMap :: Monad a -> (a -> b) -> Monad b (取出 Monad 的 a 做計算後,生成 b,將結果 b 重新包裝成新的 Monad)
在我們撰寫的 Promise 範例中,function (resolve, reject){ … } 相等於上面定義的 a 值。我們將這個函式用 Promise 包裝,之後用 then 指定 resolve 函式;用 catch 指定 reject 函式。Promise 會在執行 resolve 或 reject 時,將函式執行完的回傳結果包裝成新的 Promise。這樣的流程符合上面定義的 Monad 特性,所以我們可以說 Promise 其實是 Monad。
使用Promise
這節將幫助大家了解如何實際使用Promise。請看下面例子,這段程式首先呼叫後端全部學生的資料,將回傳的學生們經過排名後,用 for 迴圈檢查哪些學生的住所在 US,呼叫每一位在 US 的學生成績後計算平均,最後顯示在頁面。
如果單純的使用 callback 撰寫,程式碼的可讀性會變得很差,重用性也不高,接下來我們就用 Promise 改寫。
(1) 將異步操作包成Promise (known as promisifying a function)
首先,讓我們將異步操作 getRemoteData 改寫成回傳 Promise 資料結構的 getPromiseWithRemoteData!
(2) 將連續動作都用 then 的呼叫方式改寫,用 catch 方式補充例外處理
有了 getPromiseWithRemoteData 後,就能將上面的程式改寫成下面,程式碼一下子少了很多。
補充 Promise.all()
根據上面的 students.map 程式碼,每個學生會透過 getPromiseWithRemoteData 取得所有分數後將平均顯示到頁面上,再回傳 Fulfilled 狀態的 Promise。但向遠端請求分數時,每個學生的分數回傳時間不一樣,會導致學生們的分數整合困難。舉例,如果我們希望得到住在 US 的學生的平均分數的再平均,根據之前的思路我們可能會撰寫如下。
但這種需求用上面的寫法就會出錯,因為學生們的分數不會如我們想的那樣同時到達,而是會陸陸續續回傳,導致無法算出正確的平均,此時就需要使用 Promise.all()。
Promise.all()會將一個 Promise 陣列包裝成新的 Promise 物件,此 Promise 物件會等待陣列中的每個 Promise 都變成 Fulfilled 後,才執行 resolve 函式到達 Fulfilled 狀態,只要陣列中有一個 Promise 到 Rejected,Promise.all() 就會終止並到達 Rejected 狀態。我們可以將上面的程式改寫如下:
就算陣列元素本身不是 Promise,Promise.all() 也會先將陣列元素都轉換成 Promise 元素後,再用 Promise 包裝陣列。有了Promise.all(),我們也能做到”等待多個回傳資料”的功能。
後話
熟悉 ES6 Promise 後,可以搭配學習 Generator 和 Async/Await,觀察整個異步撰寫方法的演進。
範例參考與圖片來源