前言
先说结论,强烈建议在所有复杂泛型场景中,显式提供泛型参数,这能够非常显著降低泛型类型推断的复杂度,进而提升 TS 性能,幅度甚至可能达到50%!例如,在使用 @douyin-fe/semi 库的 Form 组件时:
- 未提供泛型参数:
图片
图片
- 提供泛型参数:
图片
图片
在未显式提供泛型参数时,构建耗时大约为2.3s,其中有 850ms 消耗在 checkSourceFile 节点上;而主动提供泛型参数后,构建总耗时下降至 1.5s,降幅达到 34%,而这仅仅只需要修改一行代码即可实现!
那么,为什么会有如此巨大的提升呢?接下来,我会详细总结整个分析排查问题的过程与工具,以及后续在工程层面,可以做那些事情防止再次出现同类问题。
TS Check 性能排查方法
工欲善其事必先利其器,首先,我们需要学习如何获取 TSC 执行的性能数据,而这需要用到两个 TSC 命令行参数:
- --generateTrace:用于 trace-xxx.json 文件,包含 TSC 编译过程中关键节点的性能数据,可使用 SpeedScope 工具可视化分析:
图片
- --generateCpuProfile:用于生成详细的 CPU 执行堆栈信息,同样可以使用 SpeedScope 工具做可视化分析:
图片
关于这两个参数更详细的解释,可参考 TS 官方文档 Performance Tracing。回到项目中,使用这两个参数执行类型检查,并将结果写出到 ts-trace 目录:
tsc -b tsconfig.build.json --generateTrace ./ts-trace --generateCpuProfile ./ts-trace/ts.cpuprofile --force
之后打开 SpeedScope 工具,选择相应文件即可。顺便提一下, SpeedScope 是我用过最好的 CPU Profile 分析工具,比 TS 文档推荐 chrome://tracing 效率高很多,建议优先使用。
我个人的使用经验:先看 trace-xxx.json 文件,再看 cpuprofile 文件。因为 trace-xxx.json 信息更聚焦一些,相对能直观发现问题,例如上图中 checkSourceFile 节点明显比其他节点长很多,肉眼可见是一个异常点;而 cpuprofile 包含了 TSC 执行过程中大部分调用堆栈,信息更全,更适合深入分析执行细节,定位问题的具体原因,例如识别出上述 trace-xxx.json 中的 checkSourceFile 异常点后,可在 cpuprofile 中找到对应函数执行堆栈,向下分析具体性能卡点。
问题分析
基于上述生成的数据,我们可以初步定位到 checkExpression 节点有明显的性能问题,在示例中消耗 607ms,占比 25% 之久:
图片
根据堆栈信息中 path/pos 等字段,可定位到问题出现在下图第 13 行:
图片
据此可初步推断,tsc 在检查表达式 <Form onSubmit={handleSubmit}> 语句时存在较大的性能损耗,而这段代码与其他代码最大的差异在于:1. 它用了 Form 元素;2. 它没有显式声明 Form 泛型参数。
至此,答案就大概可以“猜”出来了,试着补上泛型参数,这段 checkExpression 的时间直接从 607ms 降低到 79ms:
图片
原理浅析
到这里,已经初步找到这个问题的表征答案,但更重要的是:为什么一个泛型参数的缺失会导致如此严重的性能问题?只有透彻地理解性能卡点的底层原理,才能推导出正确且完善的解决方案,而要分析问题的根因,有两种方法,一是从头开始仔细阅读并理解源码,但 TS 项目太大,成本太高;二是分析上述 --generateCpuProfile 参数所生成的 Cpu 调用栈文件,理解这部分耗时操作里都做了那些事情,这明显性价比要高出许多。
所以,接下来使用 SpeedScope 打开 CpuProfile 文件后,根据时间定位到 checkExpression 对应的 CPU 堆栈节点:
图片
可以看到,这下面有一个非常长的函数堆栈列表,特别是递归出现了许多次 checkExpression、 instantiateXXX 等函数,性能问题应该就出现在这里。作为对比,补充泛型类型后,相应调用堆栈简化为:
图片
仔细对比发现,两者逻辑分叉点主要出现在 chooseOverload 函数上:
- 优化前:
图片
- 优化后:
图片
接着尝试断点调试 chooseOverload 函数,排查过程比较繁琐,就不展示了,直接抛结论,该函数大致做了下面这些事情:
- TS 执行过程中,遇到泛型定义时调用 chooseOverload,函数内判断是否传入泛型参数(下图 75424 行);若参数为空,则调用 inferJsxTypeArguments 推断类型(下图 75436 行);
图片
- 而 inferJsxTypeArguments 内部遍历 jsx 定义的 attributes ,逐步校验各个组件 Props 的类型定义;
图片
- 当遇到 onValueChange、onSubmit 等函数类型的 props 时,TS 内部需要进一步推断这类函数签名,最终走到 checkFunctionExpressionOrObjectLiteralMethod 函数;
- 而 checkFunctionExpressionOrObjectLiteralMethod 内部会递归调用多次 checkExpression 函数,经过一段非常复杂的计算后,最终推断出函数签名,之后再与 Form 元素的 Value 泛型对比检查类型匹配度。
由此可推断,此处性能卡点主要出现在 Form 元素的 Value 泛型推断,以及对传递给 Form 元素的各类 onValueChange 等函数类型的 Props 的泛型推断与检测上,只需要简单提供 Value 泛型,即可绕过许多推断步骤,进而提升效率。
需要注意的是,这一问题目前只在 Form 组件出现,其它多数带泛型参数的简单组件即使触发了推断逻辑,由于类型逻辑相对简单许多,校验链路较短,并不会导致性能问题。
图片
另外还需要注意,chooseOverload 函数中还包含了另一层用于处理函数重载的循环逻辑:
图片
实测发现,函数重载数量越多,参数形态越复杂,此处性能越差,例如下面例子中:
图片
图片
这里的卡点在于 I18nKeysNoOptionsType 是一个非常长达 12000+ 的静态字符串数组,在上述实例中,TS 需要循环校验 t 函数的重载签名,并在每次校验时遍历验证这 12000+ 静态字符串,两相叠加导致性能成本居高不下:
图片
防劣化
到此,我们已经完全可以确定问题根因出在源码中泛型参数缺失,导致 Typescript 需要做 复杂泛型类型的推导与检查,引发性能问题,只需借助 Typescript 的 Performance Trace 找出这类性能卡点,补充相应泛型参数即可。但更重要的是,修复存量问题后,后续如何防止这类问题再次出现呢?有几种方案:
- 文档化,约束代码规范;
- ESLint 检测并拦截特定模式代码;
- CI 阶段分析 TS 性能数据,拦截导致长任务的代码;
首先,最简单也是成本最低的方法,可以将相关规则提升为团队开发规范,明确要求开发者在那些情况下必须补充完备的泛型参数,但这种方式本质上属于“软性约束”,执行与否完全取决于开发者的状态,考虑到人类智能的随机性,最终效果往往并不理想,更好的方式是使用自动化工具在 CI 阶段自动检测问题实现更“强”的约束。
具体来说,可以选择编写 ESLint 规则,限定某些 Case 必须提供泛型参数,例如:
import { Rule } from 'eslint';
export const enforceTsGenericRule: Rule.RuleModule = {
meta: {
type: 'problem',
// ...
},
create(context) {
return {
JSXOpeningElement(node) {
if (
node.name.type === 'JSXIdentifier' &&
node.name.name.toLowerCase() === 'form'
) {
const hasGeneric =
node.typeParameters && node.typeParameters.params.length > 0;
if (!hasGeneric) {
context.report({
node,
message: 'Form elements must have generic parameters.',
});
}
}
},
};
},
};
但问题在于,这种方式必须先提前找出所有可能引发性能劣化问题的代码模式,整体僵化不灵活,容易导致遗漏或误伤,相对还不够极致。
更好的方式是在 CI 环境增量分析 TS 执行性能数据,分析并拦截导致长任务的代码,实现逻辑:
- CI 环境中执行 tsc -b tsconfig.build.json --generateTrace ./ts-trace,生成性能数据,注意不要加 --force 参数;
图片
- 遍历 trace-xx.json 文件,找到所有 name === "checkExpress" && dur > threshold 的节点,取出对应 path 与 pos 数值;
- 根据 path 与 pos 数值定位对应代码行, 调用 git diff source-branch...target-branch 取得增量内容,之后判断长任务对应代码行是否为本次更新代码,若命中则调用 CI 接口进行拦截。
总结
对于大规模项目而言,Typescript 很好,我认为几乎是必选技术栈之一,并且有必要在开发环境、CI/CD 各个环节设置卡口,验证代码的正确性,其本身性能也做的非常极致,但架不住大型项目代码量上来之后,任务复杂度过高导致类型检测成本也居高不下,此时就必须从代码本身着手,做好各类性能优化,保证时间复杂度在合理范围内。
但这个方向资料并不多,很少能找到现成且有效的解决方案,多数时候需要自己摸索。过去这段时间,我们团队也做了许多这方面的尝试,除了本文提到的这种显式定义泛型参数的方法外,其他值得分享的性能优化手段包括:
- 使用 tsc 缓存,复用旧的结果;
- 使用 ts project references,实现分片检测;
- 正确配置 watchOption 属性,减少文件监听复杂度;