前言
先说结论,强烈建议在所有复杂泛型场景中,显式提供泛型参数,这能够非常显著降低泛型类型推断的复杂度,进而提升 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
在检查表达式
至此,答案就大概可以“猜”出来了,试着补上泛型参数,这段
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 找出这类性能卡点,补充相应泛型参数即可。但更重要的是,修复存量问题后,
后续如何防止这类问题再次出现呢
?有几种方案:
-
-
-
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.',
});
}
}
},
};
},
};
但问题在于,这种方式必须先提前找出所有可能引发性能劣化问题的代码模式,整体僵化不灵活,容易导致遗漏或误伤,相对还不够极致。