給萌新的 TS custom transformer plugin 教程——TypeScript 自定義轉換器插件
- 2020 年 3 月 18 日
- 筆記
xuld/原創
Custom transformer (自定義轉換器)是幹什麼的
簡單說,TypeScript 可以將 TS 源碼編譯成 JS 程式碼,自定義轉換器插件則可以讓你訂製生成的程式碼。比如刪掉程式碼里的注釋、改變變數的名字、將類轉換為函數等等。
TypeScript 將 TS 程式碼編譯到 JS 的功能,其實也是通過內置的轉換器實現的,從 TS 2.3 開始,TS 將此功能開放,允許開發者編寫自定義的轉換器。
預備知識
語法樹
語法樹是用於表示語法的數據結構。具體請參考我的另一個篇文章:https://www.cnblogs.com/xuld/p/12238167.html 。
轉換器原理
TS 源碼會先被解析為語法樹,然後通過弱幹個轉換器生成新的語法樹,最後通過程式碼列印器將語法樹轉回源碼。
轉換器本質就是一個函數,這個函數接收一個語法樹,並返迴轉換後的新語法樹。
自定義轉換器分 before 和 after,其中,before 是位於內置轉換器之前(轉換 TS 程式碼),after 是位於內置轉換器之後(轉換已處理的 JS 程式碼)。
如何使用轉換器
官方的 tsc 命令不支援載入自定義插件,但還有很多方法使用自定義轉換器:
- 直接調用 TS 編譯器的 API 編譯程式碼
- 使用社區提供的 TTypeScript 項目:https://github.com/cevek/ttypescript
- 使用 Webpack+TS-loader 編譯項目,並且在 TS-loader 配置自定義轉換插件:
{ test: /.ts$/, loader: 'ts-loader', options: { getCustomTransformers(program) { return { before: [myTransformer], after: [] } } } }
其中,myTransformer 就是一個轉換器。這裡接收一個數組,可以傳遞多個轉換器函數。
Hello world
按慣例先來一個簡單的例子,教你如何寫一個轉換器。
目標:將下面源碼中字元串的內容改成 “Hello world”
console.log("Hello xuld")
1. 新建一個 hello.js,內容如下:
const ts = require("typescript") // 這是一個自定義轉換器 function createTransformer() { return context => { return node => ts.visitNode(node, visit) function visit(node) { // 如果發現字元串,替換為自己的內容 if (ts.isStringLiteral(node)) { return ts.createStringLiteral("Hello world") } // 其它節點保持不變 return ts.visitEachChild(node, visit, context) } } }
2. 測試自定義轉換器
為學習方便,這裡採用直接調用 TS API 的方案使用轉換器
const ts = require("typescript") // 要編譯的源碼 const source = `console.log("Hello xuld")` // 編譯源碼 const result = ts.transpileModule(source, { transformers: { before: [createTransformer()] } }) // 列印結果 console.log(result.outputText)
使用 node 執行以上程式碼可以看到最終的結果。
實現轉換器
轉換器的職責是接收一個語法樹節點,然後返回生成的新節點,如果這個節點無需變化(多數情況),可以返回節點本身。
需要特別注意的是:轉換器只會生成新的節點,而不會修改原有節點。
這是因為一個節點會在多個地方被使用,而且很多地方針對節點作了快取,為了確保系統穩定性,禁止修改節點可以避免很多意外的錯誤。
語法樹是一種有層級的樹結構,只要任何一個節點變化,這個節點的所有父節點都需要重新生成。為了避免每次重新創建大量節點浪費性能,TS 提供了 ts.visitNode,這個 API 接收一個節點和一個回調函數,然後將節點傳遞給回調函數,回調函數負責返回新節點,如果新節點和原節點相同,則重用舊節點,否則自動創建新的父節點。對用戶而言,我們只需要使用 ts.visitNode 找出需要處理的節點並返回新節點,其它情況使用默認的 ts.visitEachChild 即可。
簡而言之,無論你要做什麼功能的轉換器,不用在意原理,只要按這個模板填程式碼即可:
function createTransformer() { return context => { return node => ts.visitNode(node, visit) function visit(node) { // 其它程式碼不變,只需修改下面的部分 // ======================================= if (判斷節點的類型(node)) { return 創建轉換的節點(node) } if (判斷節點的類型(node)) { return 創建轉換的節點(node) } // ======================================= return ts.visitEachChild(node, visit, context) } } }
判斷節點的類型
要判斷節點的類型,可以通過 node.kind === SyntaxKind.xxx 比較,也可以通過 ts.isXXX(node):
如果你不清楚你要處理的這個語法對於的類型叫什麼,可以使用 AstExplorer 。
創建轉換的節點
創建轉換後的新節點有兩種方式:一種是最簡單的,使用 ts.createXXX 創建;還有一種 ts.updateXXX 是基於已有的節點,如果節點發生變化則創建新節點,否則重用節點(主要為了節約記憶體損耗)。
比如要創建一個表示 a + 1 的節點:
ts.createBinary(ts.createIdentifier("a"), ts.SyntaxKind.PlusToken, ts.createNumericLiteral(1))
替換變數名
按以上的思路,替換變數名就需要:先找出變數名對應的節點,然後返回替換後的新變數名:
// 將程式碼中變數 foo 變成 goo if (ts.isIdentifier(node) && node.text === "goo") { return ts.createIdentifier("goo") }
但這裡有個問題,就是變數名、函數名、類名也都是 Identifier 類型的節點,上面程式碼會全部換掉,有時,我們只希望處理某些條件下的節點,這時可以添加更多的判斷,比如只替換作為函數名調用的 foo() 中的 foo,但不替換其它場景:
if (ts.isIdentifier(node) && node.text === "goo" &&
ts.isCallExpression(node.parent) && node.parent.expression === node) { return ts.createIdentifier("goo") }
轉換上下文
所有轉換器都接收一個參數 context,表示轉換的上下文。轉換的上下文主要用於:
- 提供了一些實用的 API
- 在多個轉換器之間共享數據
- 註冊生成節點為字元串時的附加事件
自動生成變數
目標:支援 case 語句中使用 it 關鍵字:
源程式碼:
switch (1 + 1) { case it == 2: }
轉換後:
var _t_1;
_t_1 = 1 + 1 switch (true) { case _t_1 == 2: }
程式碼如下:
function createTransformer() { return context => { return node => ts.visitNode(node, visit) function visit(node) { if (ts.isSwitchStatement(node)) { // 創建臨時變數 const name = ts.createUniqueName("_t") // 插入變數 context.hoistVariableDeclaration(name) // 生成兩行程式碼 return [ // 賦值變數 ts.createExpressionStatement(ts.createAssignment(name, node.expression)), // 將 switch 的條件改為 true ts.createSwitch(ts.createTrue(), ts.visitEachChild(node.caseBlock, child => visitSwitch(child, name), context)) ] } // 其它節點保持不變 return ts.visitEachChild(node, visit, context) } function visitSwitch(node, name) { // 將 it 變為新的變數名 if (ts.isIdentifier(node) && node.text === "it") { return name } // 其它節點保持不變 return ts.visitEachChild(node, child => visitSwitch(child, name), context) } } }
思路:先創建一個臨時變數,存放 switch 條件內容,然後將原始條件改成 true,並將內部 it 替換掉。
報錯
在轉換時,如果需要報錯,可以使用 context.addDiagnostic(diag)
使用類型資訊
在實際場景中,可能需要用到程式碼的類型資訊(比如變數有沒有定義,變數在哪些地方被使用,變數的類型)
轉換器本身並沒有直接提供這些資訊,但可以通過 program.getTypeChecker() 獲取到 TypeChecker,然後通過 TypeChecker 提供的豐富 API 獲取到這些資訊。
如果是採用了 ts-loader, program 對象通過 getCustomTransformer() 的參數得到。
[[[TODO: 更多的案例待閱讀量超過1000後添加]]]
xuld/原創
更多案例
這裡列了一些社區的現成插件,方便研究學習:
ts-nameof
ts-optchain/transform
ts-transform-asset
ts-transform-auto-require
ts-transform-css-modules/dist/transform
ts-transform-graphql-tag/dist/transformer
ts-transform-img/dist/transform
ts-transform-react-intl/dist/transform
ts-transformer-enumerate/transformer
ts-transformer-keys/transformer
ts-transformer-minify-privates
typescript-is/lib/transform-inline/transformer
typescript-plugin-styled-components
typescript-transform-jsx
typescript-transform-macros
typescript-transform-paths
@zerollup/ts-transform-paths
@zoltu/typescript-transformer-append-js-extension
@magic-works/ttypescript-browser-like-import-transformer