一行代码提升 30%+ TS 类型检查性能

开发 前端
先说结论,强烈建议在所有复杂泛型场景中,显式提供泛型参数,这能够非常显著降低泛型类型推断的复杂度,进而提升 TS 性能,幅度甚至可能达到50%!

前言

先说结论,强烈建议在所有复杂泛型场景中,显式提供泛型参数,这能够非常显著降低泛型类型推断的复杂度,进而提升 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 函数,排查过程比较繁琐,就不展示了,直接抛结论,该函数大致做了下面这些事情:

  1. TS 执行过程中,遇到泛型定义时调用 chooseOverload,函数内判断是否传入泛型参数(下图 75424 行);若参数为空,则调用 inferJsxTypeArguments 推断类型(下图 75436 行);

图片图片

  1. 而 inferJsxTypeArguments 内部遍历 jsx 定义的 attributes ,逐步校验各个组件 Props 的类型定义;

图片图片

  1. 当遇到 onValueChange、onSubmit 等函数类型的 props 时,TS 内部需要进一步推断这类函数签名,最终走到 checkFunctionExpressionOrObjectLiteralMethod 函数;
  2. 而 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 执行性能数据,分析并拦截导致长任务的代码,实现逻辑:

  1. CI 环境中执行 tsc -b tsconfig.build.json --generateTrace ./ts-trace,生成性能数据,注意不要加 --force 参数;

图片图片

  1. 遍历 trace-xx.json 文件,找到所有 name === "checkExpress" && dur > threshold 的节点,取出对应 path 与 pos 数值;
  2. 根据 path 与 pos 数值定位对应代码行, 调用 git diff source-branch...target-branch 取得增量内容,之后判断长任务对应代码行是否为本次更新代码,若命中则调用 CI 接口进行拦截。

总结

对于大规模项目而言,Typescript 很好,我认为几乎是必选技术栈之一,并且有必要在开发环境、CI/CD 各个环节设置卡口,验证代码的正确性,其本身性能也做的非常极致,但架不住大型项目代码量上来之后,任务复杂度过高导致类型检测成本也居高不下,此时就必须从代码本身着手,做好各类性能优化,保证时间复杂度在合理范围内。

但这个方向资料并不多,很少能找到现成且有效的解决方案,多数时候需要自己摸索。过去这段时间,我们团队也做了许多这方面的尝试,除了本文提到的这种显式定义泛型参数的方法外,其他值得分享的性能优化手段包括:

  • 使用 tsc 缓存,复用旧的结果;
  • 使用 ts project references,实现分片检测;
  • 正确配置 watchOption 属性,减少文件监听复杂度;
责任编辑:武晓燕 来源: Tecvan
相关推荐

2016-12-02 08:53:18

Python一行代码

2017-04-05 11:10:23

Javascript代码前端

2014-02-12 13:43:50

代码并行任务

2022-04-09 09:11:33

Python

2022-12-30 08:08:30

2020-03-26 12:38:15

代码节点数据

2020-07-22 08:30:02

代码开发工具

2020-09-09 16:00:22

Linux进程

2021-11-02 16:25:41

Python代码技巧

2020-08-19 10:30:25

代码Python多线程

2021-08-31 09:49:37

CPU执行语言

2017-04-13 19:20:18

Python代码并行任务

2023-09-12 10:10:57

开发者工具开源

2023-10-23 09:56:00

2020-09-28 12:34:38

Python代码开发

2020-08-12 14:54:00

Python代码开发

2019-12-25 14:08:50

Pandas数据计算

2022-09-09 09:33:14

支付宝代码性能

2021-05-28 07:39:17

SQL代码操作

2021-11-05 06:57:50

架构工具代码
点赞
收藏

51CTO技术栈公众号