1. 程式人生 > >給萌新的 TS custom transformer plugin 教程——TypeScript 自定義轉換器外掛

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

  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/原創

更多案例

這裡列了一些社群的現成外掛,方便研究學習:

  • 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