本篇為 JSDC 2017 - R201#D105 RxJS - 封裝程式的藝術 By Jerry Hong 這場演講的學習筆記。如有錯誤歡迎指正。
What’s RxJS?
有一種說法是,我們可以把 RxJS 想像成 Lodash for async.,專門用來處理非同步行為。
或著更精準的說法,RxJS 就是「一個透過 Observable 來組合各種非同步行為的 Library」。
但我們不是已經有 Promise 來處理非同步了?為什麼我們還需要學 Observable 呢?
Why we need RxJS?
以下方程式碼為例,這是用來實作無限滾動的程式碼:

這段程式碼的邏輯大致如下:
- 註冊滾動事件:因為是無限滾動,我們需針對 Scroll 監聽事件。
- 判斷滾動高度:透過 event 拿到 DOM 物件,即可計算出使用者目前 Scroll 頁面多少 %。
- 發送 Request:當使用者 Scroll 超過 90%,就讓程式發送 Request,取得文章資料並顯示在畫面上。
但這種寫法其實有些問題,每當使用者持續 Scroll 到 90% 時,會不斷發送同樣的 Request,此時畫面就會顯示許多重複的資料。
一般解法:設定 Flag
我們可以透過「設定 Flag(標記)」來解決上述遇到的問題。在範例中,就是藉由判斷 isRequesting 的布林值,來決定要不要發送 Request:
- isRequesting 初始值為 false,代表沒有發送請求,當 isRequesting 為 false 時才會發送 Request。
- 發送 Request 前,設定 isRequesting 為 true。
- 接收到 Response 後,設定 isRequesting 為 false。
使用 Promise 的限制
但是這段複雜的程式碼,其實隱藏一些問題:
- 兩個非同步行為 Scroll 事件 & 發送 request
- 同樣是非同步行為,卻使用不同的 pattern:Callback pattern & Promise
- 由於 Promise 無法處理 Scroll 等事件,因此必須用兩種不同的 pattern
- 假設需求改成:接收到 fetch 資料,經過幾秒後再顯示訊息,則必須透過設定 setTimeout
Promise 應該是為了解決 Callback 產生的問題,卻無法改善上述情形。
此外,這是在只設置一個 Flag 的情況下,假如需求不斷增長,必須設定更多的 Flags 時,程式碼將會變得更加複雜⋯⋯
我們一定要寫這麼醜的程式碼嗎?
VanillaJS vs RxJS
以下分別是使用原生 JavaScript 和 RxJS 所撰寫的程式碼:

可以很明顯看出,後者 RxJS 的寫法,即使不知道這些方法的實際用途,卻也能從語意大概猜出每個 function 的功能,大大提高程式碼可讀性。
What is Observable?
想要瞭解 RxJS,就必須先從 Observable 談起。簡單來說,Observable 就是一個集合,再加上時間的維度:
1 | Obervable = Collection + Time |
再更白話一點,Observable 就像是一個「序列」,裡面的元素會隨著「時間推移」。
Observable 建立與訂閱
- 透過 Observable.fromEvent() 建立一個監聽 mousemove 事件的 observable:
1 | // mouseMove 這個變數就是一個 observable |
- 接著就可以訂閱這個 observable,執行 mouseMove.subscribe() 時,就會傳入的 function:
1 | // 訂閱 mouseMove 這個 observable |
- 最後可透過 unsubscribe() 來移除這個監聽事件:
1 | subscription.unsubscribe(); |
更多建立 Observable 的方法
以下舉幾個常見的方法,用來建立 Observable:
1 | // 同步送出 2, 3, 4 後馬上結束 |
觀念整理:observable / operator / oberver / subscription
這裡快速整理有關 observable 和 operators 的幾個重點:
/ observable /
- 是 Observable 的物件實例
- 在尚未被訂閱之前,只是記憶體中的一個物件,不會送出元素
- 可被訂閱(subscribe)
- 具有多種運算子(operators)
/ operators /
- 是 Observable 的方法
- 可對元素進行運算處理
- 永遠回傳一個新的 observable(但有例外)
以下方程式碼為例,其中 map 和 filter 就是 operators,用法其實就和 JavaScript 用來處理陣列的 map 和 filter 類似,只是對象換成了 Observable:
1 | var sub = Observable |
/ observer /
- 用來訂閱 obervable 的物件
- 具有三個方法:next, error, complete
- next 為必要,error 與 complete 則是可選
/ subscription /
- 在 observable 訂閱後回傳的物件
- 可用來退訂(unsubscribe),observable 就不會再執行其他動作
- 可以和其他訂單合併,一直執行退訂動作
舉個例子:生產線

