关于javascript:手写ReduxSaga源码

7次阅读

共计 13197 个字符,预计需要花费 33 分钟才能阅读完成。

上一篇文章咱们剖析了 Redux-Thunk 的源码,能够看到他的代码非常简单,只是让 dispatch 能够处理函数类型的 action,其作者也抵赖对于简单场景,Redux-Thunk 并不实用,还举荐了 Redux-Saga 来解决简单副作用。本文要讲的就是 Redux-Saga,这个也是我在理论工作中应用最多的Redux 异步解决方案。Redux-SagaRedux-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,咱们每个都会响应并发动申请。相似的还有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:这个办法会返回一个中间件实例sagaMiddleware
  2. sagaMiddleware.run: 这个办法是真正运行咱们写的 saga 的入口
  3. takeEvery:这个办法是用来管制并发流程的
  4. call:用来调用其余办法
  5. 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 中间件的范式了:

  1. 一个中间件接管 store 作为参数,会返回一个函数
  2. 返回的这个函数接管老的 dispatch 函数作为参数(也就是下面的next),会返回一个新的函数
  3. 返回的新函数就是新的 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:

  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的执行形式咱们之前在另一篇文章具体讲过,简略来说就是能够另外写一个办法 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 的处理函数,而后拿来解决以后 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,官网源码中还反对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来实现的,官网源码又结构了一个新的迭代器来组合他们,不是很直观。官网文档中的这种写法反而很好了解,我这里采纳文档中的这种写法:

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,两个重点APItakeput
  2. 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

我也搞了个公众号[进击的大前端],不打广告,不写水文,只发高质量原创,欢送关注~

正文完
 0