給萌新的 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 命令不支援載入自定義插件,但還有很多方法使用自定義轉換器:

  1. 直接調用 TS 編譯器的 API 編譯程式碼
  2. 使用社區提供的 TTypeScript 項目:https://github.com/cevek/ttypescript
  3. 使用 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,表示轉換的上下文。轉換的上下文主要用於:

  1. 提供了一些實用的 API
  2. 在多個轉換器之間共享數據
  3. 註冊生成節點為字元串時的附加事件

自動生成變數 

目標:支援 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/原創

更多案例

這裡列了一些社區的現成插件,方便研究學習: