【Rematch 源码系列】四、Third-Party plugins

Rematch 源码解读系列的第 4️⃣ 篇,关于 Rematch 的第三方插件。

如无特殊说明,本专栏文章的代码版本均为 @rematch/core: 1.4.0

上篇介绍了 rematch 的插件机制以及其核心的两个插件。除了这两个插件外,rematch 团队其实还开发了不少第三方插件,例如 immer,loading,select,persist,updated 等等。这篇文章我们选取两个使用最多的插件来介绍,它们分别是 immer 和 loading。

在讲解之前,还是先回顾一下 rematch 的代码结构和组成部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
...
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 的组成部分:

rematch 组成部分

immer 插件的灵感来源于 immerjs,方便你在 reducer 中以 mutable 的方式生成 immutable 的数据。简而言之,如果没有 immer,我们在 reducer 中通常的做法是:

1
2
3
4
5
6
function myReducer(state, action) {
  return {
    ...state,
    slice: action.payload,
  };
}

而使用 immer 后,我们可以:

1
2
3
function myReducer(state, action) {
  state.slice = action.payload;
}

是不是简单了许多,要实现 immer plugin 比较简单,只需要给正常的 reducer 包一层,使其经过 immer 处理即可,我们来看看代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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.producedraftState,因此我们可以直接修改它。当 state 为简单数据类型时,则跳过 immer.produce

关于 immer.producecombineReducers 的更多使用和原理请参考官方文档,这里不再赘述。

注意,关于 immer 的设计存在两个缺陷:

  1. 上面的 immer.produce 的第二个函数参数中,其实可以无需 return next。immer 会使用最终的 draftState 构造出新的 state。如果使用 return newState,则要注意不能修改 draftState 后,return 一个和 draftState 无关的新对象,这样会报错

  2. 上面提到插件的 redux 相关配置会被合入到 rematch 的全局配置。但是如果使用两个插件,且两个插件都导出了这个配置,则后一个插件的配置不会被应用,下面是 merge 相关的部分代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 相关的配置,如果不能像上面的 enahancersmiddlewares 一样作为一个数组保存下来,而是被替换的话,这意味着我们插件中定义的这些配置也许无法生效。也是这个原因,在 rematch v2 中,我们实现了一个更细粒度的 plugin hooks,叫做 onReducer,并且使用它来重构了 immer,我会在后面的升级文章中详述。

接下来看看 loading 插件,其相对于 immer 会稍微复杂些。其主要使用的 plugin hook 是 onModel,最后导出一个 loading model,里面的 state 用于判断异步副作用(例如网络请求)是否正在执行中。

为了便于理解,我分为三个部分来讲述:首先是初始化代码,其次是 onModel 钩子,最后是两个 reducer。

先来看看初始化代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 钩子的使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
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。

loading model 实现了两个 reducer,分别是 hideshow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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
};

hideshow 两个 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 类型体系,以及这套体系目前残留的一些问题。敬请期待!

相关内容