本文由极客时间整理自 FreeWheel 高级软件工程师陈芸在 QCon+ 案例研习社的分享《TypeScript 在 FreeWheel 核心业务团队的项目实践(下)》。
作者|陈芸
编辑|严强
你好,我是陈芸,目前就职于 FreeWheel 核心业务团队,主要负责前端开发工作。我想和你分享一下我在改造现有 JavaScript 项目上的实践经验,手把手带你一起把现有的 JavaScript 项目 TypeScrip 化。
改造背景
先介绍一下改造背景。
TypeScript 作为 JavaScript 的类型化超集,弥补了静态、弱类型的 JavaScript 的缺陷,具有静态类型声明,可以减少不必要的类型判断和人工查看类型的成本,开发过程中进行静态类型检查和类型提示,对提高开发效率有正向作用。基于 TypeScript 的优点和我们面临的现状,FreeWheel 核心业务前端开发团队决定将前端开发语言从 JavaScript 向 TypeScript 切换。
我们改造的项目业务非常复杂,参与开发的人员非常多(代码行数 8 万多行,前端开发人员 40 多人),以及在可预见的将来,项目会有大量的功能迭代。
P.S.:在引入 TypeScript 的时候,我们使用的 TypeScript 版本是 4.2.2。
改造过程
接下来我会从以下 3 个方面来介绍我们是如何把 JavaScript 项目 TypeScrip 化的:
迁移方式探讨
类型定义公约
代码改造实操
迁移后的收获
迁移方式探讨
第一部分,我们来探讨一下迁移方式。
在决定把一个 JavaScript 项目 TypeScript 化的时候,首先我们需要去判断我们这个项目的属性以及我们本身具备的一些条件。
如图所示,第一个条件:判断是否一个稳定的组件。这个意思就是在比较久的一段时间内并不会有一些功能迭代进来。在这样的项目下,我们推荐你手动去维护一份.d.ts 的文件,可以做到只是对外提供类型,方便第三方的调用。如果不是一个稳定的组件,我们需要在将来不停地去维护它。
如果有新的功能加进来的话,那我们就来看第二个条件:是否有足够的时间窗口。如果有足够的时间的话,我们推荐使用 TypeScript 来对原来的 JavaScript 代码进行完整的重构。因为 TypeScript 的设计思维和 JavaScript 是不同的,它比较推荐接口设计先于代码实现,所以在很大程度上,原来的 JavaScript 代码是不能匹配这样的设计而写出来的。我们推荐用 TypeScript 重构,这样会比较符合。
如果我们没有足够的时间窗口该怎么办?以我们的项目为例,我们选择的方式是给模块逐个添加类型来达到进行类型约束的目的。当然给模块添加类型也是有顺序的,推荐把底层的模块先加类型,然后再是上层的模块。
类型定义公约
接下来是我们在这个项目中组内约定的类型定义公约。约定这样的类型定义公约目的有两个:第一明确迁移进度,第二更好地做类型约束。
我们约定了一些必须要遵守的公约:
第一条,定义 type 尽量不使用 any。我们都知道,在代码中大量的使用 any 其实可以明显地降低移植难度,但是我们引入 TypeScript 的初衷是为了给代码进行类型约束,那么使用了 any 之后就失去了这种类型约束的作用,所以我们这里把 eslink 中,这一条 no-explicit-any 设置成了 warn,我们并不会完全去禁止使用 any,但是不建议。
其他三条,都是规定了在代码中哪些位置是需要定义 type 的,分别是 Component 的 props、function/hook 的输入输出以及 state。
看一下这个例子,useDate 是一个 hook,它里面包含了一个 state。Header 是一个 component,它有 props。左图中,我们没有很好地在这些位置定义清楚各自的类型。而右图中,我们给 useDate 的返回值定义了类型,给 state 定义好了类型,同时给 Header 的 props 也定义好了类型。
我们知道,当我们把一段代码单独提取出来成为一个 function,或者成为一个 Component,我们默认这样的代码是会被多次调用的。那么,在这些代码被定义了类型之后,当我们去调用这样的 function 或者 component,TypeScript 会给我们带来怎么样的帮助呢?
首先,我们刚才定义了 useDate 的返回值,包含了三个值,它们的类型分别是 string,string 以及 function。那么我们在调用这个 hook 的时候,如果想要拿它的第四个返回值,这时编译器就会告诉我们这个值已经超出了它的返回值的范围,是有问题的,不可以这样使用。
我们刚才定义的 hook 第三个返回值其实是一个 function,它包含了两个 string 类型的参数,它的返回值是 void。
在我们调用这个 function 时,如果我们给它的第二个参数传入的值是 number 类型,编译器就会告诉我们这里的 number 类型并不被允许,我们只能传入一个 string 类型的值。这就可以帮助我们提前发现这里传入参数是否正确。
然后是 Component,我们刚才定义了它的 props 的类型,它只包含了一个属性 getData。我们在调用 Component 的时候,如果给它传入别的参数,编译器同样会告诉我们这个属性并不被 Component 所接收。
完成了以上这几处类型的定义,许多代码中别的类型都可以通过类型推导来得到。这也就是用最小的工作量发挥 TypeScript 最大的好处的情况。所以我们约定了完成以上这几处类型的定义,就算完成本阶段的代码迁移。
接下来介绍一些我们推荐遵守的公约,这些公约主要是为了帮助我们能够更好地实现类型约束。
第一条,尽量避免类型重复定义。
看这个例子,PowerUser 它的一些属性其实是 User 和 Admin 的一个合集,同时它有特殊的 type。我们如果把左图中的写法,改成右图中这样,去掉 User 中 type 属性,去掉 Admin 中 type 属性,同时和它自定义的 type 组成它自己的类型,这样其实更符合代码逻辑。
还有这样的一种情况,上面的写法其实是一种函数重载,这里的 diff() 支持输入一个参数,支持输入两个参数,也支持输入三个参数。我们推荐把这样的情况写成下面这样的类型定义:第一个参数是 require 的,后面两个参数都可以设置成 optional 的。
这样的例子在代码中很常见,比如这里的 ignoreErrors,shouldAutoReload,isEditIO 这三个参数在 function 内部都是有默认值的。也就是说,我们在调用这种方法的时候并不是必须去传入这样的参数的。对于这种有默认值的属性,在定义 type 时,我们推荐直接将它们设置成 optional。为什么要这样做?首先它可以使我们的代码更加简洁清晰。其次,由于以上写法更符合代码逻辑,所以也就决定了它维护起来更加方便。
第二条,推荐使用 keyof 对 object 中的 key 进行约束。
首先看上面这个代码段,这个写法会存在两个比较大的问题:第一,我们无法确定返回值类型;第二,我们的参数把它定义为了 string,但是很可能会出现拼写错误,而使这个 function 发生运行时的错误。
我们推荐下面这种写法,使用泛型 T 配合 keyof 来对这个 object 进行约束,同时也对它的 key 进行约束,就可以很好的解决这两个问题。
来看一下项目实例,这个函数它主要负责处理 URL 相关的一些功能。它从 URL 的 search 参数中得到一些键值对,并且把这些键值对转化成一个 object 类型然后输出。这里的 currentQuery 就是 object 类型。
其次,它还提供了一个 deleteQueries 的 function。这个 function 支持输入参数 keys,然后去除 search 中的一些键值对,并且把新得到的 search 反向 push 回 URL。
我们在代码中使用了泛型 T 对它的 object 进行约束,可以看到这里的第一个返回值就是泛型 T。
然后我们使用 keyof T 对它的参数 filters 以及 useCallback 的参数 keys 进行了约束。filters 的作用是可以指定返回的 object 中包含哪些 key,而 keys 是指定了在 deleteQueries 中需要具体去删除哪些 key。
看一下实际的调用情况。在我们调用这个 hook 的时候传入了一个指定类型 URLQueryForCampaignPage。这个类型包含了四个属性,分别是 insertion_order_id、placement_id、pagelink_id 以及 step。当我们传入这样类型的时,就决定了我们得到的第一个返回值 Query 的类型被约束为只能包含这四个 key。那么当我们想去拿 Query 中的某一个属性,比如要拿 test 属性时,编译器会直接告诉我们这个属性并不包含在这个 object 中。
定义它的第二个返回值,deleteQueries 这个 Callback,它的参数用 keyof T 约束了必须是这个 object 中的这些属性所组成的 list。这时如果我们手动输入某一个值,比如说 test 或一种拼写错误,编译器也会直接告诉我们这个 test 不在 object 中,从而提前发现了 bug。
第三条,推荐用 tuple 来代替 array 来约束数组长度。
看这两个代码段,上面的写法是用 array 来定义数组类型,下面的写法是用 tuple 来定义数组类型。这两种写法都是可以的,但如果明确知道数组的长度,我们更推荐使用 tuple,为什么呢?
看这个代码实例。这个例子的 useMemo(),返回了两个 number 类型的值组成的 list。如果我们用 array 来定义数组类型,要拿第三个返回值的时候,编译器并不能检查出错误,可能会导致运行时的 bug。只有我们把它定义成了下面这样的情况,编译器才能提前知道第三个属性已经超出了返回值的范围,提前发现错误。
最后一条,id 尽量不要模糊定义它的类型(string | number),推荐把 id 明确定义为 string 或者 number。这是我们在项目中实际踩过的坑,如果可以,我们希望能在项目初期就规避这样的问题。
比如这个 Message,我们的 id 可以明确成 string 话,千万不要定义为 string | number 这样的类型。为什么这么说?我们知道,后端返回给前端的数据可能来自不同的 service, 他们的 id 往往无法保证统一,有些接口的 id 是 number, 有些接口的 id 是 string。而 id 通常会作为唯一标识做匹配或者构建新的对象, 这就会给代码造成额外的处理负担,也增加了出错率。
比如这个实例,这里的 BFInfo 的 ioId 实际是 string 类型的,我们把它定义成了 number | string。这时 IOInfo 的 id 是 number 类型的。当我们要从 BFInfo 这个 list 中去匹配某一项 IOInfo 时,我们会写如下语句。这段代码在类型检查的时候是会成功通过的,但事实上这里隐藏着一个 bug。
可以看到,如果我们把刚才的 BFInfo 的 ioId 定义成了 string,那编译器会直接告诉我们一个 number 类型的值。如果想跟一个 string 类型的值作比较,它的返回值永远是 false,就说明这个语句是没有意义的。只有我们把它们的类型明确了,我们才能提前发现这里有问题,才可以相应地去做代码修改,比如把这两个值统一转成 number 类型或者 string 类型后再去作比较。
代码改造样例
讲了上面两部分之后,我们用具体实例来实际演示一下代码改造的过程。
- 2.0x
- 1.5x
- 1.25x
- 1.0x
- 0.75x
- 0.5x
迁移后的收获
最后总结一下我们在把 JavaScript 项目 TypeScrip 化的过程中遇到的问题和获得的收获。
第一个难点,在迁移阶段,整个项目处于 JavaScript 和 TypeScrip 并存的状态,项目的配置方案也因此变得比较复杂。
左侧是我们的 webpack 配置,我们针对 JavaScript 和 TypeScrip 引入了不同的加载器。而右侧是 ESlint,我们针对 JavaScript 和 TypeScrip 又使用了不同的 rule 配置。
第二个难点是我们原先的代码设计很多时候并不利于类型化。
看这个例子,由于原来的代码设计,function 的某个参数 data 分别可以是三种完全不同的数据结构,而代码中通过 if else 去对不同的数据进行处理。像这样的代码其实是非常不利于进行类型约束的。推荐的做法是把这样的方法进行重构,但是由于我们在项目迁移的过程中仍然有大量的功能迭代进来,为了尽量地避免冲突以及把风险降到最低,我们选择了这样的折中的方式来进行约束。
有遇到问题,但收获也是非常大的。TypeScrip 化给我们带来的第一份惊喜是让我们发现了原先代码中隐藏的 bug。
第一个 bug 是我们在调用这个 function 给它传参的时候,把这里的 key 和 value 直接给写反了。
第二个 bug 隐藏得比较深。component 有一个参数 value,value 值的属性是 number 或者 string 的一个 list。而我们在 component 中定义了 state,这个 state 的类型是这里的 TreeOptionItem 所组成的 list。可以知道 string or number 和 TreeOptionItem 是两个完全不同的类型,我们在写代码的时候,把 value 作为 state 的默认值这样的写法肯定是有问题的。这个在类型检查的时编译器直接告诉了我们。
第三个例子也非常常见。我们在代码中做一次 rename 之后可能忘记了去修改,这个文件中也引用到了这个参数,就导致这里的参数已经找不到了。像这样的问题,tsc 也很容易就告诉了我们。
总结
最后,我们建议:对于多人参与开发的大中型项目,引入 TypeScript 将非常有利于后期的代码维护。对于个人的小型项目,引入 TypeScript 的必要性并没有那么强,但如果个人感兴趣的话,推荐你依据个人喜好而定。比如你想要去体会 TypeScript 带来的好处的话,小型项目也可以明显感觉到。
送给你一句话,听得再多东西都是别人的,亲手试一试才能成为自己的。
作者介绍
陈芸 FreeWheel 核心业务团队高级软件工程师
就职于 FreeWheel 核心业务团队,主要负责前端开发工作,对前端前沿技术非常热衷,致力于提升产品质量,优化用户体验。前豆瓣全栈开发工程师,对 ToB,ToC 的项目都有深刻的理解。
评论