文由极客时间整理自 FreeWheel 核心业务团队高级软件工程师陈芸在 QCon+ 案例研习社的演讲《TypeScript 在 FreeWheel 核心业务团队的项目实践(上)》。
作者|陈芸
编辑|贾亚宁
热衷前端技术的小伙伴都知道 TypeScript 这几年的需求呈现指数级增长的趋势,越来越多的开源项目开始使用 TypeScript 进行重构,出于对 TypeScript 究竟好不好,好在哪里的好奇,我们也对它进行了探索与尝试。
我本次的分享主要分为以下两个部分:首先探讨一下是否要引入 TypeScript,其次我们挑选了部门内比较典型的 TypeScript 项目,来带你尝试开启一个全新的 TypeScript 项目。
是否引入 TypeScript
第一部分我们先抛出一个疑问,我的项目是否应该引入 TypeScript?
想要引入一门新的语言肯定是有诉求的,如果原来的 JavaScript 对我们的开发来说非常完美,那大可不必做这样的尝试。既然 JavaScript 存在着问题,那我们就总结一下最大的痛点,看看 TypeScript 是否可以帮助来解决。
首先,找一找你平时经常遇到的前端 bug,很大一部分可能都是缺少“约束”导致的。缺少“约束”是什么意思呢?就是你制定了一个逻辑规则,但是并没有完整地描述这个规则,使得你获得的结果集合中总是会出现你期望之外的可能。我们先来看一个简单的例子:
这个 function 功能非常常见,就是从一个 list 中 filter 出某一项。看这三个结果,我期望这里的 item 包含匹配属性 id,且必须是 number 类型,这里的“必须包含以及类型限制”就是一种约束。
这个例子非常简单,但是我们在实际的项目中会有很多复杂的逻辑,经常会忘记当时写代码时思考清晰的约束逻辑,合作的小伙伴也不知道有这样的约束,就可能会触发 bug。那么这样的约束逻辑是不是可以显式地写在代码里,而不是只维护在作者的脑子里,这时我们就可以借助 TypeScript。当然有人会说我们可以借助测试来提前发现这样的 bug,但是对于大型项目而言,测试很难做到覆盖所有的逻辑。
这之后,我们还想提高开发效率。
我们在项目开发中常常会调用很多第三方的包,这些包怎么使用,我们往往需要去查看文档,还要注意版本是否一致,有时甚至需要去看源码,这是非常耗时的。不仅仅是第三方包,就是我们公司内部开发的 lib 库,在调用时也存在着同样的问题。尤其是项目团队中人员比较多的情况下,当我们需要互相调用对方开发的组件时,往往需要付出比较大的沟通成本。
这个问题,TypeScript 也可以很好地帮助到我们,尤其是它的编辑器有友好的类型提示功能,还可以自动补齐代码,在提升开发效率的同时,还可以减少引用的出错。
既然 TypeScript 可以帮我们解决这些痛点,那我们就动手实践一下,看看是不是真如外界说的那么好,同时也看看它会带来哪些问题。
开启全新的 TypeScript 项目
首先,我们遇到了一个契机,公司要开启一个新的前端项目,这个项目是把原来系统中一个高度复杂的业务模块进行改版。
这个项目的规模,大概 15 万行的代码量,前端开发团队大概十几人。项目的特点是逻辑非常复杂、上线时间紧迫、测试可覆盖的面比较小。接下来我们从以下三个方面来介绍我们是如何开启一个全新的 TypeScript 项目的:项目配置和目录设计方案,以及一些常见问题的处理方案。
首先说一下我们的项目配置方案:大家都知道 TypeScript 被诟病的一个很大的问题就是它的 compile 耗时,对于一个大型项目而言,每一次改动需要等待多长时间才能生效将严重影响到前端的开发效率,所以选择什么样的编译方式是我们面临的第一个问题。
先来看一份数据对比:
首先 ts-loader 是一个 webpack 上针对 TypeScript 的加载器,ts-loader 内部是调用 TypeScript 的官方编译器 tsc 实现的,它整个编译过程包含类型检查和语言转换,我们知道这里的类型检查是非常耗时的,常见的一种解决方式是把 option 中的 transpileOnly 设置为 true, 这样就只做语言转换而不进行类型检查,相当于只是把类型剔除掉,然后我们再通过别的辅助方式在一个单独的进程里做类型检查,可以看到 compile 的耗时减少了。
那么可不可以更快呢?大家都知道 esbuild 是一个基于 Go 的打包工具,它的运行效率是非常高的,所以我们用 esbuild-loader 替换了 ts-loader,它的运行过程也是剔除类型进行语言转化,同样我们可以用别的插件来单独解决类型检查的问题,可以看到耗时又显著减少了。
当文件越来越多,这个差距也会越来越大。
从上面这个图中可以看到,我们项目使用了 webpack5+esbuild 来进行 bundle。在另一个进程中使用插件来进行类型检查,由于是另起进程,所以它不会阻塞主进程的 bundle 过程。
对应到实际 webpack 配置文件,我们使用了 esbuild-loader 作为 TypeScript 文件的加载器,使用了插件 fork-ts-checker-webpack-plugin 来进行类型检查。其他的 webpack 配置和 JavaScript 项目是一致。
使用 fork-ts-checker-webpack-plugin 的效果如上图所示,在 Terminal 中可以查看 compile error 的详细信息,如果编译通过,则显示 no issue found。但如果只是这样其实还是不够的,因为我们完全可以忽略类型检查报的错,继续提交代码,那 TypeScript 也就没有意义了,怎么约束呢?
我们的做法是把 tsc 作为 lint 的一部分,无论是本地提交代码,还是线上打包,lint 不过时无法完成的,这就起到了强制的作用。从这个命令中我们还可以看到,除了 tsc 以外,我们还使用了 eslint 来对 TypeScript 做代码检查。
2019 年 1 月,TypeScirpt 官方决定全面采用 ESLint 作为代码检查的工具,并创建了一个新项目 typescript-eslint。
有人会觉得,JavaScript 非常灵活,所以需要代码检查。而 TypeScript 已经能够在编译阶段检查出很多问题了,为什么还需要代码检查呢?因为有许多非类型问题是 tsc 所不关注的,比如代码风格方面可以用 eslint 来约束。
上图是我们的 eslint 配置,具体每一条官方文档都有清楚的解释,这里就不逐条解说了,我就拿 no-empty-interface 为例,当我们代码里写了这样的 code:
首先是空的 interface, 我们知道在实际代码中定义一个没有任何值的空对象是没有什么意义的,所以相应地也不应该出现这样的类型定义。然后是一个 interface 继承另一个类型后不做任何扩展,这样的写法相当于这两个类型就是完全相等的,也不应该出现这样的写法。
诸如此类的问题 tsc 都是不会报错的,但这样的写法可能会给将来埋下隐患,所以我们通过 eslint 检查来规避一下。这里顺便提一下,有一些代码格式方面的约束比如缩进、空行、空格等之类的,我们并没有通过 eslint 来做,而是通过 prettier 来帮助完成。
接下来我们来介绍一下 TypeScript 项目最重要的 tsconfig 配置,下图是我们项目使用的配置方案:
这里我重点挑了几个参数:首先我们把 noEmit 设置成了 true,因为在我们项目中 tsc 只负责进行类型检查,并不真实输出 js 和.d.ts 文件。
然后是 paths, 是用来 alias(取别名)的,配合 webpack 中的 alias,我们在 import 一些包的时候会用 alias 代替相对路径,那么在 TypeScript 类型检查的时候也需要知道这些别名才能找到对应的模块,完成检查。
最后是 include 和 exclude,include 指定某些路径下的文件被包含进来,exclude 排除一些路径。“include”的默认值是当前目录及其子目录下的所有 TypeScript 文件,“exclude”默认情况下会排除 node_modules、bower_components、jspm_packages 和目录,这里的 exclude 其实我们也可以省略。
此外还有两个我们项目中没有使用,但可能大家会用到的参数:首先是 files,如果想明确指定某几个 TypeScript 文件加入到 include 中,可以用“files”这个参数来添加。需要注意的是,通过“files”属性明确指定的文件总是会被包含在内,不受 exclude 的约束。
然后是 typeRoots,它默认值是 node_module 下的 @type,以及各个子路径下的 node_modules /@types。它的作用是:我们代码中 import 的一些第三方库,这些库的类型文件有全局声明,只有把他们添加进来,全局声明才会生效。需要注意,如果自定义 typeRoots,那么默认值就失效了,不要忘记手动把 node_modules/@type 也添加进去。
现在我们项目的框架已经搭好了,接下来介绍一下项目中的文件目录结构安排,通过项目实践,我们调整出的目录结构是好维护并有利于扩展的。下图展示了项目的文件目录结构,是一种我们比较推荐的 practic。
除了最外层的项目配置文件外,首先,一个 component 下会有以 component 为前缀的四个文件,分别是 hook.ts、.css、.types.ts 以及 component 本身,类型定义相关的代码会被放到单独的.types.ts 文件中,这样做是为了使 component 仅仅只包含业务逻辑代码。
其次,有许多公共类型会被多次调用,这样的类型我们通常会放到 lib/types 下。当然,全局 declare 的类型也会放在这里。这里有一个点值得说一下,关于.d.ts 和.ts 的区别:
.d.ts 是编译器从你的.ts 代码中分离出来的非 js 的部分,类似于接口定义规范。从上图中可以看出.d.ts 是给 js 文件提供类型声明的,通常来说它是 tsc 自动生成的。
当然还有一种情况,代码不是用 TypeScript 写的,而我们希望调用方可以得到类型信息,这时我们需要手动写.d.ts 来提供一份对外的 type。比如项目中会引入许多第三方库,而这些库是基于 JavaScript 开发的,通常这些库的类型声明文件会放到 node_modules/@type 下。比如这里的 react,就是通过.d.ts 文件来提供类型声明的。
最后回到我们的文件中,由于我们项目的特性,我们并没有大量写.d.ts 文件,但由于我们会需要用到全局声明,通常我们习惯会把全局 declare 放在.d.ts 里。
然后我们说一下通常哪些类型会被当做公共数据类型放到 lib/types 下面:
首先是后端接口数据类型,看这个例子,这里定义的 ListAllChangeHistoryInfoResponse 就是后端返回的数据类型,其中每一项的类型是 ChangeHistoryInfo。
由于前端的页面展示的内容是以后端返回数据为基础的,这也决定了这样的类型会被多个上层类型定义所调用。
这里还有一个隐含的好处,我们在项目中期,引入了前后端接口同步方案,这个后面会提到,是我们自己发布了一个第三方 type 库来集中提供各种与接口相关的数据类型。那么在 adopt 的过程中,我们不需要全局逐个文件地改这个被替换的接口,只需要在 lib/type 下做一次这样的修改即可。
还有一类是公共组件或者通用方法的某些参数的类型,从右边的代码中可以看到,这里的 TreeSelect 有一个属性是 flatOptions, 它的类型就是左侧定义的 TreeOptionItem 所组成的 list,我们在项目中一个常见的场景是请求回后端数据,经过一个数据转换的函数,把数据 format 成 option 类型的数据,传给 TreeSelect 做展示。
由于这样的场景非常多见,针对于不同的后端数据,会需要不同的数据处理,所以这个 TreeOptionItem 就会被多个上层 component 所调用。因此我们也推荐放到 lib/types 下。
最后一部分是常见问题处理,我们在项目中遇到了各种问题,在这里总结两个比较典型的问题。
当我们用 ts 编译器做类型检查时,出现 compile error 很常见,通常我们也可以通过修正 type 的定义来 fix,但如果我们 import 的是一些 css、png 这样的文件该怎么办呢?
由于这些文件本身无法定义类型,最直接的想法是加上 @ts-except-error,这确实可以解决问题,但是需要注意,如果使用了 ts-expect-error,加下来的代码中没有真实的类型错误,编译器会提示:Unused ‘@ts-expect-error’ directive,而使用 ts-ignore 则无论下面的语句有没有编译错误,编译器都会忽略。
但无论是哪一条命令,这样做的缺点是每次 import 都必须加,有没有一劳永逸的方式呢?
我们推荐使用全局 declare 的方式,像一个配置文件一样,在项目初期就把这样一个文件放到 lib/types 下,那么此类问题都不会出现了。需要注意的是,全局 declare 不可以在最外层包含 import、export 这样的语句,否则它会被当做局部声明而无法全局生效。
第二个问题是引入第三方库没有 type 或者 type 定义有问题该怎么办?这里同样可以通过全局 declare 的方法解决。
同时,我们更加推荐把声明文件发布到 DefinitelyTyped 上,让更多的人可以受益。
作者介绍
陈芸 FreeWheel 核心业务团队高级软件工程师
主要负责前端开发工作,对前端前沿技术非常热衷,致力于提升产品质量,优化用户体验。前豆瓣全栈开发工程师,对 ToB,ToC 的项目都有深刻的理解。
评论