迁移Dva项目到typescript有感

2021/4/5 typescriptdva

2021.9.13 更新:完整的类型代码已上传 github,见这里

最近一直在捣鼓一个事情,就是将一个基于 Dva 的 React 项目从 js 迁移到 ts,踩了很多坑,决定分享一下。

# 前置动作

问:把大象装进冰箱分几步?

最直观的想法是“渐进式迁移”,即:新代码尽量用 ts 写,老代码慢慢重构成 ts。这也是很多大规模重构首选的策略,比如国际化。但在实际执行的时候你会发现一个大问题,类型推导特别麻烦。类型推导,类型推导,它是基于已有的类型推导,如果没有类型那是推不出来的。而项目迁移刚开始的时候这部分恰恰是缺失的(比如 API 返回的数据类型、redux 全局状态中的类型),巧妇难为无米之炊。没有充分利用类型推导能力的 ts 代码,就好像这样:

ts withou type inference

因此,迁移大项目到 ts,首先要做的第一步应该是手动补全关键类型定义,这一步完成了,才可以进行后续的迁移动作。

那么什么样的类型需要补全呢?

其实这个问题的答案也不难想到,有些类型是可以通过类型推导得到的,那么这样的类型就不必手动补全,而有些类型无法通过类型推导得到,这些类型就是我们需要手动补全的类型。什么样的类型是无法通过推导得到的呢?一路顺藤摸瓜向源头找,通常你会发现这些东西来自外部 API,然后以全局状态的形式留在了 redux 这样的地方被各处引用。

答案呼之欲出了,迁移老项目,应该先从迁移全局状态开始。当你把全局状态的类型定义都补充得差不多的时候,此时就可以开始正式的 ts 迁移了。

那么接下来就来聊聊 Dva 迁移到 ts 的一些坑。

# 摸着自己过河

react-redux 官方文档中有一篇Usage with TypeScript提到了 react-redux 的 ts 用法,如果你只用到了 react-redux,那么参考这一篇就基本足够了,基本不会遇到大问题。

如果你的项目使用了 redux-saga,那么很不幸,对 generator 的处理会非常蛋疼,redux-saga 官方也没有给出推荐的做法,你只能依赖于社区经验,比如这一篇Redux-saga and Typescript, doing it right,还有这一篇How To Use Redux Saga/Typescript Like a pro!。总的来说,写起来会比较蛋疼,勉强可以接受。

而如果你的项目使用了 Dva,除了上面提到的 redux-saga 的问题之外,你还将遇到 Dva 特有的一堆问题。Dva 官网至今都没有给出 ts 的指南(实际上是给不出,后面会分析),Umi 官网倒是给了一个小例子,但那个例子可以说是“毫无诚意”,基本上就是手动添加了各种类型让 ts 编译不报错而已,几乎没有任何参考价值。不仅官网不给力,dva 社区也同样不给力,基本上找不到有用的社区实践经验,即便找到了,通常都是玩具级别的 demo,糊弄小孩呢。

没办法,那就只能自己摸索了,摸着自己过河吧。

# 社区的解决方案

给 Dva 补全类型,本质上就是给 model 添加类型,该怎么做呢?我们先看看社区的解决办法。

第一种办法,利用 dva 内置的一些通用类型:

import { EffectsCommandMap, Model } from 'dva';
import { AnyAction, Reducer } from 'redux';

type Effect = (
  action: AnyAction,
  effects: EffectsCommandMap & { select: <T>(func: (state: StateType) => T) => T },
) => void;

type State = {
  value: number;
}

interface TestModel {
  namespace: 'test';
  state: State;
  reducers: { // reducers里几乎没有类型限定
    foo: Reducer<State>;
  };
  effects: { // effects里几乎没有类型限定
    bar: Effect;
  };
}

// 最后是具体model实现
export default testModel: TestModel = {
  ...
}

这种写法的类型限定太宽松了,对 reducer 和 effect 函数几乎就没有限制,除了让 ts 类型检查不报错以外实用价值很低,跟直接用 javascript 写没什么区别,属于自欺欺人式的写法。

还有一种社区方案是这样,简单来说就是在第一种的基础上把 reducer 和 effect 函数的类型写得更详细了:

type TestState = {
  value: number;
};

type TestModel = {
  namespace: 'test';
  state: TestState;
  reducers: { // reducers里的函数起码有了参数和返回值的限定
    foo(state: TestState, action: { payload: number }): TestState;
  };
  effects: { // effects里的函数起码有了参数和返回值的限定
    bar(action: { payload: string }): void;
  };
};

// 最后是具体model实现
export default model: TestModel = {
  ...
}

这种写法,reducer 和 effect 函数内部至少能做一些类型检查了,但也就仅此而已了,在触发 reducer 和 effect 的地方仍然是没有类型检查的,下面这种错误是无法检查发现的,比如:

put({
  type: 'tset', // test拼写错误
  payload: 1,
});

put({
  type: 'test', // 忘记写payload
});

# 约定式之殇

仔细想想,reducer 或 effect 都是通过 dispatch action 触发的,因而导致上述问题的根本原因在于我们还缺失了 action 的类型。如何得到 action 的类型呢?等等!我们在 Dva 里似乎从来没有定义过 action 呀!比如下面是一个非常典型的 dva model,里面的 action 只在 reducer 和 effect 函数中作为参数出现,action 是什么压根就没有定义过。

export default {
  namespace: 'test',
  state: { value: 1 },
  reducers: {
    foo(state, action) {
      ...
    },
  },
  effects: {
    *bar(action) {
      ...
    },
  },
};

其实 action 并不是没有定义,只是这个定义 action 的过程被 Dva 给封装起来了,它会在运行时对 model 类型做处理,动态生成 action 的定义,这是一个约定的行为。这种写法也就是常说的约定式编程。

约定式编程最大好处就是写起来省事儿,可以省略很多样板代码,让开发者把精力放在重要的事情上面,开发者也很喜欢这种做法(毕竟懒是天性)。但约定式编程也有很多缺点,其中最大的缺点在于约定式写法无法获得完整的静态检查,这是因为“约定的东西”是运行时生效的,而静态检查发生在编译时,君生我未生,我生君已老。

在 javascript 时代,这个缺点还好,因为 js 代码总是充满各种运行时的 hack,但是到了 typescript 时代,这个缺点就很严重了。因为 ts 的核心价值“静态类型检查”它是静态检查,前面说过了,约定式享受不到静态类型检查的福利,而 ts 编译器又无法理解约定写法的含义,结果就是这些约定式写法在 ts 编译器眼中大概率会变成各种编译错误 😂

在 typescript 时代,约定式的写法越多,越蛋疼,这就是所谓的约定式之殇。

Dva 会很贴心地根据 model 中的 reducers 和 effects 自动生成 action。比如还是之前的例子:

export default model = {
  namespace: 'test',
  reducers: {
    foo(state, action) {
      ...
    },
  },
  effects: {
    *bar(action) {
      ...
    }
  }
}

Dva 会在运行时自动生成test/footest/bar这两个 action key。(甚至为了方便开发者,在当前 model 内部可以省略 namespace)

除了 action 外,Dva 还会帮你把所有 models 目录下的 model 文件合并到一起,组成一个 global state(在 react-redux 中,开发者必须自己写一个 combine 过程)。这也是一个约定式的写法,所以在 Dva 中,完整的 state 类型也是很难得到的。当然这是后话了,后文会再次提到这个缺陷导致的一些影响。

#