- observable 的元素:生產線上的產品
- operator:生產線上的機具,用來包裝或篩選產品
- observer:啟動生產線的人員
Marble Diagram 彈珠圖
由於 Observable 具有時間的維度,因此我們常會使用圖像的方式來表達,也就是彈珠圖(Marble Diagram)。
呈現方式
-:由左向右代表時間推移n(0-9/a-z):送出的元素(next)|:送出結束(complete)#:送出錯誤(error)():同步送出
1 | ---0---1---2---3-- |
以下舉幾個範例:
Observable.of(1, 2, 3):同步送出後馬上結束
1 | (123|) |
Observable.interval(10):每隔 10 毫秒送出,注意 0 也會占一格
1 | -01234... |
Observable.fromEvent(DOM, 'click'):監聽 click 事件,當使用者點擊時才會發送 event 物件
1 | ---e--ee-e--... |
綜合範例
例一:take & map & filter
1 | Observable.interval(10) // 每隔 10 毫秒送出 |
彈珠圖如下:
1 | -01234... |
例二:takeUntil & fromEvent
1 | Observable.interval(20) // 每隔 20 毫秒送出 |
彈珠圖如下:
1 | --0-1-2-3-4-5... |
例三:map & margeAll => mergeMap => switchMap => exhaustMap
- map & margeAll
1 | Observable |
彈珠圖如下:
1 | -----e-e---- // 使用者點擊兩次 |
如果不使用 margeAll 就直接訂閱結果,會因為 Observable 裡面還有 Observable,而無法拿到實際的 response,因此需要先將二維的 Observable 攤平,再繼續往下送結果。
- map + margeAll => 可簡寫成 mergeMap
1 | Observable |
- switchMap => 當第一個 Observable 尚未處理完,又送出第二個 Observable 時,取消舊的訂閱,只想保留新的結果
1 | Observable |
彈珠圖如下:
1 | -----e-e---- |
- exhaustMap => 當第一個 Observable 尚未處理完,又送出第二個 Observable 時,不管新的,只想保留舊的結果
1 | Observable |
彈珠圖如下:
1 | -----e-e---- |
【範例】無限滾動
接著回到一開始的無限滾動,使用 RxJS 語法邏輯如下:
1 | Observable.fromEvent(scrollView, 'scroll') // scroll 事件 |
Make Your Code Clean
在熟悉如何使用 RxJS 語法之後,能夠提升程式碼可讀性;但,實際上真是如此嗎?
隨著程式碼以及需求漸趨複雜,就算使用 Observable,還是可能會寫出不易閱讀的程式碼,這時我們需要思考的,是如何讓簡潔化我們寫的程式碼。
也就是如何達成 Code Clean 的三大重點:
- Readable 可讀的
- Composable 可組合的
- Testable 可測試的
(1) Readable 可讀的
【範例】scrollOverNinePercent & getPostObservable
- 抽取出有意義的、可能被多次使用的 Observable,並 assign 給一個變數:
$scroll

- 抽取出共用的 Operator,並命名為一個 function:
scrollOverNinePercent()

- 為了避免出現多個小括號,可使用
letoperator,即可取得 function 的回傳值

- 將共用 function 放到獨立的檔案

- 抽離 Observable creator,也就是抽出「有建立新的 Observable」的程式碼:
getPostObservable()

修改後的程式碼如下,透過簡化邏輯,提高了程式碼可讀性:
1 | import { scrollOverNinePercent } from '...'; |
(2) Composable 可組合的
可組合的意思並不在於是否可重用,重點是「能夠和其他程式碼組合,來針對不同情境使用」。
在 RxJS 中,盡可能使用 Observable 來撰寫程式碼,因為 Observable 本身就是可組合的!
透過 Pipe 來組合多個 Operators
在演講中,有提到 RxJS 5.5 版本發佈了新特性:lettable operator,但其實 lettable 這個詞,後來又被改為 pipeable,因此後續皆以 Pipeable Operators(操作符)稱呼。
pipe 是 Observable 的一部分,不需導入即可使用,可代替 let 操作符,用來組合 Operators,傳入的 Observable 會回傳新的 Observable,再繼續往下傳遞。
1 | source$.let(myOperator) |
要把 Operators 從 Observable 中獨立出來,必須解決 Operator 名稱和 JavaScript 的關鍵字衝突問題。
因此對部分 Pipeable Operators 名稱進行修改:
- do -> tap
- catch -> catchError
- switch -> switchAll
- finally -> finalize
詳細可參考這篇文章:[譯] Pipeable 操作符
【範例】scrollOver & onErrorReturn
1. scrollOverNinePercent() => scrollOver()
還記得我們剛才寫的 scrollOverNinePercent() 嗎?實際上,這個 Function 並不易用來重組,因為 scroll 觸發時機點不一定會在 90%。

