Redux-Saga 漫談

blank

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

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

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

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(eg 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',那么执行fetchData export function* watchFetchData() { yield takeEvery('REQUEST', fetchData); } // component.js // ------- // View 层dispatch(action) 触发异步请求// 这里的action 依然可以是一个plain object this.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 middleware const sagaMiddleware = createSagaMiddleware(); // 注入saga middleware const enhancer = applyMiddleware(sagaMiddleware); // 创建store const store = createStore(rootReducer, /* preloadedState, */ enhancer); // 启动saga sagaMiddleWare.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.js import { 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的一次完整單向數據流,如果用一張圖來表現的話,應該是這樣:

blank

文章看到這裡,對於一個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 api task.done().then((greeting) => { console.log('greeting: ', greeting); }); // 1.0.0-beta.0 api task.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); } // 启动saga sagaMiddleware.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,差不多就先講這些吧...完!

What do you think?

Written by marketer

blank

和Houdini, CSS Paint API 打個招呼吧

blank

基於React 的高質量坦克大戰復刻版