Centralized Error Handle in React with RxJS

使用 Redux-Cylces 實行錯誤集中處理

Michael Hsu
10 min readJul 13, 2017

本篇雖然主要搭配 Cycle.JS 來處理 HTTP Error,但是理論上任何 Rx 概念應該也通用。

本篇主旨:示意簡化後的 Marble Diagram

HTTP Error Handling

不論是用 Browser Native Fetch 或是第三方的套件來發送 HTTP 請求,都會明確地把 Success 回應(Status Code 2oo)與 Failure 回應(Status Code 非 2oo)劃分開來,避免後續處理資料結構不一至的問題。以 Fetch 為例,通常會用 ok 布林值來做區分,把 Fail Response Throw 出去:

function handleError(response) {
if (!response.ok) throw response;
return response;
}
fetch('/api/todos')
.then(handleError)
.then(res => ...)
.catch(error => ...);

又或是舉 Cycle.JS 使用的 Superagent Library 為例,其會在 Callback Function 直接幫你分類好:

request
.get('/api/todos')
.end((error, res) => {
...
});

讓我們先從幾個不同的方法來看,React 在 HTTP Error Handle 怎麼實作的:

A:React

單純就 React Component 實作上,會於 componentDidMount 發送 HTTP 請求。這種方式能很簡單地把 Catch 到 Error Message 存放在 React State,再看 UI 設計上要怎麼予以呈現:

componentDidMount() {
fetch('/api/todos')
.then(handleError)
.then(res => this.setState({ value: res.value }))
.catch(error => this.setState({ error }));
}

B:Redux

一旦專案開始複雜,就會採取 Redux 來幫你做狀態管理。大部分主流的套件都偏好把 Side Effects 往外搬,也就是盡量留下 Stateless React Component 元件,好處在於測試上比較簡便。例如會將上個段落 A 呼叫的 Fetch 抽離至 Action,再以 Thunk Pattern 來發送 Async Action,通常返回的即為 Fetch Promise。同樣地,你可以繼續做後續的 Catch 處理:

componentDidMount() {
dispatch(actions.fetchTodos())
.catch(error => ...);
}

如果 Error Message 跟當前頁面呈現較無關,或是想透過更上層的 UI 來統一顯示 Error,例如可以用 Toast 或 Notification 來提醒使用者。這時連同 Catch 一併移置 Action 也是個不錯的選擇,另一方面也能簡單地 Dispatch 其它 Action 來管理 Error State:

// container.js
componentDidMount() {
dispatch(actions.fetchTodos());
}
// actions.js
export const fetchTodos = () => dispatch =>
fetch('/api/todos')
.then(handleError)
.then(res => dispatch(setTodos(res.value)))
.catch(error => dispatch(setErrors(error)));

以上 A、B 兩種方法都很不錯,但是常常會忘記在哪已經設定好過 Error Handle,有時候甚至是找不到 Catch 寫在哪或是重複了。

C:Reactive way with Cycle

Rx 的 Observable (Stream) 即為具備時間軸概念的資料流。

最近半年在開源專案上嘗試使用了 Redux-Cycles 來處理 Action/Data Flow,其採用 Cycle.JS 的理念並且基於 RxJS 5 來進行開發。

Cycle Function

Redux-Cycles 透過 Redux Middlewares 的機制,將 Driver(Side Effects 執行小幫手)的概念引入 Redux 之中。我通常會把 Cycle 當作一個 Request/Response 的單位,以 Todo List GET API 為例使用起來像是:

function cycle(sources) {
const request$ = sources.ACTION
.filter(action => action.type === 'FETCH_TODO')
.mapTo({
url: '/api/todos',
method: 'GET',
category: 'FETCH_TODO',
});
const action$ = sources.HTTP
.select('FETCH_TODO')
.switchMap(success)
.pluck('body', 'results')
.map(setTodos);
return {
ACTION: action$,
HTTP: request$,
};
}

