【Rematch 源码系列】五、Rematch v2, a big step

Rematch 源码解读系列的第 5️⃣ 篇,关于 Rematch V2。

从这篇文章开始,我们来了解一下 Rematch v2。

在说 v2 之前,先给大家说说我加入 Rematch 的经历。

现在回想起来,也没想到自己会成为 Rematch 的贡献者,这也是我第一个深度参与的知名开源项目。最开始是因为公司新项目使用了 Rematch,在使用的过程中我就发现它的 TS 兼容很差,特别是 dispatch,TS 类型根本没兼容,我为此提过 issue 和对应的 PR,但是当时 Rematch 没有及时处理。我便只在内部脚手架中提了一个 PR,创建了一个文件 rematch.d.ts 用于覆盖 rematch 自身的类型定义,对大部分类型进行了修正。

大概过了一周,Rematch 的维护者邮件联系到我,想让我帮忙处理 Rematch 的类型问题,我很爽快地答应了,由此便参与到 v2 的迭代中。

Rematch v1 的最后一个版本,也是这个专栏前面文章的示例代码版本 v1.4.0 发布于 2020-02-23。在这之后,Rematch 团队便开始了 v2 的迭代(此时 Rematch 的创始人已不再接手 Rematch),并于 2020-07-30 发布了第一个预发布版本 @rematch/core@2.0.0-next.1。而我是在 2020-08-17 开始参与 Rematch(感慨一下,这篇博客写于 2021-08-17,一晃一年就过去了),并推动了 @rematch/core@2.0.0-next.2 的预发布版本在 2020-08-19 进行发布。随后经历了 10 个预发布版本,我们终于在 2021-01-31 发布了正式的 v2,历时近一年。

以上便是 Rematch v2 历史的简要回顾和我加入 Rematch 的原因。在这篇文章中,我会主要介绍 Rematch v2 升级带来的一些变化。由于我参与的大部分是 TS 的类型兼容,因此这些变化,一部分是我自己有涉及的,其他部分则是我询问了团队的一些成员,了解到了他们的一些想法。

Rematch v2 对代码和目录结构做了很大改动,并使用 lerna 进行项目管理(由于 rematch 含有一些 plugins,因此天生适合 lerna 这种 monorepo 包管理方案)。让我们先看看目录结构的变化:

 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
 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
...
blog
docs
website
packages
|—— core
|  |—— test
|  |—— src
|  |  |—— bag.ts
|  |  |—— config.ts
|  |  |—— dispatcher.ts
|  |  |—— reduxStore.ts
|  |  |—— rematchStore.ts
|  |  |—— types.ts
|  |  |—— validate.ts
|  |  |—— index.ts
|  |—— node_modules
|  |—— ...
|—— immer
|  |—— test
|  |—— src
|  |  |—— index.ts
|  |—— node_modules
|  |—— ...
|—— loading
|—— select
|—— ...

可以看到,之前的 plugins 都作为 package 进行管理,而 rematch 核心代码也作为了一个 package 叫做 core,两个核心 plugins 和 pluginFactory 被删除,相关代码被整合到 core 中,且新增 bag.tsdispatcher.ts。后面我会每个单独讲讲。

在我看来,Rematch v2 中最大的变化便是去除代码中的”类“,都改为了函数。

先看看 v1 中的 rematch class(这里我会省略大部分代码,如果想学习全部代码,可以查看专栏前面的相关文章):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * Rematch class
 *
 * an instance of Rematch generated by "init"
 */
export default class Rematch {
  protected config: R.Config;
  // ... other private configs

  constructor(config: R.Config) {}

  public init() {
    // ...

    const rematchStore = {
      name: this.config.name,
      ...redux.store,
      // ...
    };

    return rematchStore;
  }
}

调用时:

1
2
3
4
5
6
export const init = (initConfig: R.InitConfig = {}): R.RematchStore => {
  const name = initConfig.name || count.toString();
  count += 1;
  const config: R.Config = mergeConfig({ ...initConfig, name });
  return new Rematch(config).init();
};

看看代码的调用方式,其实这里使用”类“并没有凸显出哪些优势。我们再来看看 v2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
export default function createRematchStore<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
>(config: Config<TModels, TExtraModels>): RematchStore<TModels, TExtraModels> {
  // ...

  const reduxStore = createReduxStore(bag);

  let rematchStore = {
    ...reduxStore,
    name: config.name,
    // ...
  } as RematchStore<TModels, TExtraModels>;

  return rematchStore;
}

