Redux-Saga 漫談

Redux-Saga 漫談

來自專欄 螞蟻金服體驗科技

原文有更好的閱讀體驗??:《Redux-Saga 漫談》。

新知識很多,且學且珍惜。

在選擇要系統地學習一個新的 框架/庫 之前,首先至少得學會先去思考以下兩點:

  • 它是什麼?
  • 它解決了什麼問題?

然後,才會帶著更多的好奇心去了解:它的由來、它名字的含義、它引申的一些概念,以及它具體的使用方式...

本文嘗試通過 自我學習/自我思考 的方式,談談對 redux-saga 的學習和理解。

學前指引

『Redux-Saga』是一個 庫(Library),更細緻一點地說,大部分情況下,它是以 Redux 中間件 的形式而存在,主要是為了更優雅地 管理 Redux 應用程序中的 副作用(Side Effects)。

那麼,什麼是 Side Effects?

Side Effects

來看看 Wikipedia 的專業解釋(敲黑板,劃重點):

Side effects are the most common way that a program interacts with the outside world (people, filesystems, other computers on networks).

映射在 Javascript 程序中,Side Effects 主要指的就是:非同步網路請求本地讀取 localStorage/Cookie 等外界操作:

Asynchronous things like data fetching and impure things like accessing the browser cache

雖然中文上翻譯成 「副作用」,但並不意味著不好,這完全取決於特定的 Programming Paradigm(編程範式),比如說:

Imperative programming is known for its frequent utilization of side effects.

所以,在 Web 應用,側重點在於 Side Effects 的 優雅管理(manage),而不是 消除(eliminate)

說到這裡,很多人就會有疑問:相比於 redux-thunk 或者 redux-promise, 同樣在處理 Side Effects(比如:非同步請求)的問題上,redux-saga 會有什麼優勢?

Saga vs Thunk

這裡是指 redux-saga vs redux-thunk

首先,從簡單的字面意義就能看出:背後的思想來源不同 —— Thunk vs Saga Pattern。

這裡就不展開講述了,感興趣的同學,推薦認真閱讀以下兩篇文章:

  • 《Thunk | Rethinking Asynchronous Javascript》
  • 《Saga Pattern | How to implement business transactions using Microservices》

其次,再從程序的角度來看:使用方式上的不同

Note:以下示例會省去部分 Redux 代碼,如果你對 Redux 相關知識還不太了解,那麼《Redux 卍解》了解一下。

redux-thunk

一般情況下,actions 都是符合 FSA 標準的(即:a plain javascript object),像下面這樣:

{ type: ADD_TODO, payload: { text: Do something. }};

它代表的含義是:每次執行 dispatch(action) 會通知 reducer 將 action.payload(數據) 以 action.type 的方式(操作)同步更新到 本地 store 。

而一個 豐富多變的 Web 應用,payload 數據往往來自於遠端伺服器,為了能將 非同步獲取數據 這部分代碼跟 UI 解耦,redux-thunk 選擇以 middleware 的形式來增強 redux store 的 dispatch 方法(即:支持了 dispatch(function)),從而在擁有了 非同步獲取數據能力 的同時,又可以進一步將 數據獲取相關的業務邏輯 從 View 層分離出去。

來看看以下代碼:

// action.js// ---------// actionCreator(e.g. fetchData) 返回 function// function 中包含了業務數據請求代碼邏輯// 以回調的方式,分別處理請求成功和請求失敗的情況export function fetchData(someValue) { return (dispatch, getState) => { myAjaxLib.post("/someEndpoint", { data: someValue }) .then(response => dispatch({ type: "REQUEST_SUCCEEDED", payload: response }) .catch(error => dispatch({ type: "REQUEST_FAILED", error: error }); };}// component.js// ------------// View 層 dispatch(fn) 觸發非同步請求// 這裡省略部分代碼this.props.dispatch(fetchData({ hello: saga }));

如果同樣的功能,用 redux-saga 如何實現呢?它的優勢在哪裡?

redux-saga

先來看下代碼,大致感受下(後面會細講):

// saga.js// -------// worker saga// 它是一個 generator function// fn 中同樣包含了業務數據請求代碼邏輯// 但是代碼的執行邏輯:看似同步 (synchronous-looking)function* fetchData(action) { const { payload: { someValue } } = action; try { const result = yield call(myAjaxLib.post, "/someEndpoint", { data: someValue }); yield put({ type: "REQUEST_SUCCEEDED", payload: response }); } catch (error) { yield put({ type: "REQUEST_FAILED", error: error }); }}// watcher saga// 監聽每一次 dispatch(action) // 如果 action.type === REQUEST,那麼執行 fetchDataexport function* watchFetchData() { yield takeEvery(REQUEST, fetchData);}// component.js// -------// View 層 dispatch(action) 觸發非同步請求 // 這裡的 action 依然可以是一個 plain objectthis.props.dispatch({ type: REQUEST, payload: { someValue: { hello: saga } }});