每當 React Container Dispatch Actions 時,都會進到 Cycle 層。Cycle Function 將會訂閱其關注的 Action Type 項目,而做出相對應的反應,分為兩部分來看:

  1. request$:處理 HTTP Side Effects。可以看到最後送出的是一個 Pure Object,事實上在 Redux Middleware 才會透過封裝的 HTTPDriver 幫你把真正的 HTTP 請求送出。
  2. action$:處理 Action Side Effects。當 HTTPDriver 的請求返回時,你可以任意地做後續處理。此處很像上個段落 B 的 Async Action,將 Response Data 送到另一個 Action(setTodos)去。同樣地,在 Redux Middleware 才會透過封裝的 ActionDriver 幫你把真正的 Action Dispatch 出去。後續就是進入大家熟習的 Reducer 階段。

Flatten Observable-of-Observables

因為 sources.HTTP Stream,有點類似左圖是一個 Higher-Order Observable (Observable-of-Observables),因此要採取扁平化的處理。而這邊討論的 .switchMap(success) 是示意圖中 Switch Operator 的延伸,先把原本的 Source Value 做 Map 後再壓扁,下方程式碼是等價的:

const action$ = sources.HTTP
.select('FETCH_TODO')
.switchMap(success)
const action$ = sources.HTTP
.select('FETCH_TODO')
.map(success)
.switch()

Success Stream

而關鍵的 success 是一個 Project Function:將每一個 Response Stream 做 Catch 的動作,並且當發生時轉換為 Empty Stream,字面上的意義就是:『僅給我 Status Code 200 的 Response 就好,錯誤時也不需通知我。』

const success = res$ => res$.catch(() => Observable.empty());
當第三個 Throw Error,若沒 Catch 處理第四個會看不到 (Link)

如上圖所示,四個 Value 間隔 200ms 依序產生,模擬第三個點發生問題。每當 Operator 進入內部 Try-Catch 機制,就會立即反應在 Output Observable 上,若可以將每一個 Inner Observable 做 Catch 轉換處理,就不怕後續的 Value 因 Unsubscribe 而停止。

Failure Stream

既然都可以忽略任何 Error 了,那我可以再做另一個 Cycle Function 來接應所有 HTTP Request 的 Error:

function errorCycle(sources) {
const failure$ = sources.HTTP
.select()
.switchMap(failure);
const action$ = failure$...; return {
ACTION: action$,
};
}

failure Project Function,把所有的 Successful Response Value 都忽略,若收到的 Error Response 則轉成一般的 Observable Value 來處理,字面上的意義是:『忽略任何 Value,我只負責 Error。』下圖是同樣的範例,但這次我只要搜集第三個 Error 值。:

const failure = res$ => 
res$
.skip()
.catch(error => Observable.of(error));
只收集 Error Value (Link)

如此一來,所有 Error 都能在同一處進行管理囉,特別適合需要統一處理 General Error 的案例。當然考慮到多國語言的規劃,又或是其他流程設計,例如 Handle 不同的 Status Code,HTTP 500 Internal Error 進行 Retry 機制、HTTP 401 Unauthorized 登出的流程等等,就會變得稍微複雜。

Centralized Handle General 400 Status Code

後記

我覺得 Error Handle 一直是我用 Rx 開發上的盲點,因為每當處理複雜的 Observable 接到 Error 時會進到 Catch 然後就終止,偶爾碰到會覺得很困擾,怎麼突然收不到新值了,特別是在 Higher-Order Observable 不同層的 Catch 也會影響性能問題,似乎未來 RxJS 5+會針對這點進行改善

Further Readings

  1. Refactoring React Component in Reactive Way
  2. Build A Web App in MediaTek

Reference

  1. Cycle.js.org HTTP Driver API
  2. Cycle-async-driver
  3. RxJS 5+ and Beyond Talk by Ben Lesh at Contributor Days
  4. Continue RxJS Streams When Errors Occur
  5. Egghead: Error Handling in RxJS

*完整 Source Code 放在 MCS-Lite/mcs-lite,如果你喜歡這系列文章,關於 Michael 在 OSS 的專案開發心得,別忘了可以點個 ❤️ 讓我知道喔!

--

--