給萌新的 TS custom transformer plugin 教程——TypeScript 自定義轉換器外掛
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