手寫Redux-Saga源碼

上一篇文章咱們分析了Redux-Thunk的源碼[1],能夠看到他的代碼很是簡單,只是讓dispatch能夠處理函數類型的action,其做者也認可對於複雜場景,Redux-Thunk並不適用,還推薦了Redux-Saga來處理複雜反作用。本文要講的就是Redux-Saga,這個也是我在實際工做中使用最多的Redux異步解決方案。Redux-SagaRedux-Thunk複雜得多,並且他整個異步流程都使用Generator來處理,Generator也是咱們這篇文章的前置知識,若是你對Generator還不熟悉,能夠看看這篇文章[2]javascript

本文仍然是老套路,先來一個Redux-Saga的簡單例子,而後咱們本身寫一個Redux-Saga來替代他,也就是源碼分析。html

本文可運行的代碼已經上傳到GitHub,能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga前端

簡單例子

網絡請求是咱們常常須要處理的異步操做,假設咱們如今的一個簡單需求就是點擊一個按鈕去請求用戶的信息,大概長這樣:java

這個需求使用Redux實現起來也很簡單,點擊按鈕的時候dispatch出一個action。這個action會觸發一個請求,請求返回的數據拿來顯示在頁面上就行:react

import React from 'react';import { connect } from 'react-redux';
function App(props) { const { dispatch, userInfo } = props;
const getUserInfo = () => { dispatch({ type: 'FETCH_USER_INFO' }) }
return ( <div className="App"> <button onClick={getUserInfo}>Get User Info</button> <br></br> {userInfo && JSON.stringify(userInfo)} </div> );}
const matStateToProps = (state) => ({ userInfo: state.userInfo})
export default connect(matStateToProps)(App);

上面這種寫法都是咱們以前講Redux就介紹過的[3]Redux-Saga介入的地方是dispatch({ type: 'FETCH_USER_INFO' })以後。按照Redux通常的流程,FETCH_USER_INFO被髮出後應該進入reducer處理,可是reducer都是同步代碼,並不適合發起網絡請求,因此咱們可使用Redux-Saga來捕獲FETCH_USER_INFO並處理。ios

Redux-Saga是一個Redux中間件,因此咱們在createStore的時候將它引入就行:git

// store.js
import { createStore, applyMiddleware } from 'redux';import createSagaMiddleware from 'redux-saga';import reducer from './reducer';import rootSaga from './saga';
const sagaMiddleware = createSagaMiddleware()
let store = createStore(reducer, applyMiddleware(sagaMiddleware));
// 注意這裏,sagaMiddleware做爲中間件放入Redux後// 還須要手動啓動他來運行rootSagasagaMiddleware.run(rootSaga);
export default store;

注意上面代碼裏的這一行:github

sagaMiddleware.run(rootSaga);

sagaMiddleware.run是用來手動啓動rootSaga的,咱們來看看rootSaga是怎麼寫的:redux

import { call, put, takeLatest } from 'redux-saga/effects';import { fetchUserInfoAPI } from './api';
function* fetchUserInfo() { try { const user = yield call(fetchUserInfoAPI); yield put({ type: "FETCH_USER_SUCCEEDED", payload: user }); } catch (e) { yield put({ type: "FETCH_USER_FAILED", payload: e.message }); }}
function* rootSaga() { yield takeEvery("FETCH_USER_INFO", fetchUserInfo);}
export default rootSaga;

上面的代碼咱們從export開始看吧,export的東西是rootSaga這個Generator函數,這裏面就一行:axios

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

這一行代碼用到了Redux-Saga的一個effect,也就是takeEvery,他的做用是監聽每一個FETCH_USER_INFO,當FETCH_USER_INFO出現的時候,就調用fetchUserInfo函數,注意這裏是每一個FETCH_USER_INFO。也就是說若是同時發出多個FETCH_USER_INFO,咱們每一個都會響應併發起請求。相似的還有takeLatesttakeLatest從名字均可以看出來,是響應最後一個請求,具體使用哪個,要看具體的需求。