將從上面的代碼,與之前的進行對比,可以歸納以下幾點:

  • 數據獲取相關的業務邏輯 被轉移到單獨 saga.js 中,不再是摻雜在 action.js 或 component.js 中。
  • dispatch 的參數依然是一個純粹的 action (FSA),而不是充滿 「黑魔法」 thunk function。
  • 每一個 saga 都是 一個 generator function,代碼採用 同步書寫 的方式來處理 非同步邏輯(No Callback Hell),代碼變得更易讀(沒錯,這很 co~ )。
  • 同樣是受益於 generator function 的 saga 實現,代碼異常/請求失敗 都可以直接通過 try/catch 語法直接捕獲處理。

深入學習

最簡單完整的一個單向數據流,從 hello saga 說起。

先來看看,如何將 store 和 saga 關聯起來?

import { createStore, applyMiddleware } from redux;import createSagaMiddleware from redux-saga;import rootSaga from ./sagas;import rootReducer from ./reducers;// 創建 saga middlewareconst sagaMiddleware = createSagaMiddleware();// 注入 saga middlewareconst enhancer = applyMiddleware(sagaMiddleware);// 創建 storeconst store = createStore(rootReducer, /* preloadedState, */ enhancer);// 啟動 sagasagaMiddleWare.run(rootSaga);

代碼分析:

  • 8L:通過工廠函數 createSagaMiddleware 創建 sagaMiddleware(當然創建時,你也可以傳遞一些可選的配置參數)。
  • 10L~13L:注入 sagaMiddleware,並創建 store 實例,意味著:之後每次執行 store.dispatch(action),數據流都會經過 sagaMiddleware 這一道工序,進行必要的 「加工處理」(比如:發送一個非同步請求)。
  • 16L:啟動 saga,也就是執行 rootSaga,通常是程序的一些初始化操作(比如:初始化數據、註冊 action 監聽)。

整合以上分析:程序啟動時,run(rootSaga) 會開啟 sagaMiddleware 對某些 action 進行監聽,當後續程序中有觸發 dispatch(action) (比如:用戶點擊)的時候,由於數據流會經過 sagaMiddleware,所以 sagaMiddleware 能夠判斷當前 action 是否有被監聽?如果有,就會進行相應的操作(比如:發送一個非同步請求);如果沒有,則什麼都不做。

所以來看看,初始化程序時,rootSaga 具體可以做些什麼?

// sagas/index.jsimport { fork, takeEvery, put } from redux-saga/effects;import { push } from react-router-redux;import ajax from ../utils/ajax;export default function* rootSaga() { // 初始化程序(歡迎語 :-D) console.log(hello saga); // 首次判斷用戶是否登錄 yield fork(function* fetchLogin() { try { // 非同步請求用戶信息 const user = yield call(ajax.get, /userLogin); if (user) { // 將用戶信息存入 本地 store yield put({ type: UPDATE_USER, payload: user }) } else { // 路由跳轉到 403 頁面 yield put(push(/403)); } } catch (e) { // 請求異常 yield put(push(/500)); } }); // watcher saga 監聽 dispatch 傳過來的 action // 如果 action.type === FETCH_POSTS 那麼 請求帖子列表數據 yield takeEvery(FETCH_POSTS, function* fetchPosts() { // 從 store 中獲取用戶信息 const user = yield select(state => state.user); if (user) { // TODO: 獲取當前用戶發的帖子 } });}

如同前面所說,rootSaga 裡面的代碼會在程序啟動時,會依次被執行:

  • 8L:控制台同步列印出 hello saga 歡迎語。
  • 11L~21L:發起一個 非同步非阻塞數據請求(Non-Blocking),初始化用戶信息,也做了一些異常情況的容錯處理。
  • 31L~38L:takeEvery 方法會註冊一個 watcher saga,對 { type: FETCH_POSTS } 的 action 實施監聽,後續會執行與之匹配的 worker saga(比如:fetchPosts)。

PS:通常情況下,在無需進行 saga 按需載入 的情況下,rootSaga 里會集中 引入並註冊 程序中所有用到的 watcher saga(就像 combine rootReducer 那樣)。

最後再看看,程序啟動後,一個完整的單向數據流是如何形成的?

import React from react;import { connect } from react-redux;// 關聯 store 中 state.posts 欄位 (即:帖子列表數據)@connect(({ posts }) => ({ posts }))class App extends React.PureComponent { componentDidMount() { // dispatch(action) 觸發數據請求 this.props.dispatch({ type: FETCH_POSTS }); } render() { const { posts = [] } = this.props; return ( <ul> { posts.map((post, index) => (<li key={index}>{ post.title }</li>)) } </ul> ); }}export default App;

