作者 | 林伟轩
编辑 | 贾亚宁
本文由 InfoQ 整理自阿里巴巴淘宝前端开发工程师林伟轩在 QCon+案例研习社的演讲《淘宝店铺 TypeScript 研发规约落地》。
你好,我是来自淘宝的前端开发工程师林伟轩,目前工作于淘宝店铺团队,主要负责面向商家的旺铺装修部分。今天要和你分享的内容是我在淘宝店铺迁移到 TypeScript,以及落地相关研发规约的经验。
在开始前,我们先做一个简单的铺垫。淘宝旺铺是店铺业务中面向商家的一站式店铺管理后台,最重要的部分之一就是旺铺装修页面。商家在这里使用官方模块、外部 ISV(也就是外部开发者开发的三方模块),按照自己的诉求搭建出最后消费者在手淘打开时看到的店铺首页。
淘宝旺铺后台页
旺铺装修页面
旺铺装修这一部分的代码量大概在 2w 余行,原本完全由 JavaScript 开发,在从之前的维护者接手项目开始需求开发以后,我们很快发现,JavaScript 难以支持页面数据字段格外繁多且灵活的旺铺装修项目,随着需求的迭代,它将慢慢得演变为一个庞大而笨拙的项目。
同时,在后续由于代码问题出现的一次线上故障也让我们开始思考,如何提升这一核心项目的稳定性。所以,我们将目光投向了 TypeScript,我们认为,在这种数据类型庞杂且又对页面逻辑的稳定性有较高要求的项目,TypeScript 必然会是一味良药。
淘宝店铺前端侧目前有数百个模块与项目,代码量在二十万行左右,在迁移过程中,我们选择了旺铺装修作为试点项目,目前已经完成了基本的迁移工作,我会在后面给出具体的迁移成本与收获。
我分享的内容首先是研发侧规范,在这部分我们关注如何写出更规范更易维护的 TypeScript 代码,这是项目参与开发维护者需要重点关注的;而在工程侧规范,我们会探讨从 JavaScript 项目的迁移,制定团队的统一工程约束。接着,我们来稍微深入下 TypeScript 的 Compiler,看看怎么让它工作得更好,甚至基于它去做更严苛的约束:源码级。在最后,来谈一谈 TypeScript 发展至今,出现过的竞争者、推荐的工具链,以及总结。
1 TypeScript 研发侧规范
TypeScript 类型撰写规范
我们前面提到,研发侧规范关注如何写出更规范的 TypeScript 代码,这里分为两个部分:编写更优雅的 TypeScript 代码以及 TypeScript 工具类型。所有从 JavaScript 过渡到 TypeScript 的开发者都能很快写出带上基本类型的 TypeScript 代码,而要自然而然地写出易读、易维护、严谨精确,我们这里统称为精确的 TypeScript 类型代码,则需要一定时间的项目开发经验积累。
编写更优雅的 TypeScript 代码
那么,能被称之为优雅的 TypeScript 代码会是什么样的呢?先想想让我们拥抱 TypeScript 的重要原因之一:VS Code 强大的类型提示,优雅的 TypeScript 代码让我们在开发项目时,每一处都能获得如臂使指的类型提示,无论是多么细微之处。但很神奇的是,你可能看不到多少显式的类型代码标注,因为代码背后已经帮助你完成了所有类型推导需要的工作。类似的,对于联合类型或者类似的情况,代码层面也利用类型提示功能确保了每一个类型分支都必须被妥善地处理,如果新接手的同学新增了一个类型分支却没有处理,也能够立刻被编辑器警告。
最重要的一点,类型不求多但求精,精确地类型代码意味着你事先已经基于整个项目的逻辑进行了预先设计,每一处逻辑都有对应的类型代码支撑,它们彼此之间是紧密关联的。这样的类型代码完全能够替代许多不必要的逻辑注释。我们接下来来看几个常见的 TypeScript 代码方面的内容,进一步了解一下优雅之于 TypeScript 代码:
泛型:显式泛型与隐式泛型
想要写好 TypeScript 类型就离不开泛型,一般来说在实际项目中我们可能会有两种使用方式,显式与隐式。先来看一个常见场景,在为请求结果声明类型时我们可能会这么做,左边的一二两种是,请求方法完全没有类型的情况,这个时候使用类型断言或显式类型标注就行,第三种则是请求方法带了类型,但经过数任维护者改动后类型已经越来越偏了,你又懒得修正实际类型,就会变成这种 as any 再 as 到实际请求类型的情况。
显式泛型与隐式泛型
但这些断言实际上都是不必要的,最简单的方式是只要给请求方法预留一个泛型坑位,直接作为返回结果也行,当然这就没什么意义了。在工程实践中我们通常使用统一的请求方法,所以如果每个调用请求的地方都能被自动推导出响应的类型,我们就能够在减少许多类型代码量的同时保证更精确的类型。
集中管理请求方法的示例
这张图是一个简单的,适用于集中管理的请求方法的例子,通过枚举和泛型的帮助将请求的路径直接和最后的请求结果关联起来,而不再需要更多额外的类型标注了,同时在更严格的场景下,我们实际上还可以将入参的类型校验也关联到 API 枚举中。
这实际上反映了一个 TypeScript 的重要概念,自动推导大于手动标注。通过强大的类型推导能力,我们能够确保所有的核心逻辑,乃至是边边角角都有着完整的类型覆盖。但这其实就好比汽车中的自动挡与手动挡,自动推导无疑更加高效便捷,但我们需要做掉类型推导的工作,类似于变速箱。而手动挡,也就是显式的标注类型,你可以更精细地控制每一处的类型,但也需要做更多的脏活累活以及承担更多的心智负担。
any、unknown 与 never
在 TypeScript 中存在着三个特殊的类型:any、unknown 与 never,用的最多的必然是 any 了。any 与 unknown 在 TypeScript 中其实都属于父类型,也就是 Top Type,使用 any 意味着你完全放弃了类型检查,同时由于它的传染性,一个被断言为 any 的变量,基于其的操作与派生在后续都将被视为 any,这是非常可怕的。
any 与 unknown
所以 TypeScript 后续又引入了 unknown,它们的最大区别是 unknown 仍然是有类型检查的,unknown 的变量在后续操作中需要手动断言,也就意味着你虽然不知道它应该是什么类型,但在这里它就是这个类型。所以 unknown 就像是类型安全版本的 any。
而 never 则属于相反的存在,它是所有类型的子类型,Top Type 位于所有类型的顶端,而 Bottom Type 则是无法再细分的类型,也就意味着除了本身没有类型可以再分配给它。我们前面提到优雅的 TypeScript 代码中有一个重要的点就是,确保所有类型分支被处理。TypeScriptConfig 中有一个配置项叫 noImplicateReturn,确保函数的每条分支都必须返回一个值,就有点类似于我们下面要进行的操作。
never
在这里的例子中,由于 TypeScript 的类型控制流分析,在走到最后一个 else 块的时候,由于这个变量的类型分支已经被处理完毕,只剩下了 never,所以这里不会抛出错误。如果新增了一个联合类型成员比如 boolean,那么这里就会同时抛出编译时错误和运行时错误。
类型守卫
说到联合类型,我们经常会遇到某个变量被赋予了联合类型,但是实际使用时,又必须精确到其中的某一成员或者说分支。常见的做法可能是手动断言,但这其实是非常不合理的做法,因为这需要开发者自己来保证类型的合理性,无疑会加重开发者的心智负担。
类型守卫
其实,更理想的做法是使用类型守卫,通过实际层面的逻辑判断,比如是否包含某个字段,某个字段是否是正确类型,结合 TypeScript 的 is 关键字来在实际使用时去精确地收窄类型。我们可以使用 typeof、instanceof、真值假值判断或者是专门用于区分接口的字段(即可辨识属性),比如这里 type A 和 type B 称为可辨识联合类型,其中就存在着专用于守卫的可辨识属性,在实际场景中我们会经常遇到的则有登录用户与访客用户的字段差异。
字面量类型
在字面量层面进行收窄
在某些情况下,如果我们只是使用 TypeScript 内置的类型,比如 number、string 这种,去注释可能取值固定的属性,如请求状态码的值是固定的如 200、401 等,看起来是没有问题,但其实还可以做得更好、更精确,比如我们直接使用字面量类型。
控制流分析中的类型提取
我们可以直接从字面量的层面提供类型,比如状态码、状态标识这一类完全可以通过 TypeScript 提供的字面量类型来收窄到更精确的范围。TypeScript 本身的控制流分析其实也是做了相关优化的,比如使用 const 声明的原始类型变量,由于不会再被更新,你会发现它直接被推断为最精确的字面量类型,而 let 则只会推断到基本类型。
模板字符串类型
在 TypeScript4.1 版本引入了模板字面量类型,这是对于前面字面量类型的进一步扩展,也是 TypeScript 后续迭代思路的部分体现,即它会为开发者提供越来越严谨、精确的类型注释能力。
字面量类型的进一步扩展
这里 type SayHi 中使用了随着模板字面量类型一同引入的专用方法 Capitialize,类似的还有其他三个专用于处理字符串字面量类型大小写 case 的内置方法。在 4.5 版本,我们前面提到的类型守卫也支持了基于模板字符串类型的守卫,如上面的例子能直接通过包含 Success 的字符串判断出来自于 interface Success。
重映射
在实际的项目开发中,模板字面量的意义在于使得我们可以做很多 literal 层面的操作,比如这里的重映射。我们想复制一个接口,然后对它的键值类型做操作是很容易的,用索引类型、映射类型就行,但如果我们期望的生成接口在键名上也需要做变更,就没办法了,只能重新声明。而有了重映射之后,我们可以将原键名映射到一个新的,基于模板字面量类型修改过的键名。这里只给了一个简单的示例,实际上你还可以做到更进一步的映射,如基于键值的实际类型来对键名进行不同的修改。
类型编程的核心:工具类型
接下来我们要聊一聊 TypeScript 的类型编程以及工具类型了,这也是对于 TypeScript 代码质量最至关重要的一个部分。
我们都知道 TypeScript 实际上是 JavaScript 的超集,它添加了类型以及一些预实现的 ECMAScript 提案,比如 3.7 版本引入了 Optional Chainning 可选链操作符,以及空值合并操作符,还有 TS 的装饰器、Class 支持这种实际上与 ECMAScript 已经出现偏差的语法部分(如对于类的私有成员,TypeScript 同时支持 private 关键字与 # 修饰符)。因此对于 JavaScript 开发者来说,有成本的只有类型编程部分,但很多人对它其实抱着一股矛盾的情绪。
先说一说我对类型体操的看法,我认为非基础框架开发者完全没有必要去学习过于花哨的体操技巧,这里的花哨意味着你在实际的项目开发中绝对不会有这种需要。有可能你好不容易看懂了每一个花式体操动作,觉得自己掌握了这么花式的动作,应该可以大展身手了。但到实际项目开发中,你会发现这些技巧大部分用不上,你还是不会写真正实际项目所需要的工具类型,即使它的难度比花式体操低很多。这并不是在完全的否定类型体操,如果仅出于学习的意义,它们确实能够很好地帮助你理解如分布式条件类型、模板字符串类型配合 infer 的推导以及一些基础概念,如索引类型映射类型等,所以如果你有兴趣和时间,多看看是有好处的,但不需要追求对它们的掌握程度达到如何高深的境界。
类型编程的优点与弊端
我们前面说,类型体操实际上就是类型编程不断深入,甚至有些脱离实际意义的发展,对于实际项目中我们还是只需要关注更接地气,不那么花里胡哨的部分。那关于类型编程,我们还是先说一说它的优点与弊端吧。
类型编程优点与弊端的权衡
大家都能说出把类型写好了的优势:类型安全、稳定性、开发体验,这也是为什么它现在能成为新项目的首选。而且,最重要的一点,良好的类型编程范式要求你的类型体系是预先设计过的,而不是拍脑袋这里一个接口那里一个类型别名,后面再为了可维护性去收拢它们。
弊端也很明显,首当其冲的是无法避免的大量的类型代码,这些类型怎么组织、怎么设计都是一门学门。而且,每一任维护者的能力不一定能保持一致,对于能力暂时不足的同学来说,即使是前人精心设计过的 TypeScript 项目也能弄得一塌糊涂。另外,类型编程的边际收益实际上是不断递减的,假设你先花了 10 个单位的投入来获得 10 个单位的类型代码精确程度,接下来可能你再花 10 个单位的投入也只能提升 2~3 个单位的精确程度,所以维持投入与提升的比例在一个良性的、可接受的范围内是非常重要的。
类型编程的核心实际上就是工具类型,可能会有同学问,那泛型呢?条件类型呢?这其实不是一个层面的东西,正因为我们把泛型、条件类型、类型推导这些逻辑都封装进了工具类型中,让后续的开发者不需要关注内部实现细节,所以才说它是核心。
工具类型的实际场景
常见的工具类型场景归纳一下就是对基础类型、对接口、对模板字符串类型的操作,TypeScript 本身是内置了一批基础的工具类型的,但是并不够用,所以社区就出现了 utility-types、type-fest 这一类进阶工具类型的合集。
定制并积累适用于项目的工具类型集
实例:互斥工具类型 XOR
但是,当你的项目复杂度达到一定程度,或者说类型操作比较特异,你就需要自己来编写工具类型了。比如我们在重映射部分提到的,你要基于键值类型处理键名,这就是比较特异的场景了。
互斥工具类型 XOR
我们来看一个实际场景,互斥工具类型,常见的场景比如有某个对象只能,且必须满足多个接口之一,如登录用户和访客的信息,还有场景如某个对象中的多个属性存在依赖关系,要么同时存在,要么同时不存在。我这里直接给出具体的实现,其实最核心就是通过显式指定部分属性为 never 类型,来阻止我们不想要的类型存在。
互斥类型
看一下具体的效果,在互斥类型中,被标注为 FooOrBar 类型的对象必须满足 Foo 与 Bar 类型之一,但不能同时满足。
互斥属性
而互斥属性中,module 与 container 类型则是至多拥有一个。
研发侧的规范就到这里,我们比较概括地聊了一下如何编写更好的 TypeScript 代码。但要在团队层面去落地 TypeScript,我们还需要工程侧的规范来辅助。
2 TypeScript 工程侧规范
工程侧规范部分是直接命中题目,和本次分享名字相关的,也就是淘宝店铺的 TypeScript 迁移经验以及研发规约的制定与落地。
旺铺装修项目重构基本情况
先来了解一下,旺铺装修这个项目大概有 21000+ 的代码量,我用了大概一个月左右的时间进行了四轮重构,从最初的添加类型开始,重构逻辑、优化性能以及测试用例保障,现在它的代码量上涨了约 25%,代码量大概在 25000 行左右,整体的 TypeScript 类型覆盖率保持在 98%。在开始谈我们的迁移经验前,不妨先聊一聊从 JavaScript 项目迁移到 TypeScript 的一些经验。
从 JavaScript 项目迁移
我个人在数个项目的迁移过程中总结出的经验是,一般有两种迁移方式,第一种是激进型,抽出一段集中的时间进行全量迁移的工作,成本不小,但你可以一次性熟悉整个项目的脉络以及关键信息,但最明显的缺点在于随着后续需求迭代,如果出现问题无法快速定位到是否是重构导致的,这种方式适用于中小型项目。我们这里做个约定,把 1w 行和 3w 行作为中型项目和大型项目的临界点,也就是说激进派适用于代码量 3w 行以内的项目。
另外一种是温和型,由于 TypeScript 良好的兼容性,我们可以让 TypeScript 文件和 JavaScript 文件很好地共存,或者先把所有 js jsx 文件替换成 ts tsx,然后顶上全加上 @ts-nocheck,之后来一个需求, 我就把这个需求涉及到的文件全部修正完毕,在一段时间的积累后,等你发现已经改的七七八八了,这个时候就可以拿一小段时间出来完成彻底的迁移工作了。这种方式很容易在出现问题时定位到根源,同时可以在不连贯的时间里进行。但同样有缺点,每次开始重构工作时,都需要一定时间恢复一下上下文,想想上次写的这里是啥意思来着。
激进派 vs 温和派
总结一下,对于小中型项目,推荐使用激进型,连贯的操作使得不用每次开始重构前都回想一下上次的改动,不会改着改着发现上次的改动没考虑到某个问题,需要推翻重来。而温和型则适用于大型的高复杂度项目,或是对稳定性要求较高的项目。而我们在旺铺装修采用的即是激进型的迁移风格,下面来展开介绍一下整个迁移的过程。
淘宝旺铺的 TypeScript 迁移过程
首先,准备工作环节,激进型并不是说真的完全不做准备,直接撸袖子开干,你需要熟悉完整的项目逻辑,基于逻辑以及实际需求去设计重构的具体规划,比如整个工作分几期,每一期的侧重点在于哪个部分,工作量具体是怎么样的,一定要结合需求节奏来设计,比如把工作量大的部分放在需求节奏相对平缓的部分,同时,这里还需要和产品以及测试同学协调资源,让他们意识到你在做的事情是有意义的,即更高的需求开发效率和稳定性。
在执行重构时,通常第一期会是纯粹的类型添加工作,TypeScript 类型的覆盖率在本期就要开始要求,然后在精确类型的帮助下,再去做逻辑重构,你会觉得事半功倍。这里对应着前面介绍的研发侧规范,紧接着在已完成的重构工作中你肯定会发现很多代码层面的问题,这个时候就需要开始着手基于这个项目设计工程规范了。
再往后,就是提效部分了,比如性能优化、技术栈的升级等等。这里请注意,并不是重构完了就没事了,我们还有很多后期的工作要做,比如测试用例的推进、重构前后的数据指标(如代码量和效率提升、一期期重构工作下来你的经验总结等等),这些都是可以在团队内去做推广,复用到其他的项目中去的。
好的,关于迁移的部分就说到这里,接下来我们讲一讲工程侧的约束是什么样的,它的组成与意义在哪里?
工程侧规范:绝对约束
可以看到,我在标题中特异强调了两处绝对约束,你可能会觉得有点奇怪,约束还有绝对和相对的差别吗?
在团队内落地绝对约束
为什么说是绝对约束?类比到 CSS 中的相对定位和绝对定位,如果我们采用相对的规范,根据所有团队成员的个体水平或者是平均水平来制定,必然会导致最终规范对于部分成员来说显得太宽松,那么规范就不再是规范了。因此,无视成员水平的绝对约束是非常有必要的。
首先,绝对的约束确保了不会再出现代码质量的参差不齐,如所有人的 TypeScript 代码都通过了统一的、严格的规则检验,也确保了所有人的代码风格统一,如类型断言可以用尖括号和 as 的形式,对象类型声明可以用 type 和 interface 的形式,对于这种基本风格的统一是非常有必要的。
其次,绝对的约束也使得所有人都只能接受这一规范,对于能力存在不足的成员来说,这在初期可能是很痛苦的过程,但实际上这是个不破不立的槛,当你习惯了通过这种严谨的方式,或者说束手束脚的方式编写代码之后,你就很难再回到自由不羁的 JavaScript 或者 AnyScript 中去。这一过程其实也是能力飞速进步的一个时期,因为规范是确定的,它就明晃晃得摆在那里,你看着它就知道什么样的代码是 OK 的,你朝着这个确定的方向努力就行了。
TypeScript 本身写逻辑和 JavaScript 没有什么本质的区别,你要是能用 JavaScript 写出来,就能用 TypeScript 写。如果说有什么困难,唯一有成本的就是类型编程,但这其实在实际的业务中占比是非常小的,除非你是框架或基础类库的维护者。
约束的组成和制定
上图描述了我们目前落地的约束组成,包括我们马上要讲的一套包括 ESLint、StyleLint、CommitLint 以及 Git Hooks 这些 Lint 工具在内,基于实际需求定制的规则集,Lint 无法覆盖的,比如说实现逻辑,Code Review 时会被作为参考的依据,比如 React Hooks 中的 useCallback 与 useMemo 使用。测试用例,如旺铺装修做了 E2E 相关的测试用例集成,以及用于降低约束落地成本的工具链,我们不可能让大家自己给每一个新老项目配一遍,那就需要写个 cli,本地卡口可以绕过,比如 git commit--no-verify,那就需要云端 CI/CD 的卡口。还有一些特殊的定制需求,比如我们下半部分会提到的,基于 Compiler API 做源码级的约束。
而约束的制定也不是说某几个人来制定,制定完推下去,强制执行就好了。比较通用的方法是走一个循序渐进的过程,比如制定完毕以后,公示、收集完意见之后制定初版,开始在一些项目试点,在试点过程中继续根据反馈修改,然后得到最终的基础约束。再开始做其他的辅助工作,如工具链、云端的约束卡口等等。
接下来,我们来看约束中最基础,可以说只要你想建立约束,就一定会有的 ESLint 规则集部分。
ESLint、TSConfig 规则集
我们最终使用的规则集可以简单拆解成这么几个部分,首先是对于多种语法的统一,TypeScript 中可以使用 as 和尖括号来进行类型断言,as 看起来更清晰,尤其是与泛型一同使用,尖括号看起来更简洁干练,使用哪种并不重要,重要的是只使用一种。
还有使用新的语法代替掉老的语法,比如空值合并代替逻辑或,可选链代替逻辑与。对象类型只能用 interface 声明,类型别名应该用来做联合类型、函数类型、工具类型的声明等。接着是专注 TypeScript 类型书写的部分,比如不允许使用空对象或顶级对象 Function Object 来作为类型注释,函数需要显式的声明返回值,这是为了清晰地判断一个函数是否有副作用,以及泛型参数,是否要求写泛型参数的约束与默认值,比如 T extends any = xxx 这样。最后则是强制的卡口,如我们在 never 类型中提到的,对于 switch case 语句的所有类型分支都要被处理,以及一些对于约束代码质量很有帮助的 tsconfig 配置,如 strict 系列的 strictNullChecks 这种。
上半部分的分享就到此结束,我们已经讲过了研发侧与工程侧,它们是在团队内推广落地 TypeScript 相关建设的基础,也和每一个开发者息息相关。在下半部分,我们会进一步探索 TypeScript,来看一看如何优化 TypeScript 的编译性能,如何利用 Compiler API 定制特殊场景下的解决方案。
3 对 TypeScript Compiler 的进一步探索
在之前分享的上半部分内容(此处添加链接),我们已经了解了研发侧与工程侧的规范落地,但可能还并不能满足实际的项目场景。比如在大型 Node.js 项目下,你可能会遇到 tsc 的性能问题,因而开始关注 Compiler 的性能问题。或者在一些 ESLint 显得不那么智能,比如你希望强制的要求某些函数的入参来自于枚举而不是字符串的场景,你会开始关注如何使用 Compiler API 做源码分析和约束。这也是我们在下半部分分享的来源,我们这次分为两个部分,Compiler 的性能与 Compiler API(AST 部分)。
对 TypeScript Compiler 的探索并不会显得脱离实际,比如在大型的 Node.js 项目下,你可能会遇到 tsc 的性能问题,那就需要你关注如何调优性能,包括通过 tsconfig 或者是自己的定制。还有在一些 Lint 无法覆盖的场景,也就是更严格的约束,如你希望强制的要求项目中必须导入某些模块作为 polyfill,某些函数的入参来自于枚举而不是字符串的场景,那你会开始关注如何使用 Compiler API 做源码分析和约束。这也是本次分享的实际来源场景,所以我们会着重的关注 Compiler 性能与 Compiler API 这两个方面。
提升 Compiler 性能
代码示例
首先,来看一个通过 tsconfig 实现性能提升的例子。一般在 Monorepo 场景下,如果要同时并行调试多个 package,常常需要开启多个进程,每个进程执行一次 tsc--watch,这是 Node 下的 Monorepo,如果是前端相关,需要走其他编译链路如 Webpack,由于这样做本质上是通过源码来进行引用,在编译时会出现被引用的包本身的 tsconfig 不生效,因为编译时只会读取当前的 package 配置。
Project References 引用链
所以 Project References 就出现了,它的主要作用是使得你可以把整个 TypeScript 项目拆分成更细的粒度, 我们在上面这张图中声明了一个引用链,project 引用 core,core 引用 util,通过使用与 Project References 一起引入的新的 tsc 选项 --build,我们在 project 目录下执行 tsc--build,它会自动的最先去编译 util,然后才是 core 与 project。同时每一个部分都能确保使用自己的 tsconfig 进行编译,前端项目的话 ts-loader 同样支持了这一功能的。同时,它能精确地做到在依赖的项目更新时自动地去按需编译。
而在 monorepo 工程以外的场景下,要优化性能同样是大有可为的,比如说从代码层面,基于已有类型的扩展我们可以使用 interface extends 或者交叉类型,如果为了性能考虑应当使用接口继承。因为接口继承的类型级联关系能够被缓存起来,交叉类型则不会,并且交叉类型在检查是否成立时,还需要展开每一个成员。另外一个从代码层面,也是我个人比较推荐的,对于函数、类方法这些能自动推导返回值的代码,显式地标注这些返回值,一方面这在编译器层面能减少掉许多推导成本,另一方面,也让我们能够清晰地判断这个函数的作用。同时由于 TypeScript 中的上下文类型推导能力,在声明返回值类型后,函数内部的 return 语句会自动被推导为此类型。
按需加载 Definitely Typed Package ,这同样是从配置出发进行优化,通常情况下 node_modules 中所有的 @types 包都会自动被加载到全局的环境里,主要是为了方便 Jest、Mocha 这一类并没有实际导入但又注入了全局变量方法的包。但这可能会影响编译的性能或者导致全局变量的冲突,推荐的方式是在 CompilerOptions.types 中,按需引入真正需要的类型定义。你可以在 Snowpack、Vite 等创建的项目中也发现这一点。
下一点,transpile only,这个其实是 ts-loader 以及 ts-node 这一类工具提供的,它其实就是跳过了编译时的类型检查环节,能够很明显的加快编译速度。但并不是说真的就不要类型检查了,通常会和 fork-ts-checker-plugin 一起使用,这个 webpack 插件使用多个子进程来进行类型检查工作,所以能够有效地提升性能。
关于 transpileModule,这个其实涉及的比较多一些。我们知道 Babel 其实也是可以编译 TypeScript 代码的,但这些工具并不能读取类型,不像 TypeScript 在编译前存在构建整个类型关系的过程,所以如果你的代码恰好踩到了这个坑,那你使用 Babel 编译完的代码就是有问题的。所以 TS 提供了 isolateModule 这个配置项,它会在你使用了除 TypeScript 以外的构建工具无法编译的语法时给出警告,常见的这一类语法有重新导出从别处导入的类型,因为对于类型的导入实际上是走的和值导入不同的空间,它是没有值的,因此会被直接抹掉,那别的引用文件就直接没法引入了。还有常量枚举,它会在构建的时候直接被行内替换为具体的枚举值,枚举对象是不会存在的。但是其他工具根本不知道这个常量枚举,那么引用的地方也就直接抛出错误了。
定位潜在的 Compiler 性能问题
而关于定位性能问题就比较简单了,最常见的导致耗时异常的就是不正确的 include 以及 exclude 配置,即包含了不该有的文件,没有把无关文件剔除掉,你可以用 tsc --listFiles 来检查所有被引入的文件。对于其他环节的编辑器耗时,你可以用 --extendDiagnostics 来获得完整的各个阶段的耗时,比如 I/O、解析、类型检查等等环节。
这一部分即使你目前没有遇到性能问题,也可以尝试一下,看看现在的项目中是不是还是有优化的空间。
下面,我们要讲讲 Compiler 的另一个使用方式了。
源码级约束
对于 Compiler API 使用,这里其实是一个概括,只包括 AST 相关的部分。我会为你介绍为什么我们要基于 AST 去做源码级约束,它和 ESLint 的场景差别是怎么样的,如何去使用 TypeScript Compiler API。
首先来解释一下什么叫基于 Compiler API 的源码级约束,ESLint 大家都知道基本的作用和工作原理,也就是解析成符合 estree 标准的 AST,然后去检查这个 AST,比如那条要求函数显式标注返回值的规则,就是先解析成 AST,然后检查函数返回值的类型节点,也就是 TypeNode 是否存在。
但实际上还存在着一些场景是 Lint 做不到也不应该由 Lint 来做的,比如我们前面说过的,要求页面的入口文件必须有某个模块导入作为 polyfill,或者要求某个函数的调用必须通过枚举的形式调用,魔法字符串和变量都不行,我把这一部分称为源码级约束,也就是 AST Checker。
AST Checker vs ESLint
来和 ESLint 做一个 PK,看上面的这张图,黄色部分是 AST Checker 中会抛出错误的,蓝色部分是 ESLint 会抛出错误的。你会发现 ESLint 关注的是语法统一以及类型书写规范,而 Checker 关注的是实际的代码逻辑,这里可以为每一处细微的场景编写特定的 Checker,来作为 Lint 之上的又一层保证。常见的约束主要有导入、导出、函数、类以及 TSX 相关的场景。
TypeScriptCompiler API 基础
而要做源码级约束,由于我们需要关注类型,很显然最好的方式是使用 TypeScript 自己的 Compiler API 来做,你可以简单的理解为类似 Babel 系列的包。但 Compiler API 存在着一定的使用成本,它需要你有一定的编译原理知识储备,比如看上面的这张图,创建 Identifier、TypeNode、Declaration、Expression、Statement 等等,我们看一下这一段代码最后生成了什么样的 TypeScript 代码:
组装式 API
是的,这一波操作其实我们只生成了三行代码。如果我们需要进行的操作复杂度提高一些,实际使用 Compiler API 的成本也会暴涨。前面说到对于编译原理知识的储备,也就意味你需要了解 SynatxKind、Statement 这些 AST 子节点,你需要了解它们在 AST 中的作用,在实际源码中都代表着什么,同时 TypeScript 的 AST 还存在类型相关的部分,就进一步带来了理解成本。所以你可能会想,能不能把这些 API 封装起来,至少屏蔽一部分编译原理的知识?事实上社区也的确有这样的方案,即 ts-morph。
但我并不打算介绍 ts-morph 的使用,包括右边这张图也和 ts-morph 无关。因为我自己作为一个非科班非架构的前端同学,使用 ts-morph 的时候还是感觉有些繁琐和吃力,为什么不能像 Lodash 那样直观的把 AST 结构当成对象和数组一样操作呢?本着社区没有就自己造一个的原则,我在 ts-morph 的基础上封装了一批 AST 的 util 方法,AST 操作也本该如此,获取一个树节点,看看它是否是我们想要的结构,如果不是,我们修改这个声明,保存,然后就结束。如果你并不是想要通过这些 AST 操作学习编译原理,那么就没有必要,也不应该花费额外的时间去了解相关概念。
ts-morph 与 ts-morpherts-morpher
旨在提供一系列符合直觉的命令式 API 来在 ts-morph 的基础上进一步降低 AST 操作的使用成本,并将不同类型的操作分散在不同的 package 下,如 checker、creator 等,每一个 package 都提供了细粒度的针对于各个语法类型如导入导出、Class 的方法,每个方法只做一件事情。我们看上面的图中,检查一个导入是否存在,为已存在的 Class 新增一个 Entity 装饰器。更进一步,还可以检查一个导入是不是要求的类型,比如命名空间导入,仅类型导入,混合导入等等。而实际场景也是类似这样基于一个个细粒度方法封装出来的,就像搭积木一样。我们本身并没有统一的 AST Checker 方法,每个项目要求的 AST 检查都不一样,所以它是与项目实际关联的,我们把这一卡口放在最前,即先通过了 AST 检查才会有后面工程侧引入的 Lint、TypeScript 类型覆盖率检测、测试用例等卡口。
4 杂谈:工具链与竞争者们
这一部分已经到了分享的尾声,所以让我们来聊一点相对轻松无压力的东西吧。我这里准备了在工程中比较通用的 TypeScript 辅助工具链,以及简单介绍下 TypeScript 发展至今一路出现过的和它作用类似,都是想给 JavaScript 安上类型以及额外特性的竞争者们。
TypeScript 工具链
首先是工具链,我这里只会介绍比较通用,而不是仅针对于某些特殊场景的工具。首先是 zod,它是一个静态的,命令式的 Schema 校验工具,有点类似于 Joi,但提供了非常强大的 TypeScript 类型支持,目前在 Midway Hooks 以及 BlitzJS 这一类前后端一体化的项目中都能看到它作为类型安全的重要保证存在。
接着是 tsd,它的作者是 sindresorhus,同时也是 chalk 等一系列广泛使用开源库的作者,它能够被用来给你的 TypeScript 类型做单元测试,如果你想给自己团队积累的工具类型也整上测试,那么用它就对了。type-coverage 和 ts-coverage-report,这两个工具能够检查你的项目中 TypeScript 类型的覆盖率,如在我们的场景中它也被作为本地提交代码的卡口之一,其最低的覆盖率需要达到 95%。type-fest 和 utility-types 就是我们在上半部分的工具类型中提到的,社区的基础工具类型库,不管是作为团队工具类型库的基础还是用来学习都是很好地选择。
ts-morph 以及 ts-morpher 这里就不再赘述了,我们在前面已经讲过如何使用它来操作 TypeScript 的 AST,不论你的团队是想基于它们中的哪一个来建设自己的 CodeMod 或者 AST Checker,都是不错的选择。
TypeScript 的“竞争者”们
接着我们来聊一聊竞争者们,其实这里说竞争者也不是非常恰当,它们中的很多并不是抱着竞争的心态出现的,甚至方向也和 TypeScript 不太一致,所以这里加了个引号。
TypeScript 的“竞争者”们
实际上 TypeScript 不是唯一一门试图给 JavaScript 插上类型的翅膀的方言。类似的,还有 Meta(原 FaceBook)的 Flow,GraphQL 与 React 都是使用 Flow 来写就的,简单的说是因为 Meta 的巨型 Monorepo 架构,TypeScript 会把所有文件都加载到内存里面来解析,这样的话服务器是肯定顶不住的,所以就有了同样具备类型,同时可以按需加载的 Flow。
而 AtScript,实际上应该说它是 TypeScript 的方言或者说是超集。在 TypeScript 提供了类型的基础上,AtScript 额外提供了装饰器与元数据,实际上也就是注解语法的支持,这也是 Angular 这一框架的核心语法,所以 Google 才要搞一个 AtScript 出来,At 也代表了装饰器语法,或者我们平时用来艾特人的这符号 @。而大概在 2015 年,TypeScript 团队与 Angular 团队进行了一次神秘的交易:Angular 迁移到 TypeScript,AtScript 不再维护,TypeScript 引入装饰器相关特性。
最后一个 ReScript,这门语言的创造者是国人张宏波老师,它的底层编译基于 OCaml,实际上是一门和 JavaScript、TypeScript 发展方向已经不太一致的语言,它秉承了函数式的思想,拥有相比 TypeScript 真正的全类型覆盖,同时拥有极好的性能。它还有一个很大的优势是,ReScript 编译后的 JavaScript 代码可读性非常好,基本上和开发者自己编写的没有很大的区别。
5 总结
好的,最后就到了我们本次淘宝店铺 TypeScript 研发规约落地的总结环节。除了最后的工具链以及杂谈部分,我们一路从研发侧规范到工程侧规范,再到基于场景出发的 TypeScript Compiler API 扩展,实际上它们是有层级关系的。
层级关系
以我们已经落地的规范为例,一定是研发侧规范作为最基础的一层,让所有开发者首先懂得什么样的 TypeScript 代码是好的以及怎么写出好的 TypeScript 代码,然后工程侧规范才能发挥作用,将团队成员的代码进行统一的约束,进一步的提高整体编码规范。
接着,由于我们存在特殊的稳定性需求,比如希望新增的模块项目也需要被纳入到统一管控,所以我们基于 TypeScript 的 Compiler API 做了源码级的约束。如果是在你的场景下,它可能是基于 tsd 的工具类型单元测试,可能是统一的前后端类型生成与校验等等。
洋洋洒洒的说了这么多 TypeScript 的好处,是时候来稍微泼点冷水降降温了。
首先,没有银弹,即使是 TypeScript 也不是银弹。并不是所有 JavaScript 无法解决的问题都能通过切换到 TypeScript 解决,也不是上了 TypeScript 的项目就能获得大幅提升的稳定性和开发效率。
首先问一问自己,你的项目是否真的迫切需要 95% 甚至更高的类型覆盖率,需要严丝合缝的 TypeScript 类型代码吗?你是否真的愿意付出额外的成本来获取这些吗?比如多出来的类型代码以及一言不合就报错的心智负担?即使你能接受,你的团队是否能比较低成本地,从项目试点到研发规约建立推广强制执行的这个过程中走下来,这中间可能存在着很多阻力,比如不间断的需求节奏、能力暂时不足的成员无法接受严格的 TypeScript、代码质量惨不忍睹、还需要额外的成本来培训和缓慢的适应。
这些真的是你能接受的吗?但是我认为你是能够清晰地分辨出是否有必要尝试切换到 TypeScript 的,这里只是作为一些额外的成本提示。
实际上我们也不是一路顺风顺水,我们也遇到了很多问题,正是在一开始就坚定地确认过这么做,也就是切换到 TypeScript 是一定利大于弊的,所以我们逐个解决了这些问题,并推到了今天。在现在,我们能说一切的成本都是非常值得的。那如果带着这些经验回到过去,是否又会有新的不同?
答案是肯定的,回头看我能确定地说自己在迁移过程中走了许多弯路,比如说,明明已经规划好了每一期的重构工作,但是出于各种各样的原因如代码洁癖,还是没能做到让每一期工作的职责边界足够清晰,比如在第一期设计类型体系的过程中,还是对代码逻辑做出了一点改动,结果由于不熟悉项目整体的逻辑产生了额外的问题。这是第一个教训,最多记个 TODO,但还是不要在添加类型的同时去修改逻辑,不要以为只要类型 match 上了就不会有问题。
第二点,每一轮重构工作之间不仅要隔离,本身还要具备足够的完整度,添加类型的时候就把所有部分的类型添加完,后续再改动也只是小修小改,而不是出于什么原因把一部分直接留白到以后来填坑。所以,不要想着以后再说,拖延症对于程序员可不是个好习惯。
最后一点,在你设计类型的时候,不要仅仅是把每个对象打印出来看看值,然后填进去类型就完事了。比较好的实践是,对于完全陌生的项目,先熟悉它的数据流、组件结构与通信,然后从最细粒度或者最基础的那一部分开始描绘类型,后续沿着数据流的方向慢慢地去丰满整个类型体系。这样做能够帮助你从一开始就建立起有关联的类型体系,否则如果逐个部分梳理的话,很有可能会是这样的:你把每一个部分的类型都梳理的差不多,然后再去建立起关联,这个过程中提炼共享的类型,去掉冗余的类型,然后得到的才是干净整洁的类型体系。
好,今天的分享就结束了。在最后,送给大家一句话,当你还在犹豫要不要上 TypeScript 时,其实你的内心已经有了答案。能让你是犹豫而不是立刻做出决定,说明是你在权衡利弊,潜在的成本阻止了你作出决定,在这种情况下,放手去做吧。在未来,你一定会牵着 TypeScript 的手感谢自己现在做出的决定。
作者简介:
林伟轩,阿里巴巴淘宝前端开发工程师
社区昵称:林不渡,现就职于阿里巴巴淘系技术部,负责淘宝旺铺,制定并推进基于 TypeScript 的研发规约在整个淘宝店铺范围落地。热爱开源社区,GitHub:linbudu599,专注于前端工程化、GraphQL、NodeJS 等。