這時可透過 Higher Order Function 解決,也就是用一個 Function 回傳另一個新的 Function,作法如下:
- 在第一個 Function 傳入
criticalP(代表臨界點),取代原本 0.9 的位置

- 接著可使用 Pipeable Operators,也就是改用
pipe來組合多個 operator,傳入的 Observable 會回傳新的 Observable,再繼續往下傳遞

修改後的程式碼如下,讓 scrollOver() 的使用更加彈性,同時也透過 pipe 來簡化程式碼邏輯:
1 | import { scrollOver } from '...'; |
2. getPostObservable() => onErrorReturn()
- 取得文章的 Function,可能會需要進行錯誤處理,例如透過
retry來重新發送
1 | const defaultData = { success: false, data: []}; |
- 經過簡化改回傳 Array,Array 會自動被轉成 Observable:
1 | const defaultData = { success: false, data: []}; |
- 同樣改用 pipe 來組合運算子
1 | import { retry, catError } from 'rxjs/operators'; |
- 也可以試著自己定義一個 Pipeable Operators:
onErrorReturn
1 | import { retry, catError } from 'rxjs/operators'; |
- 再把
onErrorReturn放到獨立的檔案,這段邏輯即可被重用
1 | import { retry, catError } from 'rxjs/operators'; |
【其他應用】物件拖拉 vs 取消請求
這裡舉 RxJS 的經典案例「物件拖拉(Drag & Drop)」,可拆解成以下步驟:
- 使用者對目標物按下滑鼠左鍵:mouseDown$
- 使用者移動滑鼠:mouseMove$
- 直到放開滑鼠左鍵:mouseUp$
若以程式碼表示:
1 | mouseDown$ |
再舉另一個「點擊 Neflix 影片清單後,馬上取消請求」為例,動作可拆解如下:
- 使用者點擊影片方塊:mouseClick$
- 發送 Request 讀取影片資訊:request$
- 如果在尚未接收到 Response 之前,就按下取消關閉視窗:cancel$
程式碼如下:
1 | mouseClick$ |
嗯?這段程式碼怎麼好像似曾相識?
把剛才的兩個範例拿來比對:

可以發現到,程式碼竟然有 87% 像!因為兩段邏輯其實極為類似,都是在「做某件事情後,再做另一個非同步行為」,而這個非同步行為,會在某個時間點、發生某件事情時而被取消。
既然是相同邏輯,何不重用乎?
(3) Testable 可測試的
程式碼寫得好壞,通常是和「是否可進行測試」畫上等號的。因為能夠被測試的程式,才能被檢驗程式的品質。
然而,在非同步的程式碼,其實是不易去撰寫測試的。
通常在撰寫非同步測試時,會有以下兩種方法:
- 模擬非同步測試
- 例如:使用 setTimeout 去模擬等待時間;或透過 Jest 模擬非同步行為,可參考這篇討論:_.debounce() breaks fake timers #2893
- 情境複雜不易模擬,且容易出錯
- 出錯時不知道要找誰:Lodash 提供非同步行為 & Jest 提供時間模擬
- 精確非同步測試
- 例如:真的等待需要的時間
- 測試會花費過多的時間
那麼,RxJS 是如何解決非同步測試遇到的問題呢?
問題解決:Marble Testing 彈珠測試
還記得我們在前面提到的彈珠圖嗎?我們可透過彈珠圖來撰寫非同步測試:
- 用 Marble Diagram 撰寫測試
- 只要會畫圖就能寫測試
- 100% 可重複的測試
- RxJS 提供時間模擬,同時也提供測試方法
- 不需花時間等待測試結果
以下是之前舉的彈珠圖範例:

撰寫測試如下,透過預知最後結果的彈珠圖,我們可用來進行單元測:

- 更多 Unit Test 教學,可參考這篇:如何「畫圖」寫測試 - RxJS Marble Test
結語
Programming is thinking, not typing. – Cassy Pottan
我們在撰寫程式時,重要的應該是如何去思考,而非單純敲打文字。
Be a Programmer, not just a Coder.
當一個有想法的開發者,而不只是一個碼農。
時隔半年再來重新回顧 RxJS 的演講,一開始還不太懂 pipe 和 subscribe 使用時機?Operators 之間的差別?Observable 到底好用在哪?直到陸續在工作中實際應用,儘管偶爾和是會寫出和自己預期不符的程式碼,踩過一個又一個坑,累積不少經驗值之後,終於逐漸理解 Observable 的神奇魔力,期許自己未來能夠掌握 RxJS,靈活應對各種非同步行為。
參考資料
- RxJS - 封裝程式的藝術 - 演講簡報
- btroncone/learn-rxjs
- RxJS-CN/learn-rxjs-operators
- 30 天精通 RxJS
- 希望是最淺顯易懂的 RxJS 教學