v2 中定义了一个函数,并取了一个描述性的函数名 createRematchStore,使用时也更简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Prepares a complete configuration and creates a Rematch store.
 */
export const init = <
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels> = Record<string, never>
>(
  initConfig?: InitConfig<TModels, TExtraModels>
): RematchStore<TModels, TExtraModels> => {
  const config = createConfig(initConfig || {});
  return createRematchStore(config);
};

关于这样的改动,现 Rematch 维护者 semoal 的观点和我一致,他认为 JavaScript 更适合函数式的编程,他也更喜欢这种方式。其实我之前有写过一篇文章:JavaScript 的面向对象,你真的懂了吗?是否一定需要使用”类“来编程?,在这篇文章中,我也分析过使用纯对象和”类“进行编程的差异,很多时候,我们其实无需使用”类“,而且 JavaScript 中,类的本质还是函数,还是对象。而 JavaScript 中类的继承,本质还是原型链的链式关联,感兴趣的同学可以看看。

专栏前面的文章提到过 Rematch 的插件系统,其中有 pluginFactory 以及 dispatch 和 effects 两个核心插件。然而这些代码在 v2 中都被移除了,相关逻辑被整合到了 core 中。

强烈建议读完前面的专栏文章以后再来阅读这个部分。

pluginFactory的作用我认为有两点:

  1. 支持 exposed。插件可以使用 exposed 属性,暴露的方法或对象会被绑定到 pluguinFactory 上

  2. 将所有 hook 上下文的 this 也绑定到 pluginFactory 上

rematch v1 中的两个核心 plugin,dispatch 和 effects 分别暴露了 dispatcheffects 两个对象以供其他插件在 hooks 函数中进行访问。但是在 v2 中,一切都变成了函数以简化流程,effects 放到了下面将提到的 rematch bag 配置中,而 dispatch 则是直接操作 redux.dispatch

其实说实话,关于 pluginFactory 这部分的优化,我觉得早期团队做得也不是特别好。例如之前所有 hooks 中的 this 绑定到 pluginFactory,是可以便捷访问 dispatcheffects 的,但是在 v2 中,dispatch 直接等同于 redux.dispatch,需要通过 rematchStore.dispatch 访问,而 effects 则放入了 rematch bag 中。调用不同 hooks 的时候,传参也不一致,但是我猜测可能其他插件也没有在各自的 hooks 中高频访问 dispatcheffects,所以也没人关注或提出相关缺陷。

除了上面的,还有一个很大的问题就是 v2 对 exposed 参数的误用。在 v1 中,各插件是可以通过使用 exposed 参数,来暴露一些方法或属性的,比如 dispatcheffects,但是这个暴露范围仅限插件之间(因为是挂在 pluginFactory 对象上),而 v2 则专门创建了一个 addExposed 函数用于将插件的这些方法或属性直接暴露给 rematchStore,个人认为,这是对最初设计理念的误解。正常来说,达到这样的目的应该使用 onStoreCreated 这个 hook 来实现。如果是单纯增加这个功能,也显得冗余。

下面是 addExposed 代码:

 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
/**
 * Adds properties exposed by plugins into the Rematch instance. If a exposed
 * property is a function, it passes rematch as the first argument.
 *
 * If you're implementing a plugin in TypeScript, extend Rematch namespace by
 * adding the properties that you exposed from your plugin.
 */
function addExposed<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
>(
  store: RematchStore<TModels, TExtraModels>,
  plugins: Plugin<TModels, TExtraModels>[]
): void {
  plugins.forEach((plugin) => {
    if (!plugin.exposed) return;
    const pluginKeys = Object.keys(plugin.exposed);
    pluginKeys.forEach((key) => {
      if (!plugin.exposed) return;
      const exposedItem = plugin.exposed[key] as
        | ExposedFunction<TModels, TExtraModels>
        | ObjectNotAFunction;
      const isExposedFunction = typeof exposedItem === "function";

      store[key] = isExposedFunction
        ? (...params: any[]): any =>
            (exposedItem as ExposedFunction<TModels, TExtraModels>)(
              store,
              ...params
            )
        : Object.create(plugin.exposed[key]);
    });
  });
}

dispatch 在 v1 中主要是用于增强 redux.dispatch,支持链式派发 action,例如 dispatch.modelName.reducerName。而这部分逻辑都被放到了 v2 的 dispatcher.ts 文件中。

effects plugin 做了两点:

  1. 暴露了全局 effects
  2. 使用 middleware hook 处理副作用
  3. 支持链式派发副作用 action,例如 dispatch.modelName.effectName