當組件 <App /> 被執行掛載後,通過 dispatch({ type: FETCH_POSTS }) 通知 sagaMiddleware 尋找到 匹配的 watcher saga 後,執行對應的 woker saga,從而發起數據非同步請求 ...... 最終 <App/> 會在得到最新 posts 數據後,執行 re-render 更新 UI。


至此,以上三個部分代碼實現了基於 redux-saga 的一次 完整單向數據流,如果用一張圖來表現的話 ,應該是這樣:

文章看到這裡,對於一個 redux-saga 新手而言,可能會留有這樣的疑惑: 上述代碼中 put/call/fork/takeEvery 這些方法是幹什麼用的?這就是接下來要詳細討論的 saga effects。

Effects

前面說到,saga 是一個 generator function,這就意味著它的執行原理必然是下面這樣:

function isPromise(value) { return value && typeof value.then === function;}const iterator = saga(/* ...args */);// 方法一:// 一步一步,手動執行let result;result = iterator.next();result = iterator.next(result.value);result = iterator.next(result.value);// ...// done!!// 方法二:// 函數封裝,自主執行function next(args) { const result = iterator.next(args); if (result.done) { // 執行結束 console.log(result.value); } else { // 根據 yielded 的值,決定什麼時候繼續執行(resume) if (isPromise(result.value)) { result.value.then(next); } else { next(result.value) } }}next();

也就是說,generator function 在未執行完前(即:result.done === false),它的控制權始終掌握在 執行者(caller)手中,即:

  • caller 決定什麼時候 恢復(resume)執行。
  • caller 決定每次 yield expression 的返回值。

而 caller 本身要實現上面上述功能需要依賴原生 API :iterator.next(value) ,value 就是 yield expression 的返回值。

舉個例子:

function* gen() { const value = yield Promise.reslove(hello saga); console.log(value: , value); // value??}

單純的看 gen 函數,沒人知道 value 的值會是多少?

這完全取決於 gen 的執行者(caller),如果使用上面的 next 方法來執行它,value 的值就是 hello saga,因為 next 方法對 expression 為 promise 時,做了特殊處理(這不就是縮小版的 co 么~ wow~⊙o⊙)。

換句話說,expression 可以是任何值,關鍵是 caller 如何來解釋 expression,並返回合理的值 !

以此結論,推理來看:

  • 大家熟知的 co 可以認為是一個 caller,它解釋的 expression 是:promise/thunk/generator function/iterator 等。
  • 這裡的 sagaMiddleware 也算是一個 caller,它主要解釋的 expression 就是 effect(當然還可以是 promise/iterator) 。

講了這麼多,那麼 effect 到底是什麼呢?先來看看官方解釋:

An effect is a plain JavaScript Object containing some instructions to be executed by the saga middleware.

意思是說:effect 本質上是一個普通對象,包含著一些指令信息,這些指令最終會被 saga middleware 解釋並執行。

用一段代碼來解釋上述這句話:

function* fetchData() { // 1. 創建 effect const effect = call(ajax.get, /userLogin); console.log(effect: , effect); // effect: // { // CALL: { // context: null, // args: [/userLogin], // fn: ajax.get, // } // } // 2. 執行 effect,即:調用 ajax.get(/userLogin) const value = yield effect; console.log(value: , value);}

可以明顯的看出:

  • call 方法用來創建 effect 對象被稱作是 effect factory
  • yield 語法將 effect 對象 傳給 sagaMiddleware,被解釋執行,並返回值。

這裡的 call effect 表示執行 ajax.get(user/Login) ,又因為它的返回值是 promise, 為了等待非同步結果返回,fetchData 函數會暫時處於 阻塞 狀態。

除了上述所說的 call effect 之外,redux-saga 還提供了很多其他 effect 類型,它們都是由對應的 effect factory 生成,在 saga 中應用於不同的場景,比較常用的是:

  • put:相當於在 saga 中調用 store.dispatch(action)。
  • take:阻塞當前 saga,直到接收到指定的 action,代碼才會繼續往下執行,有種 Event.once() 事件監聽的感覺。
  • fork: 類似於 call effect,區別在於它不會阻塞當前 saga,如同後台運行一般,它的返回值是一個 task 對象。
  • cancel:針對 fork 方法返回的 task ,可以進行取消關閉。
  • ...等等

其中,比較難以理解的就屬:如何區分 call 和 fork?什麼是阻塞/非阻塞?這是接下來要講的。

Call vs Fork

前面已經提到,saga 中 call 和 fork 都是用來執行指定函數 fn,區別在於:

  • call effect 會阻塞當前 saga 的執行,直到被調用函數 fn 返回結果,才會執行下一步代碼。
  • fork effect 則不會阻塞當前 saga,會立即返回一個 task 對象。

舉個例子,假設 fn 函數返回一個 promise:

// 模擬數據非同步獲取function fn() { return new Promise((resolve, reject) => { setTimeout(() => { resolve(hello saga); }, 2000); });}function* fetchData() { // 等待 2 秒後,列印歡迎語(阻塞) const greeting = yield call(fn); console.log(greeting: , greeting); // 立即列印 task 對象(非阻塞) const task = yield fork(fn); console.log(task: , task);}

顯然,fork 的非同步非阻塞特性更適合於在後台運行一些不影響主流程的代碼(比如:後台打點/開啟監聽),這往往是加快頁面渲染的一種方式,有點類似於 Egg 的 runInBackground,倘若在這種情況下,你依然要獲取返回結果,可以這樣做:

const task = yield fork(fn);// 0.16.0 apitask.done().then((greeting) => { console.log(greeting: , greeting);});// 1.0.0-beta.0 apitask.toPromise().then((greeting) => { console.log(greeting: , greeting);});

PS:這裡的函數 fn 是一個 normal function,其實它還可以是一個 generator function(被稱作是 Child Saga)。

最後的最後,再簡單聊聊 saga 中的錯誤處理方式?

Error Handling

在 saga 中,無論是請求失敗,還是代碼異常,均可以通過 try catch 來捕獲。

倘若訪問一個介面出現代碼異常,可能是網路請求問題,也可能是後端數據格式問題,但不管怎樣,給予日誌上報或友好的錯誤提示是不可缺少的,這也往往體現了代碼的健壯性,一般會這麼做:

function* saga() { try { const data = yield call(fetch, /someEndpoint); return data; } catch(e) { // 日誌上報 logger.error(request error: , e); // 錯誤提示 antd.message.error(請求失敗); }}

這是最正確的處理方式,但這裡更想討論的是:如果忘記寫 try catch 進行異常捕獲,結果會怎麼樣?

就好比下面這樣:

function* saga1 () { /* ... */ }function* saga2 () { throw new Error(模擬異常); }function* saga3 () { /* ... */ }function* rootSaga() { yield fork(saga1); yield fork(saga2); yield fork(saga3);}// 啟動 sagasagaMiddleware.run(rootSaga);

假設 saga2 出現代碼異常了,且沒有進行異常捕獲,這樣的異常會導致整個 Web App 崩潰么?答案是:肯定的!

來具體解釋下:

redux-saga 中執行 sagaMiddleware.run(rootsaga)fork(saga) 時,均會返回一個 task 對象(上文中說到),嵌套的 task 之間會存在 父子關係,就比如上述代碼:

  • rootSaga 生成了 rootTask。
  • saga1,saga2 和 saga3,在 rootSaga 內部執行,生成的 task,均被認為是 rootTask 的 childTask。

現在某一個 childTask 異常了(比如這裡的: saga2),那麼它的 parentTask(如:rootTask)收到通知先會執行自身的 cancel 操作,再通知其他 childTask(如:saga1,saga3) 同樣執行 cancel 操作。(這其實正是 Saga Pattern 的思想)

但這就意味著,用戶可能會因為一個按鈕點擊引發的異常,而導致整個 Web 應用的功能均無法使用!!

那麼,面對這樣的問題,如何優化呢?隔離 childTask 是首先想到的一種方案。

export default function* root() { yield spawn(saga1); yield spawn(saga2); yield spawn(saga3);}

使用 spawn 替換 fork,它們的區別在於 spawn 返回 isolate task,不存在 父子關係,也就是說,即使 saga2 掛了,rootSaga 也不受影響,saga1 和 saga3 自然更不會受影響,依然可以正常工作。

但這樣的方案並不是讓人最滿意的!如果因為某一次網路原因,導致 saga2 掛了,在不刷新頁面的情況下,用戶連重試的機會都不給,顯然是不合理的,那麼如果可以做到 saga 自動重啟呢?社區里已經有一個比較好的方案了:

function* rootSaga () { const sagas = [ saga1, saga2, saga3 ]; yield sagas.map(saga => spawn(function* () { while (true) { try { yield call(saga); } catch (e) { console.log(e); } } }) );}

上述代碼通過在最上層為每一個 childSaga 添加異常捕獲,並通過 while(true) {} 循環自動創建新的 childTask 取代 異常 childTask,以保證功能依然可用(這就類似於 Egg 中某一個 woker 進程 掛了,自動重啟一個新的 woker 進程一樣)。

OK,差不多就先講這些吧... 完!

推薦閱讀:

丁香園開源介面管理系統 - API Mocker
如何將喜歡的響應式網站變成APP – manifest icon製作教程無標題文章
前端頁面熱更新實現方案
把網頁導出為圖片的兩種方案以及其適用場景
web頁面字體

TAG:Redux | 前端開發 |