而後看看fetchUserInfo函數,這個函數也不復雜,就是調用一個API函數fetchUserInfoAPI去獲取數據,注意咱們這裏函數調用並非直接的fetchUserInfoAPI(),而是使用了Redux-Sagacall這個effect,這樣作可讓咱們寫單元測試變得更簡單,爲何會這樣,咱們後面講源碼的時候再來仔細看看。獲取數據後,咱們調用了put去發出FETCH_USER_SUCCEEDED這個action,這裏的put相似於Redux裏面的dispatch,也是用來發出action的。這樣咱們的reducer就能夠拿到FETCH_USER_SUCCEEDED進行處理了,跟之前的reducer並無太大區別。

// reducer.js
const initState = { userInfo: null, error: ''};
function reducer(state = initState, action) { switch (action.type) { case 'FETCH_USER_SUCCEEDED': return { ...state, userInfo: action.payload }; case 'FETCH_USER_FAILED': return { ...state, error: action.payload }; default: return state; }}
export default reducer;

經過這個例子的代碼結構咱們能夠看出:

1.

action被分爲了兩種,一種是觸發異步處理的,一種是普通的同步action

2.

異步action使用Redux-Saga來監聽,監聽的時候可使用takeLatest或者takeEvery來處理併發的請求。

3.

具體的saga實現可使用Redux-Saga提供的方法,好比callput之類的,可讓單元測試更好寫。

4.

一個action能夠被Redux-SagaReducer同時響應,好比上面的FETCH_USER_INFO發出後我還想讓頁面轉個圈,能夠直接在reducer裏面加一個就行:

...case 'FETCH_USER_INFO': return { ...state, isLoading: true };...


手寫源碼

經過上面這個例子,咱們能夠看出,Redux-Saga的運行是經過這一行代碼來實現的:

sagaMiddleware.run(rootSaga);

整個Redux-Saga的運行和本來的Redux並不衝突,Redux甚至都不知道他的存在,他們之間耦合很小,只在須要的時候經過put發出action來進行通信。因此我猜想,他應該是本身實現了一套徹底獨立的異步任務處理機制,下面咱們從能感知到的API入手,一步一步來探尋下他源碼的奧祕吧。本文所有代碼參照官方源碼寫成,函數名字和變量名字儘可能保持一致,寫到具體的方法的時候我也會貼出對應的代碼地址,主要代碼都在這裏:https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

先來看看咱們用到了哪些API,這些API就是咱們今天手寫的目標:

1.createSagaMiddleware:這個方法會返回一箇中間件實例sagaMiddleware2.sagaMiddleware.run: 這個方法是真正運行咱們寫的saga的入口3.takeEvery:這個方法是用來控制併發流程的4.call:用來調用其餘方法5.put:發出action,用來和Redux通信

從中間件入手

以前咱們講Redux源碼的時候詳細分析了Redux中間件的原理和範式[4],一箇中間件大概就長這個樣子:

function logger(store) { return function(next) { return function(action) { console.group(action.type); console.info('dispatching', action); let result = next(action); console.log('next state', store.getState()); console.groupEnd(); return result } }}

這其實就至關於一個Redux中間件的範式了:

1.一箇中間件接收store做爲參數,會返回一個函數2.返回的這個函數接收老的dispatch函數做爲參數(也就是上面的next),會返回一個新的函數3.返回的新函數就是新的dispatch函數,這個函數裏面能夠拿到外面兩層傳進來的store和老dispatch函數

依照這個範式以及前面對createSagaMiddleware的使用,咱們能夠先寫出這個函數的骨架:

// sagaMiddlewareFactory其實就是咱們外面使用的createSagaMiddlewarefunction sagaMiddlewareFactory() { // 返回的是一個Redux中間件 // 須要符合他的範式 const sagaMiddleware = function (store) { return function (next) { return function (action) { // 內容先寫個空的 let result = next(action); return result; } } }
// sagaMiddleware上還有個run方法 // 是用來啓動saga的 // 咱們先留空吧 sagaMiddleware.run = () => { }
return sagaMiddleware;}
export default sagaMiddlewareFactory;

梳理架構

如今咱們有了一個空的骨架,接下來該幹啥呢?前面咱們說過了,Redux-Saga極可能是本身實現了一套徹底獨立的異步事件處理機制。這種異步事件處理機制須要一個處理中心來存儲事件和處理函數,還須要一個方法來觸發隊列中的事件的執行,再回看前面的使用的API,咱們發現了兩個相似功能的API:

1.takeEvery(action, callback):他接收的參數就是actioncallback,並且咱們在根saga裏面可能會屢次調用它來註冊不一樣action的處理函數,這其實就至關於往處理中內心面塞入事件了。2.put(action)put的參數是action,他惟一的做用就是觸發對應事件的回調運行。

能夠看到Redux-Saga這種機制也是用takeEvery先註冊回調,而後使用put發出消息來觸發回調執行,這其實跟咱們其餘文章屢次提到的發佈訂閱模式很像。

手寫channel

channelRedux-Saga保存回調和觸發回調的地方,相似於發佈訂閱模式,咱們先來寫個:

export function multicastChannel() { const currentTakers = []; // 一個變量存儲咱們全部註冊的事件和回調
// 保存事件和回調的函數 // Redux-Saga裏面take接收回調cb和匹配方法matcher兩個參數 // 事實上take到的事件名稱也被封裝到了matcher裏面 function take(cb, matcher) { cb['MATCH'] = matcher; currentTakers.push(cb); }
function put(input) { const takers = currentTakers;
for (let i = 0, len = takers.length; i < len; i++) { const taker = takers[i]
// 這裏的'MATCH'是上面take塞進來的匹配方法 // 若是匹配上了就將回調拿出來執行 if (taker['MATCH'](input)) { taker(input); } } }
return { take, put }}

上述代碼中有一個奇怪的點,就是將matcher做爲屬性放到了回調函數上,這麼作的緣由我想是爲了讓外部能夠自定義匹配方法,而不是簡單的事件名稱匹配,事實上Redux-Saga自己就支持好幾種匹配模式,包括字符串,Symbol,數組等等。

內置支持的匹配方法能夠看這裏:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/matcher.js。

channel對應的源碼能夠看這裏:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/channel.js#L153

有了channel以後,咱們的中間件裏面其實只要再幹一件事情就好了,就是調用channel.put將接收的action再發給channel去執行回調就行,因此咱們加一行代碼:

// ... 省略前面代碼
const result = next(action);
channel.put(action); // 將收到的action也發給Redux-Saga
return result;
// ... 省略後面代碼

sagaMiddleware.run

前面的put是發出事件,執行回調,但是咱們的回調還沒註冊呢,那註冊回調應該在什麼地方呢?看起來只有一個地方了,那就是sagaMiddleware.run。簡單來講,sagaMiddleware.run接收一個Generator做爲參數,而後執行這個Generator,當遇到take的時候就將它註冊到channel上面去。這裏咱們先實現taketakeEvery是在這個基礎上實現的。Redux-Saga中這塊代碼是單獨抽取了一個文件,咱們仿照這種作法吧。

首先須要在中間件裏面將ReduxgetStatedispatch等參數傳遞進去,Redux-Saga使用的是bind函數,因此中間件方法改造以下:

function sagaMiddleware({ getState, dispatch }) { // 將getState, dispatch經過bind傳給runSaga boundRunSaga = runSaga.bind(null, { channel, dispatch, getState, })
return function (next) { return function (action) { const result = next(action);
channel.put(action);
return result; } }}

而後sagaMiddleware.run就直接將boundRunSaga拿來運行就好了:

sagaMiddleware.run = (...args) => { boundRunSaga(...args)}

注意這裏的...args,這個其實就是咱們傳進去的rootSaga。到這裏其實中間件部分就已經完成了,後面的代碼就是具體的執行過程了。

中間件對應的源碼能夠看這裏:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/middleware.js

runSaga

runSaga其實才是真正的sagaMiddleware.run,經過前面的分析,咱們已經知道他的做用是接收Generator並執行,若是遇到take就將它註冊到channel上去,若是遇到put就將對應的回調拿出來執行,可是Redux-Saga又將這個過程分爲了好幾層,咱們一層一層來看吧。runSaga的參數先是經過bind傳入了一些上下文相關的變量,好比getState, dispatch,而後又在運行的時候傳入了rootSaga,因此他應該是長這個樣子的:

import proc from './proc';
export function runSaga( { channel, dispatch, getState }, saga, ...args) { // saga是一個Generator,運行後獲得一個迭代器 const iterator = saga(...args);
const env = { channel, dispatch, getState, };
proc(env, iterator);}

能夠看到runSaga僅僅是將Generator運行下,獲得迭代器對象後又調用了proc來處理。

runSaga對應的源碼看這裏:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/runSaga.js

proc

proc就是具體執行這個迭代器的過程,Generator的執行方式咱們以前在另外一篇文章詳細講過[5],簡單來講就是能夠另外寫一個方法next來執行Generatornext裏面檢測到若是Generator沒有執行完,就繼續執行next,而後外層調用一下next啓動這個流程就行。

export default function proc(env, iterator) { // 調用next啓動迭代器執行 next();
// next函數也不復雜 // 就是執行iterator function next(arg, isErr) { let result; if (isErr) { result = iterator.throw(arg); } else { result = iterator.next(arg); }
// 若是他沒結束,就繼續next // digestEffect是處理當前步驟返回值的函數 // 繼續執行的next也由他來調用 if (!result.done) { digestEffect(result.value, next) } }}

digestEffect

上面若是迭代器沒有執行完,咱們會將它的值傳給digestEffect處理,那麼這裏的result.value的值是什麼的呢?回想下咱們前面rootSaga裏面的用法

yield takeEvery("FETCH_USER_INFO", fetchUserInfo);

result.value的值應該是yield後面的值,也就是takeEvery("FETCH_USER_INFO", fetchUserInfo)的返回值,takeEvery是再次包裝過的effect,他包裝了take,fork這些簡單的effect。其實對於像take這種簡單的effect來講,好比:

take("FETCH_USER_INFO", fetchUserInfo);

這行代碼的返回值直接就是一個對象,相似於這樣:

{ IO: true, type: 'TAKE', payload: {},}

因此咱們這裏digestEffect拿到的result.value也是這樣的一個對象,這個對象就表明了咱們的一個effect,因此咱們的digestEffect就長這樣:

function digestEffect(effect, cb) { // 這個cb其實就是前面傳進來的next // 這個變量是用來解決競爭問題的 let effectSettled; function currCb(res, isErr) { // 若是已經運行過了,直接return if (effectSettled) { return }
effectSettled = true;
cb(res, isErr); }
runEffect(effect, currCb); }

runEffect

能夠看到digestEffect又調用了一個函數runEffect,這個函數會處理具體的effect:

// runEffect就只是獲取對應type的處理函數,而後拿來處理當前effectfunction runEffect(effect, currCb) { if (effect && effect.IO) { const effectRunner = effectRunnerMap[effect.type] effectRunner(env, effect.payload, currCb); } else { currCb(); }}

這點代碼能夠看出,runEffect也只是對effect進行了檢測,經過他的類型獲取對應的處理函數,而後進行處理,我這裏代碼簡化了,只支持IO這種effect,官方源碼中還支持promiseiterator,具體的能夠看看他的源碼:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/proc.js

effectRunner

effectRunner是經過effect.type匹配出來的具體的effect的處理函數,咱們先來看兩個:takefork

runTakeEffect

take的處理其實很簡單,就是將它註冊到咱們的channel裏面就行,因此咱們建一個effectRunnerMap.js文件,在裏面添加take的處理函數runTakeEffect:

// effectRunnerMap.js
function runTakeEffect(env, { channel = env.channel, pattern }, cb) { const matcher = input => input.type === pattern;
// 注意channel.take的第二個參數是matcher // 咱們直接寫一個簡單的matcher,就是輸入類型必須跟pattern同樣才行 // 這裏的pattern就是咱們常常用的action名字,好比FETCH_USER_INFO // Redux-Saga不只僅支持這種字符串,還支持多種形式,也能夠自定義matcher來解析 channel.take(cb, matcher);}
const effectRunnerMap = { 'TAKE': runTakeEffect,};
export default effectRunnerMap;

注意上面代碼channel.take(cb, matcher);裏面的cb,這個cb其實就是咱們迭代器的next,也就是說take的回調是迭代器繼續執行,也就是繼續執行下面的代碼。也就是說,當你這樣寫時:

yield take("SOME_ACTION");yield fork(saga);

當運行到yield take("SOME_ACTION");這行代碼時,整個迭代器都阻塞了,不會再往下運行。除非你觸發了SOME_ACTION,這時候會把SOME_ACTION的回調拿出來執行,這個回調就是迭代器的next,因此就能夠繼續執行下面這行代碼了yield fork(saga)

runForkEffect

咱們前面的示例代碼其實沒有直接用到fork這個API,可是用到了takeEverytakeEvery實際上是組合takefork來實現的,因此咱們先來看看forkfork的使用跟call很像,也是能夠直接調用傳進來的方法,只是call會等待結果回來才進行下一步,fork不會阻塞這個過程,而是當前結果沒回來也會直接運行下一步:

fork(fn, ...args);

因此當咱們拿到fork的時候,處理起來也很簡單,直接調用proc處理fn就好了,fn應該是一個Generator函數。

function runForkEffect(env, { fn }, cb) { const taskIterator = fn(); // 運行fn獲得一個迭代器
proc(env, taskIterator); // 直接將taskIterator給proc處理
cb(); // 直接調用cb,不須要等待proc的結果}

runPutEffect

咱們前面的例子還用到了put這個effect,他就更簡單了,只是發出一個action,事實上他也是調用的Reduxdispatch來發出action

function runPutEffect(env, { action }, cb) { const result = env.dispatch(action); // 直接dispatch(action)
cb(result);}

注意咱們這裏的代碼只須要dispatch(action)就好了,不須要再手動調channel.put了,由於咱們前面的中間件裏面已經改造了dispatch方法了,每次dispatch的時候都會自動調用channel.put

runCallEffect

前面咱們發起API請求還用到了call,通常咱們使用axios這種庫返回的都是一個promise,因此咱們這裏寫一種支持promise的狀況,固然普通同步函數確定也是支持的:

function runCallEffect(env, { fn, args }, cb) { const result = fn.apply(null, args);
if (isPromise(result)) { return result .then(data => cb(data)) .catch(error => cb(error, true)); }
cb(result);}

這些effect具體處理的方法對應的源碼都在這個文件裏面:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/effectRunnerMap.js

effects

上面咱們講了幾個effect具體處理的方法,可是這些都不是對外暴露的effect API。真正對外暴露的effect API還須要單獨寫,他們其實都很簡單,都是返回一個帶有type的簡單對象就行:

const makeEffect = (type, payload) => ({ IO: true, type, payload})
export function take(pattern) { return makeEffect('TAKE', { pattern })}
export function fork(fn) { return makeEffect('FORK', { fn })}
export function call(fn, ...args) { return makeEffect('CALL', { fn, args })}
export function put(action) { return makeEffect('PUT', { action })}

能夠看到當咱們使用effect時,他的返回值就僅僅是一個描述當前任務的對象,這就讓咱們的單元測試好寫不少。由於咱們的代碼在不一樣的環境下運行可能會產生不一樣的結果,特別是這些異步請求,咱們寫單元測試時來造這些數據也會很麻煩。可是若是你使用Redux-Sagaeffect,每次你代碼運行的時候獲得的都是一個任務描述對象,這個對象是穩定的,不受運行結果影響,也就不須要針對這個造測試數據了,大大減小了工做量。

effects對應的源碼文件看這裏:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/io.js

takeEvery

咱們前面還用到了takeEvery來處理同時發起的多個請求,這個API是一個高級API,是封裝前面的takefork來實現的,官方源碼又構造了一個新的迭代器來組合他們[6],不是很直觀。官方文檔中的這種寫法反而很好理解[7],我這裏採用文檔中的這種寫法:

export function takeEvery(pattern, saga) { function* takeEveryHelper() { while (true) { yield take(pattern); yield fork(saga); } }
return fork(takeEveryHelper);}

上面這段代碼就很好理解了,咱們一個死循環不停的監聽pattern,即目標事件,當目標事件過來的時候,就執行對應的saga,而後又進入下一次循環繼續監聽pattern

總結

到這裏咱們例子中用到的API已經所有本身實現了,咱們能夠用本身的這個Redux-Saga來替換官方的了,只是咱們只實現了他的一部分功能,還有不少功能沒有實現,不過這已經不妨礙咱們理解他的基本原理了。再來回顧下他的主要要點:

1.Redux-Saga其實也是一個發佈訂閱模式,管理事件的地方是channel,兩個重點APItakeput2.take是註冊一個事件到channel上,當事件過來時觸發回調,須要注意的是,這裏的回調僅僅是迭代器的next,並非具體響應事件的函數。也就是說take的意思就是:我在等某某事件,這個事件來以前不準往下走,來了後就能夠往下走了。3.put是發出事件,他是使用Redux dispatch發出事件的,也就是說put的事件會被ReduxRedux-Saga同時響應。4.Redux-Saga加強了Reduxdispatch函數,在dispatch的同時會觸發channel.put,也就是讓Redux-Saga也響應回調。5.咱們調用的effects和真正實現功能的函數是分開的,表層調用的effects只會返回一個簡單的對象,這個對象描述了當前任務,他是穩定的,因此基於effects的單元測試很好寫。6.當拿到effects返回的對象後,咱們再根據他的type去找對應的處理函數來進行處理。7.整個Redux-Saga都是基於Generator的,每往下走一步都須要手動調用next,這樣當他執行到中途的時候咱們能夠根據狀況再也不繼續調用next,這其實就至關於將當前任務cancel了。

本文可運行的代碼已經上傳到GitHub,能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga

參考資料

Redux-Saga官方文檔:https://redux-saga.js.org/

Redux-Saga源碼地址:https://github.com/redux-saga/redux-saga/tree/master/packages/core/src

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

做者博文GitHub項目地址:https://github.com/dennis-jiang/Front-End-Knowledges

做者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

References

[1] 上一篇文章咱們分析了Redux-Thunk的源碼: https://juejin.im/post/6869950884231675912
[2] 若是你對Generator還不熟悉,能夠看看這篇文章: https://juejin.im/post/6844904133577670664
[3] 上面這種寫法都是咱們以前講Redux就介紹過的: https://juejin.im/post/6847902222756347911
[4] 以前咱們講Redux源碼的時候詳細分析了Redux中間件的原理和範式: https://juejin.im/post/6845166891682512909#heading-7
[5] 在另外一篇文章詳細講過: https://juejin.im/post/6844904133577670664
[6] 官方源碼又構造了一個新的迭代器來組合他們: https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/sagaHelpers/takeEvery.js
[7] 官方文檔中的這種寫法反而很好理解: https://redux-saga.js.org/docs/advanced/Concurrency.html


本文分享自微信公衆號 - 進擊的大前端(AdvanceOnFE)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索