在 v2 中,第一点的 effects 放到了 rematch bag 中,第二点则专门创建了一个函数 createEffectsMiddleware 来处理。第三点逻辑也移到了dispatcher.ts 文件中。

v2 中增加了一个对象叫做 rematchBag,用于存储一些全局可访问的信息,例如之前 v1 rematch class 中的 forEachPlugin 方法,以及完善的 models,reduxConfig 配置,以及上面提到的 effects 插件暴露的全局 effects

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * Object for storing information needed for the Rematch store to run.
 * Purposefully hidden from the end user.
 */
export interface RematchBag<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
> {
  models: NamedModel<TModels>[];
  reduxConfig: ConfigRedux;
  forEachPlugin: <Hook extends keyof PluginHooks<TModels, TExtraModels>>(
    method: Hook,
    fn: (content: NonNullable<PluginHooks<TModels, TExtraModels>[Hook]>) => void
  ) => void;
  effects: ModelEffects<TModels>;
}

v1 的插件一共可以使用四个 hook,分别是:onInitmiddlewareonModelonStoreCreated。v2 删除了 onInit(因为 onInit 只在 pluginFactory 中使用,前面提到 pluginFactory 已经被移除),新增了 onReduceronRootReducer,同时将 middleware 更名为 createMiddleware

之前的 middleware 参数直接接收一个 redux middleware,而改为 createMiddleware 后,接收的则是一个函数,函数返回的还是 redux middleware,不过可以传递上面提到的 rematch bag 作为参数,以此方便用户对 middleware 进行定制。

v1 中使用 middleware 的是 effects, typed-state 和 subscriptions(v2 中被移除)三个插件,v2 中则仅剩 typed-state,且暂时也没有利用 rematch bag 参数。

例如 v1 和 v2 中使用对比:

v1:

1
2
3
4
5
6
const typedStatePlugin = (): Plugin => ({
  // ...
  middleware: (store) => (next) => (action) => {
    // ...
  },
});

v2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const typedStatePlugin = <
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels> = Record<string, any>
>(
  config = DEFAULT_SETTINGS
): Plugin<TModels, TExtraModels> => {
  // ...

  return {
    // ...
    createMiddleware: () => (store) => (next) => (action) => {
      // ...
    },
  };
};

onInit hook 在 pluginFactory 的 create 方法中被调用,我在 v1 代码中搜索了一下,也没看到有 plugin 使用这个 hook,因此不清楚它的具体作用。

v2 中由于 pluginFactory 的移除,因此 onInit 也没有了。

v2 在 onModel 的基础上新增了 onReducer,用于更细粒度的控制 reducer 行为。该 hook 会在生成单个 model reducer 的时候被调用,如果有返回值,则最终的 reducer 被返回值替代,否则继续使用原 reducer。

之前讲 immer 插件的时候提到过,v1 中的 immer 主要通过 redux.combineReducers 配置实现,且这个配置存在覆盖的问题。因此在 v2 中,通过更细粒度的 onReducer 来重构了 immer plugin,同理,也重构了 persist 插件。这样一来,就避免了同时使用 immer 和 persist 时 combineReducers 被覆盖的问题。

这个 hook 和 onReducer 类似,只不过是在最后生成 rootReducer 时调用。目前只有 persist 插件使用了这个 hook。

以上便是本次升级带来的主要变化,看着有点多,实际对代码运行无太大影响,而且由于采用函数式的编程方式,代码流程变得更加清晰易懂。但仍然存在一些问题,例如这次升级的人员和 Rematch v1 的开发人员不是一批人,而且作者也已经不再维护该框架,难免会出现对之前设计理念的一些误解。

除此之外,v1 代码中使用了大量的 this,而且并不遵循纯函数的编程方式,代码里各种 mutation。v2 中减少了 this 的使用,但是还是存在不少对参数的 mutation。这些都让源码变得更加复杂,流程难以梳理和理解。


接下来的最后一篇文章,我会给大家讲讲我最擅长的部分,也就是 Rematch 的类型系统。这是一个很复杂的系统,我在 v2 中几乎将它重写了一遍,极大地提高了 Rematch 的可用性。不过,也还仍然残留许多问题,有些可能是我的知识所限无法解决,还有一些也因为 TypeScript 的设计限制,我都会在接下来的文章中和大家讨论,也希望有更厉害的类型体操队员能够加入我们,持续完善 Rematch,打造最佳体验。敬请期待!

相关内容