上一篇文章我們分析了 Redux-Thunk dispatch action Redux-Thunk Redux-Saga Redux-Saga Redux Redux-Saga Redux-Thunk Generator Generator Generator
的源碼,可以看到他的代碼非常簡單,隻是讓
可以處理函數類型的
,其作者也承認對于複雜場景,
并不适用,還推薦了
來處理複雜副作用。本文要講的就是
,這個也是我在實際工作中使用最多的
異步解決方案。
比
複雜得多,而且他整個異步流程都使用
來處理,
也是我們這篇文章的前置知識,如果你對
還不熟悉,可以看看這篇文章。
本文仍然是老套路,先來一個
Redux-Saga
的簡單例子,然後我們自己寫一個 Redux-Saga
來替代他,也就是源碼分析。
本文可運作的代碼已經上傳到GitHub,可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/redux-saga
簡單例子
網絡請求是我們經常需要處理的異步操作,假設我們現在的一個簡單需求就是點選一個按鈕去請求使用者的資訊,大概長這樣:
這個需求使用
Redux
實作起來也很簡單,點選按鈕的時候
dispatch
出一個
action
。這個
action
會觸發一個請求,請求傳回的資料拿來顯示在頁面上就行:
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
就介紹過的,
Redux-Saga
介入的地方是
dispatch({ type: 'FETCH_USER_INFO' })
之後。按照
Redux
一般的流程,
FETCH_USER_INFO
被發出後應該進入
reducer
處理,但是
reducer
都是同步代碼,并不适合發起網絡請求,是以我們可以使用
Redux-Saga
來捕獲
FETCH_USER_INFO
并處理。
Redux-Saga
是一個
Redux
中間件,是以我們在
createStore
的時候将它引入就行:
// 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後
// 還需要手動啟動他來運作rootSaga
sagaMiddleware.run(rootSaga);
export default store;
注意上面代碼裡的這一行:
sagaMiddleware.run(rootSaga);
sagaMiddleware.run
是用來手動啟動
rootSaga
的,我們來看看
rootSaga
是怎麼寫的:
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
函數,這裡面就一行:
yield takeEvery("FETCH_USER_INFO", fetchUserInfo);
這一行代碼用到了
Redux-Saga
的一個
effect
,也就是
takeEvery
,他的作用是監聽每個
FETCH_USER_INFO
,當
FETCH_USER_INFO
出現的時候,就調用
fetchUserInfo
函數,注意這裡是每個
FETCH_USER_INFO
。也就是說如果同時發出多個
FETCH_USER_INFO
,我們每個都會響應并發起請求。類似的還有
takeLatest
,
takeLatest
從名字都可以看出來,是響應最後一個請求,具體使用哪一個,要看具體的需求。
然後看看
fetchUserInfo
函數,這個函數也不複雜,就是調用一個
API
函數
fetchUserInfoAPI
去擷取資料,注意我們這裡函數調用并不是直接的
fetchUserInfoAPI()
,而是使用了
Redux-Saga
的
call
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;
通過這個例子的代碼結構我們可以看出:
被分為了兩種,一種是觸發異步處理的,一種是普通的同步
action
。
action
- 異步
使用
action
來監聽,監聽的時候可以使用
Redux-Saga
或者
takeLatest
來處理并發的請求。
takeEvery
- 具體的
實作可以使用
saga
提供的方法,比如
Redux-Saga
call
之類的,可以讓單元測試更好寫。
put
- 一個
可以被
action
和
Redux-Saga
同時響應,比如上面的
Reducer
發出後我還想讓頁面轉個圈,可以直接在
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就是我們今天手寫的目标:
- createSagaMiddleware:這個方法會傳回一個中間件執行個體
sagaMiddleware
- sagaMiddleware.run: 這個方法是真正運作我們寫的
的入口
saga
- takeEvery:這個方法是用來控制并發流程的
- call:用來調用其他方法
- put:發出
,用來和
action
通訊
Redux
從中間件入手
之前我們講
Redux
源碼的時候詳細分析了
Redux
中間件的原理和範式,一個中間件大概就長這個樣子:
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
中間件的範式了:
- 一個中間件接收
作為參數,會傳回一個函數
store
- 傳回的這個函數接收老的
函數作為參數(也就是上面的
dispatch
),會傳回一個新的函數
next
- 傳回的新函數就是新的
函數,這個函數裡面可以拿到外面兩層傳進來的
dispatch
和老
store
dispatch
依照這個範式以及前面對
createSagaMiddleware
的使用,我們可以先寫出這個函數的骨架:
// sagaMiddlewareFactory其實就是我們外面使用的createSagaMiddleware
function 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:
- takeEvery(action, callback):他接收的參數就是
action
,而且我們在根
callback
裡面可能會多次調用它來注冊不同
saga
的處理函數,這其實就相當于往進行中心裡面塞入事件了。
action
- put(action):
的參數是
put
,他唯一的作用就是觸發對應事件的回調運作。
action
可以看到
Redux-Saga
這種機制也是用
takeEvery
先注冊回調,然後使用
put
發出消息來觸發回調執行,這其實跟我們其他文章多次提到的釋出訂閱模式很像。
手寫channel
channel
是
Redux-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
上面去。這裡我們先實作
take
takeEvery
是在這個基礎上實作的。
Redux-Saga
中這塊代碼是單獨抽取了一個檔案,我們仿照這種做法吧。
首先需要在中間件裡面将
Redux
getState
dispatch
等參數傳遞進去,
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
的執行方式我們之前在另一篇文章詳細講過,簡單來說就是可以另外寫一個方法
next
來執行
Generator
next
裡面檢測到如果
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的處理函數,然後拿來處理目前effect
function runEffect(effect, currCb) {
if (effect && effect.IO) {
const effectRunner = effectRunnerMap[effect.type]
effectRunner(env, effect.payload, currCb);
} else {
currCb();
}
}
這點代碼可以看出,
runEffect
也隻是對
effect
進行了檢測,通過他的類型擷取對應的處理函數,然後進行處理,我這裡代碼簡化了,隻支援
IO
這種
effect
,官方源碼中還支援
promise
iterator
,具體的可以看看他的源碼:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/proc.js
effectRunner
effectRunner
是通過
effect.type
比對出來的具體的
effect
的處理函數,我們先來看兩個:
take
fork
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,但是用到了
takeEvery
takeEvery
其實是組合
take
fork
來實作的,是以我們先來看看
fork
fork
的使用跟
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
,事實上他也是調用的
Redux
dispatch
來發出
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-Saga
effect
,每次你代碼運作的時候得到的都是一個任務描述對象,這個對象是穩定的,不受運作結果影響,也就不需要針對這個造測試資料了,大大減少了工作量。
effects
對應的源碼檔案看這裡:https://github.com/redux-saga/redux-saga/blob/master/packages/core/src/internal/io.js
takeEvery
我們前面還用到了
takeEvery
來處理同時發起的多個請求,這個
API
是一個進階API,是封裝前面的
take
fork
來實作的,官方源碼又構造了一個新的疊代器來組合他們,不是很直覺。官方文檔中的這種寫法反而很好了解,我這裡采用文檔中的這種寫法:
export function takeEvery(pattern, saga) {
function* takeEveryHelper() {
while (true) {
yield take(pattern);
yield fork(saga);
}
}
return fork(takeEveryHelper);
}
上面這段代碼就很好了解了,我們一個死循環不停的監聽
pattern
,即目标事件,當目标事件過來的時候,就執行對應的
saga
,然後又進入下一次循環繼續監聽
pattern
總結
到這裡我們例子中用到的
API
已經全部自己實作了,我們可以用自己的這個
Redux-Saga
來替換官方的了,隻是我們隻實作了他的一部分功能,還有很多功能沒有實作,不過這已經不妨礙我們了解他的基本原理了。再來回顧下他的主要要點:
-
其實也是一個釋出訂閱模式,管理事件的地方是Redux-Saga
,兩個重點channel
API
take
put
-
是注冊一個事件到take
上,當事件過來時觸發回調,需要注意的是,這裡的回調僅僅是疊代器的channel
,并不是具體響應事件的函數。也就是說next
的意思就是:我在等某某事件,這個事件來之前不許往下走,來了後就可以往下走了。take
-
是發出事件,他是使用put
發出事件的,也就是說Redux dispatch
的事件會被put
Redux
同時響應。Redux-Saga
-
增強了Redux-Saga
Redux
函數,在dispatch
的同時會觸發dispatch
,也就是讓channel.put
也響應回調。Redux-Saga
- 我們調用的
和真正實作功能的函數是分開的,表層調用的effects
隻會傳回一個簡單的對象,這個對象描述了目前任務,他是穩定的,是以基于effects
的單元測試很好寫。effects
- 當拿到
傳回的對象後,我們再根據他的effects
去找對應的處理函數來進行處理。type
-
都是基于Redux-Saga
的,每往下走一步都需要手動調用Generator
,這樣當他執行到中途的時候我們可以根據情況不再繼續調用next
,這其實就相當于将目前任務next
了。cancel
參考資料
Redux-Saga
官方文檔:https://redux-saga.js.org/
Redux-Saga
源碼位址: https://github.com/redux-saga/redux-saga/tree/master/packages/core/src
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝啬你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公衆号進擊的大前端第一時間擷取高品質原創~
“前端進階知識”系列文章:https://juejin.im/post/5e3ffc85518825494e2772fd
“前端進階知識”系列文章源碼GitHub位址: https://github.com/dennis-jiang/Front-End-Knowledges