简书技术团队

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

2019-06-15  本文已影响77人  z4d

前言

在使用 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 时样板代码的编写,同时也带来了很多好处:

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

上一篇下一篇

猜你喜欢

热点阅读