在 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([
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 树的正确性。
注意:这里用到了
Immutable
的updateIn
、set
等方法,如果model.state
不是Immutable
对象,简单修改相关逻辑即可。
2. 整合 saga
和 reducer 的处理方法不太一样,由于 saga 内只是做一些 effects 操作,最终 put 出来一些 action,并不涉及 state 相关操作,因此对于子 model 的情况,处理起来就简单许多。
我们还是先处理单个 model 的情况,之前提到过,样板代码有很多与 saga 对应的 watch 函数,有几个 saga,就有几个 watch 函数,这个是样板代码多的一个主要原因,因此我们主要针对这种情况进行处理。
这里需要注意一个地方,就是我们需要指定 watch 函数的类型,即 takeLatest
、takeEvery
和 throttle
,基于此,我们可以设定 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 else
和switch case
。 - store.dispatch 返回 Promise,从而可以在视图层进行响应。
注意:在 saga function 内如果需要 try catch 操作,则必须要在 catch 后 throw error,否则
sagaWithPromise
方法无法 catch 到 error,反而会在执行出错时调用__resolve__
方法。