一行代碼提升 30%+ TS 類型檢查性能
前言
先說結論,強烈建議在所有復雜泛型場景中,顯式提供泛型參數,這能夠非常顯著降低泛型類型推斷的復雜度,進而提升 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 屬性,減少文件監聽復雜度;