如无特殊说明,本专栏文章的代码版本均为 @rematch/core: 1.4.0
上篇介绍了 rematch 的插件机制以及其核心的两个插件。除了这两个插件外,rematch 团队其实还开发了不少第三方插件,例如 immer,loading,select,persist,updated 等等。这篇文章我们选取两个使用最多的插件来介绍,它们分别是 immer 和 loading。
在讲解之前,还是先回顾一下 rematch 的代码结构和组成部分:
...
plugins
|—— ...
|—— loading
|—— immer
|—— select
src
|—— plugins
| |—— dispatch.ts
| |—— effects.ts
|—— typings
| |—— index.ts
|—— utils
| |—— deprecate.ts
| |—— isListener.ts
| |—— mergeConfig.ts
| |—— validate.ts
|—— index.ts
|—— pluginFactory.ts
|—— redux.ts
|—— rematch.ts
可以看到,上面提到的两个核心 plugin 是放在 src
目录下的,而第三方插件位于一个单独的 plugins
目录下。后面的文章会提到,在 Rematch v2 中,我们将这两个核心 plugin 集成到了 rematch/core
,所以将不再有 src/plugins
目录。
下面为 rematch 的组成部分:
immer
immer 插件的灵感来源于 immerjs,方便你在 reducer 中以 mutable 的方式生成 immutable 的数据。简而言之,如果没有 immer,我们在 reducer 中通常的做法是:
function myReducer(state, action) {
return {
...state,
slice: action.payload,
};
}
而使用 immer 后,我们可以:
function myReducer(state, action) {
state.slice = action.payload;
}
是不是简单了许多,要实现 immer plugin 比较简单,只需要给正常的 reducer 包一层,使其经过 immer 处理即可,我们来看看代码实现:
function combineReducersWithImmer(reducers: ReducersMapObject) {
const reducersWithImmer: ReducersMapObject<any, Action<any>> = {};
// reducer must return value because literal don't support immer
for (const key of Object.keys(reducers)) {
const reducerFn = reducers[key];
reducersWithImmer[key] = (state, payload) =>
typeof state === "object"
? produce(state, (draft: Models) => {
const next = reducerFn(draft, payload);
if (typeof next === "object") return next;
})
: reducerFn(state, payload);
}
return combineReducers(reducersWithImmer);
}
// rematch plugin
const immerPlugin = (): Plugin => ({
config: {
redux: {
combineReducers: combineReducersWithImmer,
},
},
});
插件导出了一个 plugin.config.redux.combineReducers
配置,这个配置会通过初始化方法中的 mergeConfig()
合入到 rematchConfig.redux.combinReducers
,然后在生成 rootReducer 的时候使用。
这个 combineReducersWithImmer
,本质上是在每一个 modelReducer 上加了一层,执行 modelReducer 时,传入的 state 其实是 immer.produce
的 draftState
,因此我们可以直接修改它。当 state 为简单数据类型时,则跳过 immer.produce
。
关于 immer.produce
和 combineReducers
的更多使用和原理请参考官方文档,这里不再赘述。
注意,关于 immer 的设计存在两个缺陷:
-
上面的
immer.produce
的第二个函数参数中,其实可以无需return next
。immer 会使用最终的draftState
构造出新的 state。如果使用return newState
,则要注意不能修改draftState
后,return 一个和draftState
无关的新对象,这样会报错。 -
上面提到插件的 redux 相关配置会被合入到 rematch 的全局配置。但是如果使用两个插件,且两个插件都导出了这个配置,则后一个插件的配置不会被应用,下面是 merge 相关的部分代码:
for (const plugin of config.plugins) {
if (plugin.config) {
// redux
if (plugin.config.redux) {
config.redux.enhancers = [
...config.redux.enhancers,
...(plugin.config.redux.enhancers || []),
];
config.redux.middlewares = [
...config.redux.middlewares,
...(plugin.config.redux.middlewares || []),
];
// ... other redux configs
config.redux.combineReducers =
config.redux.combineReducers || plugin.config.redux.combineReducers;
config.redux.createStore =
config.redux.createStore || plugin.config.redux.createStore;
}
}
}
我一直觉得这部分设计让人困惑,因为很多 redux 相关的配置,如果不能像上面的 enahancers
和 middlewares
一样作为一个数组保存下来,而是被替换的话,这意味着我们插件中定义的这些配置也许无法生效。也是这个原因,在 rematch v2 中,我们实现了一个更细粒度的 plugin hooks,叫做 onReducer
,并且使用它来重构了 immer,我会在后面的升级文章中详述。
loading
接下来看看 loading 插件,其相对于 immer 会稍微复杂些。其主要使用的 plugin hook 是 onModel
,最后导出一个 loading model,里面的 state 用于判断异步副作用(例如网络请求)是否正在执行中。
为了便于理解,我分为三个部分来讲述:首先是初始化代码,其次是 onModel 钩子,最后是两个 reducer。
初始化代码
先来看看初始化代码:
const cntState = {
global: 0,
models: {},
effects: {},
};
export default (config: LoadingConfig = {}): Plugin => {
validateConfig(config);
const loadingModelName = config.name || "loading";
const converter =
config.asNumber === true ? (cnt: number) => cnt : (cnt: number) => cnt > 0;
const loading: Model = {
name: loadingModelName,
reducers: {
hide: createLoadingAction(converter, -1),
show: createLoadingAction(converter, 1),
},
state: {
...cntState,
},
};
cntState.global = 0;
loading.state.global = converter(cntState.global);
// ... return the plugin configs
};
我们定义了初始 state 变量 cntState
,其结构包含三部分,global 用于判断全局是否有正在执行中的副作用,models 用于判断特定 model 中是否有正在执行中的副作用(例如 loading.modelA
),而 effects 则用于判断特定的副作用是否正在执行(例如 loading.modelA.effectA
)。
执行初始化时,首先会设置 loading 作为默认的 modelName,然后定义一个 converter,用于将 state 在 boolean 和 number 类型之间转换。(boolean 只能表示表示副作用是否处于执行中,而 number 可表示执行中的数量,0 则代表没有执行中的副作用。这样的设计便于我们按需配置),最后设置 cntState.global
为 0,并使用 converter 进行转换。
我们看到在定义的 loading model 中还有两个 reducer,他们俩我们会放在第三步讲解。
onModel 钩子
其次是最重要的一部分,onModel
钩子的使用:
export default (config: LoadingConfig = {}): Plugin => {
// ... 上述的初始化代码
return {
config: {
models: {
loading,
},
},
onModel({ name }: Model) {
// do not run dispatch on "loading" model
if (name === loadingModelName) {
return;
}
cntState.models[name] = 0;
loading.state.models[name] = converter(cntState.models[name]);
loading.state.effects[name] = {};
const modelActions = this.dispatch[name];
// map over effects within models
Object.keys(modelActions).forEach((action: string) => {
if (this.dispatch[name][action].isEffect !== true) {
return;
}
cntState.effects[name][action] = 0;
loading.state.effects[name][action] = converter(
cntState.effects[name][action]
);
const actionType = `${name}/${action}`;
// ignore items not in whitelist
if (config.whitelist && !config.whitelist.includes(actionType)) {
return;
}
// ignore items in blacklist
if (config.blacklist && config.blacklist.includes(actionType)) {
return;
}
// copy orig effect pointer
const origEffect = this.dispatch[name][action];
// create function with pre & post loading calls
const effectWrapper = async (...props) => {
try {
this.dispatch.loading.show({ name, action });
// waits for dispatch function to finish before calling "hide"
const effectResult = await origEffect(...props);
this.dispatch.loading.hide({ name, action });
return effectResult;
} catch (error) {
this.dispatch.loading.hide({ name, action });
throw error;
}
};
effectWrapper.isEffect = true;
// replace existing effect with new wrapper
this.dispatch[name][action] = effectWrapper;
});
},
};
};
还是分三步来看:
首先,由于 plugin 导出的 model 也会经过 onModel
钩子处理,因此先排除 loading model 自身。然后对 state.models
初始化,以及从 this.dispatch
中取得该 model 的所有 action。
注意:前面提到过,plugin hooks 函数中的上下文
this
绑定到了 pluginFactory 实例上,而dispatch
作为 dispatch 插件的导出属性,也被添加到了该 pluginFactory 实例(不记得的同学可以翻看前一篇文章)
其次,遍历所有 actions,从中只对 effect action 做处理:对 state.effects[currentModel][effectAction]
初始化,再通过黑白名单过滤掉无需处理的 effect。
最后,这也是最为重要的一步,实现对原有 effect action 的包装,包装后的 action 函数中,会在原始 effect 执行的前后,以及出错时调用 loading model 的 reducer,实现对原始 effect 执行时的状态管理。第三步让我们来分别看看这两个 reducer。
两个 reducer
loading model 实现了两个 reducer,分别是 hide
和 show
:
const createLoadingAction =
(converter, i) =>
(state, { name, action }: any) => {
cntState.global += i;
cntState.models[name] += i;
cntState.effects[name][action] += i;
return {
...state,
global: converter(cntState.global),
models: {
...state.models,
[name]: converter(cntState.models[name]),
},
effects: {
...state.effects,
[name]: {
...state.effects[name],
[action]: converter(cntState.effects[name][action]),
},
},
};
};
export default (config: LoadingConfig = {}): Plugin => {
// ...
const loading: Model = {
name: loadingModelName,
reducers: {
hide: createLoadingAction(converter, -1),
show: createLoadingAction(converter, 1),
},
state: {
...cntState,
},
};
// ... return the plugin configs
};
hide
和 show
两个 reducer 接收的 payload 均为一个对象,对象有两个属性:modelName 和 effectName。通过这两个属性,可以找到特定的 effect 并更新其执行状态。例如,对于 show
,执行中的 effect 对应的计数加 1,对于 hide
则减 1。执行中的 effect 包括全局 effect,model effect 以及特定的单个 effect。最后通过 converter 更新转换后的 state。
总结
到此为止,所有关于 rematch 的代码都已经讲解完毕。接下来还有两篇文章,第一篇我们来聊聊 rematch v1 升级到 v2 的一些设计变化,以及我们为什么这么做;第二篇我将注意力放到了 rematch v2 中 TypeScript 的支持上,这是升级到 v2 的最大使用变化,也是我在 rematch 团队的主要贡献,我会和大家聊聊我是如何打通 rematch 类型体系,以及这套体系目前残留的一些问题。敬请期待!