美文网首页简书技术团队
在 redux-saga 中减少样板代码的编写

在 redux-saga 中减少样板代码的编写

作者: z4d | 来源:发表于2019-06-15 18:16 被阅读77次

    前言

    在使用 redux-saga 的过程中,不可避免的会产生很多的样板代码,如官方初级教程所示:

    import { delay } from 'redux-saga'
    import { put, takeEvery, all } from 'redux-saga/effects'
    
    function* increaseAsync() {
      yield delay(1000)
      yield put({ type: 'INCREASE' })
    }
    
    function* decreaseAsync() {
      yield delay(1000)
      yield put({ type: 'DECREASE' })
    }
    
    function* watchIncreaseAsync() {
      yield takeEvery('INCREASE_ASYNC', increaseAsync)
    }
    
    function* watchDecreaseAsync() {
      yield takeEvery('DECREASE_ASYNC', decreaseAsync)
    }
    
    export default function* rootSaga() {
      yield all([
        watchIncrementAsync(),
        watchDecreaseAsync(),
      ])
    }
    

    可以看到,我们不光要写包含复杂逻辑的 saga,还需要为每一个 saga 写一个 watch 函数,用于监听 action 以触发对应的 saga,那么当项目规模不断变大时,意味着项目内会有非常多的 saga 以及与其对应的 watch 函数,在最终的 rootSaga 内,也会包含很多类似的样板代码,为了解决该问题,本文将会介绍在 redux-saga 中,如何减少样板代码的编写。

    正文

    一般情况下,React 项目中的 store 目录结构如下:

    ├── store
    │   ├── actions
    │   ├── sagas
    │   ├── reducers
    

    意味着 saga 中的逻辑和 reducer 中的逻辑是被分散在两个文件中的,但由于大部分情况下,saga 与其所触发的 reducer 操作都是在 state 树的同一个子树下进行的,那么完全可以将 saga 和 reducer 都放在同一个文件内编写,受 vuex 的启发,我们可以将每一个子树都看做成一个 model,并规定其数据结构如下:

    interface Model {
      namespace: string; // 该model在state树中的key
      state: Immutable.Collection; // 该model在state树中的value
      sagas: {
        // GeneratorFunction
        *[actionType](action): void {},
      };
      reducers: {
        [actionType]: (state: State, action): State => {},
      };
      subs: Model[]; // 子model
    }
    

    这样就可以在每一个 model 内直接编写 saga 与 reducer 了,而我们要做的工作,就是将多个 model 整合起来,生成 rootSaga 和 rootReducer,可以看到每一个model 内的 state 都是一个 Immutable 对象,这是为了更方便操作 state、降低操作 state 的潜在风险、减少优化组件性能时所带来的额外开销。

    1. 整合 reducer

    由于在 model 内定义的 reducers 是一个对象,因此需要将 model 内的 reducers 对象转化为标准的 reducer,代码如下:

    /**
     * 通过model的reducers:Object 得到redux需要的reducers:(state,action)=>state
     * @param model {Object} Model对象
     * @param parentNamespace {String[]}
     */
    const getReducer = (model, parentNamespace = []) => {
      const { reducers = {}, state: initialState } = model;
      const keys = Reflect.ownKeys(reducers);
      return (state = initialState, action) => {
        for (let i = 0; i < keys.length; i++) {
          const reducerName = keys[i];
          // 命中action
          if (action.type === reducerName) {
            /**
             * 如果自身是子model,需要在更深层级的path上进行set操作
             * parentNamespace之所以要slice(1)而不是直接拿来用,是因为第一级在rootReducer上,不用手动set
             */
            if (parentNamespace.length > 0) {
              const path = [...parentNamespace.slice(1), model.namespace];
              return state.updateIn(path, prevState => reducers[reducerName](prevState, action));
            }
            return reducers[reducerName](state, action);
          }
        }
        return state;
      };
    };
    

    这样就完成了将单个 model 组合成 reducer 的工作,将 model 内的 reducers 对象,转换为形如 (state, action) => state 的标准 reducer 函数,而且不用再在 reducer 内写冗长的 switch case 代码,这里的关键在于如果是子 model,需要将子 model 对应的 state 传入 reducer,并将计算出来的子 state 重新 set 至 rootState。

    接下来需要将所有 model 整合成 redux.createStore 所需的 rootReducer,代码如下:

    import { combineReducers } from 'redux';
    
    function reduceReducers(...reducers) {
      return (previous, current) => reducers.reduce((p, r) => r(p, current), previous);
    }
    
    /**
     * 组合model的state,如果含有子model,则将子model的状态merge到父model中
     * @param model {Model}
     * @return {Model}
     */
    const combineModel = (model) => {
      if (model.subs) {
        const { subs: subModels } = model;
        const combinedState = subModels.reduce((state, subModel) => {
          return state.set(subModel.namespace, combineModel(subModel).state);
        }, model.state);
        return Object.assign({}, model, {
          state: combinedState,
        });
      }
      return model;
    };
    
    const getReducers = (models, parentNamespace = []) => {
      const result = {};
      models.forEach((model) => {
        const combinedModel = combineModel(model);
        if (model.subs) {
          const { subs: subModels } = model;
          result[model.namespace] = reduceReducers(
            getReducer(combinedModel, parentNamespace),
            ...Object.values(getReducers(subModels, [...parentNamespace, model.namespace])),
          );
        } else {
          result[model.namespace] = getReducer(combinedModel, parentNamespace);
        }
      });
      return result;
    };
    
    // 得到最终的rootReducer
    export const rootReducer = combineReducers(getReducers([model1, model2, ...]));
    
    

    这样我们就将一个个的 model,转换成了最终的 rootReducer,这里需要注意子 model 的情况,combineModel 方法会将所有子 model 的 state 全部 merge 到父 model 内,从而保证 state 树的正确性。

    注意:这里用到了 ImmutableupdateInset 等方法,如果 model.state 不是 Immutable 对象,简单修改相关逻辑即可。

    2. 整合 saga

    和 reducer 的处理方法不太一样,由于 saga 内只是做一些 effects 操作,最终 put 出来一些 action,并不涉及 state 相关操作,因此对于子 model 的情况,处理起来就简单许多。

    我们还是先处理单个 model 的情况,之前提到过,样板代码有很多与 saga 对应的 watch 函数,有几个 saga,就有几个 watch 函数,这个是样板代码多的一个主要原因,因此我们主要针对这种情况进行处理。

    这里需要注意一个地方,就是我们需要指定 watch 函数的类型,即 takeLatesttakeEverythrottle,基于此,我们可以设定 model.sagas 的 value 为一个数组,其中第一项是 saga 本身,第二项是 options,包含 type 和 ms 字段。

    interface Options {
      type: 'takeEvery' | 'takeLatest' | 'throttle';
      ms?: number; // 当type为throttle时,需要指定其wait时间,单位为ms
    }
    
    interface Model {
      sagas: {
        *[actionType](action): void {},
        [actionType]: [
          function *(action): void {},
          Options,
        ],
      };
    }
    

    这样 model.sagas 就可以写成数组或者 GeneratorFunction。

    {
      ...
      sagas: {
        * aaa(action) {
          yield put({type: 'xxx'});
        }
        bbb: [
          function* (action) {
            yield put({type: 'yyy'});
          },
          { type: 'takeLatest' }
        ],
      },
      ...
    }
    

    我们可以直接将 model 内的 sagas 对象提取出来,并根据上述规则自动生成 watch 函数,代码如下:

    // 获取saga的watch函数
    const getWatcher = (actionType, _saga) => {
      let effectType = 'takeEvery';
      let saga = _saga;
      let ms = 0;
    
      if (Array.isArray(_saga)) {
        saga = _saga[0];
        const options = _saga[1];
        if (options && options.type) {
          effectType = options.type;
          if (effectType === 'throttle') {
            invariant(options.ms, 'options.ms should be defined if effect type is throttle');
            ms = options.ms;
          }
        }
        invariant(
          ['takeEvery', 'takeLatest', 'throttle'].includes(effectType),
          'effect type should be takeEvery, takeLatest, or throttle',
        );
      }
    
      switch (effectType) {
        case 'takeLatest':
          return function* () {
            yield takeLatest(actionType, saga);
          };
        case 'throttle':
          return function* () {
            yield throttle(ms, actionType, saga);
          };
        default:
          return function* () {
            yield takeEvery(actionType, saga);
          };
      }
    };
    

    这样我们就生成了 saga 对应的 watch 函数,并且默认是使用 takeEvery 进行 watch 的,接下来直接获取 rootSaga 即可,代码如下:

    // 获取单个model的saga数组
    const getSaga = (sagas = {}) => {
      return Reflect.ownKeys(sagas).map((actionType) => {
        const saga = sagas[actionType];
        const watcher = getWatcher(actionType, saga);
        return watcher();
      });
    };
    
    // 获取所有model的saga
    const getSagas = (models) => {
      return models
        .map((model) => {
          if (model.subs) {
            const { subs: subModels } = model;
            return [...getSaga(model.sagas), ...getSagas(subModels)];
          }
          return getSaga(model.sagas);
        })
        .reduce((result, curr) => result.concat(curr), []); // 合并多个saga数组
    };
    
    // 最终的rootSaga函数
    export function* rootSaga() {
      yield all(getSagas([model1, model2, ...]));
    }
    

    做完这一步工作,我们就成功完成了 model => {rootReducer, rootSaga} 这个转换过程,得到了 rootReducer 和 rootSaga 以后,就是正常的 redux 工作流了。

    3. 改进

    redux 始终有一个痛点,那就是在组件内部 dispatch 一个 async action 后,无法得知后续的过程是成功还是失败,这样无法在视图层做一些特殊操作。

    为了解决这个问题,我们可以写一个 redux middleware,从而使 dispatch 返回 Promise,并在每个 saga 执行完以后,分别调用 resolve 和 reject 即可。

    需要在创建 store 时,指定相关的 middleware,这里需要注意的是,promiseMiddleware 一定要放在 sagaMiddleware 之前

    import { createStore, applyMiddleware, compose } from 'redux';
    import createSagaMiddleware from 'redux-saga';
    
    const promiseMiddleware = () => next => (action) => {
      return new Promise((resolve, reject) => {
        next({
          ...action,
          __resolve__: resolve,
          __reject__: reject,
        });
      });
    };
    
    const sagaMiddleware = createSagaMiddleware();
    const store = createStore(
      rootReducer,
      compose(
        applyMiddleware(promiseMiddleware, sagaMiddleware),
      ),
    );
    sagaMiddleware.run(rootSaga);
    

    之后在执行完每个 saga 后,调用 __resolve__ 和 __reject__ 即可,修改一下上文提及的 getWatcher 方法:

    const getWatcher = (actionType, _saga) => {
      ...
      const sagaWithPromise = function* sagaWithPromise(action) {
        // todo: 到时候可以给action增加一个字段,可以在错误时不弹出错误框
        const { __resolve__ = noop,  __reject__ = noop } = action;
        try {
          // 直接yield原来的saga即可
          yield saga(action);
          __resolve__();
        } catch (e) {
          __reject__(e);
        }
      };
    
      switch (effectType) {
        case 'takeLatest':
          return function* () {
            yield takeLatest(actionType, sagaWithPromise);
          };
        case 'throttle':
          return function* () {
            yield throttle(ms, actionType, sagaWithPromise);
          };
        default:
          return function* () {
            yield takeEvery(actionType, sagaWithPromise);
          };
      }
    }
    

    这样就可以在组件内 dispatch action 的时候做一些特殊操作了,比如:

    store.dispatch({type: 'xxx'})
    .then(()=> {
      console.log('success');
    })
    .catch(()=>{
      console.log('fail');
    });
    

    总结

    通过本文介绍的方法,可以有效减少使用 redux-saga 时样板代码的编写,同时也带来了很多好处:

    • store 结构更加清晰,每一个 model 就是每一个不同的 state 子树,并且 model 可以任意嵌套。
    • saga 直接写 function,无需手写 watchFunction。
    • reducer 也是直接写 function 即可,不需要 if elseswitch case
    • store.dispatch 返回 Promise,从而可以在视图层进行响应。

    注意:在 saga function 内如果需要 try catch 操作,则必须要在 catch 后 throw error,否则 sagaWithPromise 方法无法 catch 到 error,反而会在执行出错时调用 __resolve__ 方法。

    相关文章

      网友评论

        本文标题:在 redux-saga 中减少样板代码的编写

        本文链接:https://www.haomeiwen.com/subject/nefiaqtx.html