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

前言

在使用 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([
    watchIncreaseAsync(),
    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__ 方法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,039评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,223评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,916评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,009评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,030评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,011评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,934评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,754评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,202评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,433评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,590评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,321评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,917评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,568评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,738评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,583评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,482评论 2 352

推荐阅读更多精彩内容