[](https://hackmd.io/_q5WFK3LQL-JHMBnv07IzQ)
###### tags: `RxJS`
# 【學習筆記】RxJS - 封裝程式的藝術
[TOC]
> 本篇為 [JSDC 2017 - R201#D105 RxJS - 封裝程式的藝術 By Jerry Hong](https://www.youtube.com/watch?v=f1KjK8irCbY) 這場演講的學習筆記。如有錯誤歡迎指正。
## 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 就是一個集合,再加上時間的維度:
```
Obervable = Collection + Time
```
再更白話一點,Observable 就像是一個「序列」,裡面的元素會隨著「時間推移」。
### Observable 建立與訂閱
+ 透過 Observable.fromEvent() 建立一個監聽 mousemove 事件的 observable:
```typescript=
// mouseMove 這個變數就是一個 observable
var mouseMove = Observable
.fromEvent(DOM, 'mousemove');
```
+ 接著就可以訂閱這個 observable,執行 mouseMove.subscribe() 時,就會傳入的 function:
```typescript=
// 訂閱 mouseMove 這個 observable
var subscription = mouseMove
.subscribe(x => console.log(x));
```
+ 最後可透過 unsubscribe() 來移除這個監聽事件:
```typescript=
subscription.unsubscribe();
```
### 更多建立 Observable 的方法
以下舉幾個常見的方法,用來建立 Observable:
```typescript=
// 同步送出 2, 3, 4 後馬上結束
Observable.of(2, 3, 4);
// 與第一種方式相同,可同步送出 2, 3, 4
Observable.from(2, 3, 4);
// from 可接收 Promise 物件,並轉成 Observable
Observable.from(fetch('url'));
// 有提供 ajax 方法,直接把 http request 包成一個 Observable
Observable.ajax('url');
// 透過 fromEvent 來監聽事件
Observable.fromEvent(DOM, 'click');
// 代表每 1000 毫秒,送出一個從 0 開始遞增的值
Observable.interval(1000);
```
### 觀念整理:observable / operator / oberver / subscription
這裡快速整理有關 observable 和 operators 的幾個重點:
#### / observable /
+ 是 Observable 的物件實例
+ 在尚未被訂閱之前,只是記憶體中的一個物件,不會送出元素
+ 可被訂閱(subscribe)
+ 具有多種運算子(operators)
#### / operators /
+ 是 Observable 的方法
+ 可對元素進行運算處理
+ 永遠回傳一個新的 observable(但有例外)
以下方程式碼為例,其中 map 和 filter 就是 operators,用法其實就和 JavaScript 用來處理陣列的 map 和 filter 類似,只是對象換成了 Observable:
```typescript=
var sub = Observable
.from([1, 2, 3])
.map(x => x + 1) // 回傳一個新的元素
.filter(x => x % 2 ===0) // 過濾出想要的元素
.subscribe({
next: x => console.log(x),
error: err => {},
complete: () => {},
});
```
#### / 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)
+ `()`:同步送出
```
---0---1---2---3--
---0---1---2---3|
---0---1---2---3#
```
以下舉幾個範例:
+ `Observable.of(1, 2, 3)`:同步送出後馬上結束
```javascript=
(123|)
```
+ `Observable.interval(10)`:每隔 10 毫秒送出,注意 0 也會占一格
```javascript=
-01234...
```
+ `Observable.fromEvent(DOM, 'click')`:監聽 click 事件,當使用者點擊時才會發送 event 物件
```javascript=
---e--ee-e--...
```
### 綜合範例
#### 例一:take & map & filter
```javascript=
Observable.interval(10) // 每隔 10 毫秒送出
.take(3) // 只取前三個元素
.map(x => x + 1) // 將每個元素加 1
.filter(x => x/2 === 1) // 篩選出除 2 餘數等於 1 的元素
```
彈珠圖如下:
```javascript=
-01234...
-01(2|)
-12(3|)
-1-(3|)
```
#### 例二:takeUntil & fromEvent
```javascript=
Observable.interval(20) // 每隔 20 毫秒送出
.takeUntil( // 當某件事情發生時 Observable 會結束
Observable
.fromEvent(DOM, 'click')
);
```
彈珠圖如下:
```javascript=
--0-1-2-3-4-5...
-------e--------
--0-1-2| // 當點擊事件發生時馬上中止
```
#### 例三:map & margeAll => mergeMap => switchMap => exhaustMap
+ map & margeAll
```javascript=
Observable
.fromEvent(DOM, 'click')
.map(() =>
ajax('url...'))
.margeAll(); // 攤平變成一維
);
```
彈珠圖如下:
```javascript=
-----e-e---- // 使用者點擊兩次
-----o-o---- // 將 event 物件轉成 request 物件,同樣是個 observable
\ \
\ ----r|
----r|
---------r-r- // 收到的 response
```
如果不使用 margeAll 就直接訂閱結果,會因為 Observable 裡面還有 Observable,而無法拿到實際的 response,因此需要先將二維的 Observable 攤平,再繼續往下送結果。
+ map + margeAll => 可簡寫成 mergeMap
```javascript=
Observable
.fromEvent(DOM, 'click')
.margeMap(() =>
ajax('url...'))
);
```
+ switchMap => 當第一個 Observable 尚未處理完,又送出第二個 Observable 時,取消舊的訂閱,只想保留新的結果
```javascript=
Observable
.fromEvent(DOM, 'click')
.switchMap(() =>
ajax('url...'))
);
```
彈珠圖如下:
```javascript=
-----e-e----
-----o-o----
\ \
\ ----r| // 訂閱後進來的 Observable
--! // 退訂原本的 Observable
-----------r- // 只保留後面的 response
```
+ exhaustMap => 當第一個 Observable 尚未處理完,又送出第二個 Observable 時,不管新的,只想保留舊的結果
```javascript=
Observable
.fromEvent(DOM, 'click')
.exhaustMap(() =>
ajax('url...'))
);
```
彈珠圖如下:
```javascript=
-----e-e----
-----o-o----
\ \
\ ----r| // 不會訂閱後進來的 Observable
----r| // 訂閱舊的 Observable
---------r--- // 只保留舊的 response
```
### 【範例】無限滾動
接著回到一開始的無限滾動,使用 RxJS 語法邏輯如下:
```javascript=
Observable.fromEvent(scrollView, 'scroll') // scroll 事件
.map(event => event.target) // 轉成被 scroll 的 DOM 物件
.map(hasScrolled) // DOM 物件被 scroll 了幾 %
.filter(percent => percent > 0.9) // scroll 超過 90% 就繼續後面動作
.exhaustMap(() => fetch('url...')) // 在第一個 Observable 處理完之前,不會處理訂閱的 Observable
.take(3) // 當接收到 3 次 response,這個 Observable 就直接 complete,不會再送出元素
.subscribe(res => {
// Do sometheing to change view
})
);
```
## Make Your Code Clean
在熟悉如何使用 RxJS 語法之後,能夠提升程式碼可讀性;但,實際上真是如此嗎?
隨著程式碼以及需求漸趨複雜,就算使用 Observable,還是可能會寫出不易閱讀的程式碼,這時我們需要思考的,是如何讓簡潔化我們寫的程式碼。
也就是如何達成 Code Clean 的三大重點:
+ Readable 可讀的
+ Composable 可組合的
+ Testable 可測試的
### (1) Readable 可讀的
### 【範例】`scrollOverNinePercent` & `getPostObservable`
1. 抽取出有意義的、可能被多次使用的 Observable,並 assign 給一個變數:`$scroll`

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

3. 為了避免出現多個小括號,可使用 `let` operator,即可取得 function 的回傳值

4. 將共用 function 放到獨立的檔案

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

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

這時可透過 [Higher Order Function](https://zh.wikipedia.org/wiki/%E9%AB%98%E9%98%B6%E5%87%BD%E6%95%B0) 解決,也就是用一個 Function 回傳另一個新的 Function,作法如下:
1. 在第一個 Function 傳入 `criticalP`(代表臨界點),取代原本 0.9 的位置

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

修改後的程式碼如下,讓 scrollOver() 的使用更加彈性,同時也透過 pipe 來簡化程式碼邏輯:
```javascript=
import { scrollOver } from '...';
import { getPostObservable } from 'xxx';
import { exhaustMap } from 'rxjs/operators';
Observable.fromEvent(scrollView, 'scroll') // scroll 事件
scroll$
.pipe(
scrollOver(0.9),
exhaustMap(getPostObservable),
)
.subscribe(res => {
// Do sometheing to change view
});
```
#### 2. `getPostObservable()` => `onErrorReturn()`
1. 取得文章的 Function,可能會需要進行錯誤處理,例如透過 `retry` 來重新發送
```javascript=
const defaultData = { success: false, data: []};
const getPostObservable = () =>
Observable.ajax('url...')
.retry(3); // 當文章取得失敗時,會重新嘗試三次後,還是失敗就拋出例外
.catch(() => Observable.of(defaultData)); // 如發送三次仍失敗,則回傳預設值
```
2. 經過簡化改回傳 Array,Array 會自動被轉成 Observable:
```javascript=
const defaultData = { success: false, data: []};
const getPostObservable = () =>
Observable.ajax('url...')
.retry(3);
.catch(() => [defaultData]);
```
3. 同樣改用 pipe 來組合運算子
```javascript=
import { retry, catError } from 'rxjs/operators';
const defaultData = { success: false, data: []};
const getPostObservable = () =>
Observable.ajax('url...')
.pipe(
retry(3),
catchError(() => [defaultData]),
)
```
4. 也可以試著自己定義一個 Pipeable Operators:`onErrorReturn`
```javascript=
import { retry, catError } from 'rxjs/operators';
const defaultData = { success: false, data: []};
const onErrorReturn = defaultData =>
catchError(() => [defaultData]);
const getPostObservable = () =>
Observable.ajax('url...')
.pipe(
retry(3),
onErrorReturn(defaultData),
)
```
5. 再把 `onErrorReturn` 放到獨立的檔案,這段邏輯即可被重用
```javascript=
import { retry, catError } from 'rxjs/operators';
import { onErrorReturn } from '...';
const defaultData = { success: false, data: []};
const getPostObservable = () =>
Observable.ajax('url...')
.pipe(
retry(3),
onErrorReturn(defaultData),
)
```
### 【其他應用】物件拖拉 vs 取消請求
這裡舉 RxJS 的經典案例「物件拖拉(Drag & Drop)」,可拆解成以下步驟:
1. 使用者對目標物按下滑鼠左鍵:mouseDown$
2. 使用者移動滑鼠:mouseMove$
3. 直到放開滑鼠左鍵:mouseUp$
若以程式碼表示:
```javascript=
mouseDown$
.switchMap(() => // 將監聽對象 mouseDown$ 轉成 mouseMove$
mouseMove$.takeUntil(mouseUp$)) // 直到 mouseUp$ 時結束
.subscribe(value => {
// do something
});
```
再舉另一個「點擊 Neflix 影片清單後,馬上取消請求」為例,動作可拆解如下:
1. 使用者點擊影片方塊:mouseClick$
2. 發送 Request 讀取影片資訊:request$
3. 如果在尚未接收到 Response 之前,就按下取消關閉視窗:cancel$
程式碼如下:
```javascript=
mouseClick$
.switchMap(() =>
request$.takeUntil(cancel$))
.subscribe(value => {
// do something
});
```
嗯?這段程式碼怎麼好像似曾相識?
把剛才的兩個範例拿來比對:

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

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

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