手写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源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索