我是范文杰,一个专注于 AI 辅助编程与前端工程化领域的切图仔,近期有不少 HC,感兴趣的同学可联系我内推!欢迎关注:
前言
先说结论,强烈建议在所有复杂泛型场景中,显式提供泛型参数,这能够非常显著降低泛型类型推断的复杂度,进而提升 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
属性,减少文件监听复杂度;
后续再慢慢总结分享吧。
近期有不少 HC,感兴趣的同学可联系我内推!近期有不少 HC,感兴趣的同学可联系我内推!近期有不少 HC,感兴趣的同学可联系我内推!