手把手教寫 TypeScript Transformer Plugin

用過 ant-design 的同學可能對 babel-plugin-import 有印象,它可以幫助實現模塊的按需引用,比如:

import { Button } from antdn

在使用該 Plugin 之後會被轉換成:

import Button from antd/lib/buttonn

在一個沒有使用 antd 全部組件的項目里,這樣做可以明顯減少打包後的代碼體積。

可是,如果你在一個沒有使用 Babel 的 TypeScript 項目里,想要實現類似的功能,該怎麼辦呢?

這就要用到本文的主角:custom transformation,這是從 TypeScript@2.3 開始引入的新能力,他讓我們可以部分修改 TS 從源碼轉換成的語法樹,從而控制生成的 JavaScript 代碼,最終完成上述的轉換。

先讓我們從 TS 中代碼語法樹的樣子說起。

預備知識

1. 抽象語法樹(AST)

AST 是為了方便計算機理解源代碼、用於表達源代碼語法結構的樹狀結構,由稱作節點(Node)的數據結構組成。

例如:

const name: string = Tomn

上面這段代碼在 TS 會解析成下圖所示的 AST:

確切來說,上圖實際上是語法樹而不是抽象語法樹,因為節點裡面仍然包含了「冒號」等多餘信息,還不夠「抽象」,但是,因為在之後處理的過程中實際面對的就是這樣的語法樹,因此在這裡不做嚴格的區分。

TS 中所有 AST 的根節點都是 SourceFile,顧名思義,這是一個附加了源文件信息的 AST 節點(Node)。

源碼中只有一個變數聲明語句,該聲明生成了以下結構:

  • 表示這是一個常量聲明的 ConstKeyword 節點
  • 表達變數名的 Identifier 節點
  • 表達變數類型的 StringKeyword 節點
  • 表達變數初始值的 StringLiteral 節點
  • 其他附屬信息節點

在 TypeScript/typescript.d.ts 源碼中,用枚舉類型 SyntaxKind 定義了所有的 AST 節點類型,到目前為止近 300 個,可以看出來 AST 的樹形結構非常得精確細緻,想手動分析記憶比較困難,可以藉助 AST explorer 這個可視化工具幫助理解代碼的 AST 結構。

2. TS 編譯流程

和 Babel 以及其他編譯到 JavaScript 的工具類似,TS 的編譯流程包含以下三步:

解析->轉換->生成

包含了以下幾個關鍵部分:

  • Scanner:從源碼生成 Token
  • Parser:從 Token 生成 AST
  • Binder:從 AST 生成 Symbol
  • Checker:類型檢查
  • Emitter:生成最終的 JS 文件

圖示如下:

我們的標題中所指的 transformer Plugin 就是在 Emitter 階段起作用。

transformer Plugin 如何啟用?

tsc命令不支持直接配置 transformer 的參數,你可以手動引入 typescript 來自己編譯,當然,目前最方便的辦法是在 Webpack + ts-loader 的項目中,給 ts-loader 配置 getCustomTransformers 選項:

{n test: /.tsx?$/,n loader: ts-loader,n options: {n ... // other loaders optionsn getCustomTransformers: () => ({ before: [yourImportedTransformer] })n }n}n

詳見 ts-loader 文檔。

實際編寫一個 transformer Plugin

目標

我們的目標就是實現文章開頭代碼示例中的轉換:

// beforenimport { Button } from antdnn// afternimport Button from antd/lib/buttonn

了解需要改什麼

Custom Transformer 操作是 AST,所以我們需要了解代碼轉換前後的 AST 區別在哪裡。

轉換前:

import { Button } from antdn

代碼的 AST 如下:

轉換後:

import Button from antd/lib/buttonn

代碼的 AST 如下:

可以看出,我們需要做的轉換有兩處:

  • 替換 ImportClause 的子節點,但保留其中的 Identifier
  • 替換 StringLiteral 為原來的值加上上面的 Identifier

那麼,該如何找到並替換對應的節點呢?

如何遍歷並替換節點

TS 提供了兩個方法遍歷 AST:

  • ts.forEachChild
  • ts.visitEachChild

兩個方法的區別是:

forEachChild 只能遍歷 AST,visitEachChild 在遍歷的同時,提供給此方法的 visitor 回調的返回節點,會被用來替換當前遍歷的節點,因此我們可以利用 visitEachChild 來遍歷並替換節點。

先看一下這個方法的簽名:

/**n * Visits each child of a Node using the supplied visitor, possibly returning a new Node of the same kind in its place.n *n * @param node The Node whose children will be visited.n * @param visitor The callback used to visit each child.n * @param context A lexical environment context for the visitor.n */nfunction visitEachChild<T extends Node>(node: T, visitor: Visitor, context: TransformationContext): Tn

假設我們已經拿到了 AST 的根節點 SourceFile 和 TransformationContext,我們就可以用以下代碼遍歷 AST:

ts.visitEachChild(SourceFile, visitor, ctx)nnfunction visitor(node) {n if(node.getChildCount()) {n return ts.visitEachChild(node, visitor, ctx)n }n return noden}n

注意:visitor 的返回節點會被用來替換 visitor 正在訪問的節點。

如何創建節點

TS 中 AST 節點的工廠函數全都以 create 開頭,在編輯器里敲下:ts.create,代碼補全列表裡就能看到很多很多和節點創建有關的方法:

比如,創建一個 1+2 的節點:

ts.createAdd(ts.createNumericLiteral(1), ts.createNumericLiteral(2))n

如何判斷節點類型

前面說過,ts.SyntaxKind里存儲了所有的節點類型。同時,每個節點中都有一個 kind 欄位標明它的類型。我們可以用以下代碼判斷節點類型:

if(node.kind === ts.SyntaxKind.ImportDeclaration) {n // Get it!n}n

也可以用 ts-is-kind 模塊簡化判斷:

import * as kind from ts-is-kindnif(kind.isImportDeclaration(node)) {n // Get it!n}n

那麼,我們之前的 visitor 就可以繼續補充下去:

import * as kind from ts-is-kindnfunction visitor(node) {n if(kind.isImportDeclaration(node)) {n const updatedNode = updateImportNode(node, ctx)n return updateNoden }n return noden}n

因為 Import 語句不能嵌套在其他語句下面,所以 ImportDeclaration 只會出現在 SourceFile 的下一級子節點上,因此上面的代碼並沒有對 node 做深層遞歸遍歷。

只要 updateImportNode 函數完成了之前圖中表現出的 AST 轉換,我們的工作就完成了。

如何更新 ImportDeclaration 節點

下面關注 updateImportNode 怎麼實現。

我們已經拿到了 ImportDeclaration 節點,還記得到底要幹什麼嗎?

  • 用 Identifier 替換 NamedImports 的子節點
  • 修改 StringLiteral 的值

為了方便找到需要的節點,我們對 ImportDeclaration 做遞歸遍歷,只對 NamedImports 和 StringLiteral 做特殊處理:

function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {n const visitor: ts.Visitor = node => {n if (kind.isNamedImports(node)) {n // ...n }nn if (kind.isStringLiteral(node)) {n // ...n }nn if (node.getChildCount()) {n return ts.visitEachChild(node, visitor, ctx)n }n return noden }n}n

首先處理 NamedImports。

在 AST explorer 的幫助下,可以發現 NamedImports 包含了三部分,兩個大括弧和一個叫 Button 的 Identifier,我們在 isNamedImports 的判斷下,直接返回這個 Identifier,就可以取代原先的 NamedImports:

if (kind.isNamedImports(node)) {n const identifierName = node.getChildAt(1).getText()n // 返回的節點會被用於取代原節點n return ts.createIdentifier(identifierName)n}n

再處理 StringLiteral。

發現要返回新的 StringLiteral,要用到 isNamedImports 判斷里提取出來的 identifierName。因此我們先把 identifierName 提取到外層定義,作為 updateImportNode 的內部狀態。

同時,antd/lib 目錄下的文件名沒有大寫字母,因此要把 identifierName 中首字母大寫去掉:

if (kind.isStringLiteral(node)) {n const libName = node.getText().replace(/["]/g, )n if (identifierName) {n const fileName = camel2Dash(identifierName)n return ts.createLiteral(`${libName}/lib/${fileName}`)n }n}nn// from: https://github.com/ant-design/babel-plugin-importnfunction camel2Dash(_str: string) {n const str = _str[0].toLowerCase() + _str.substr(1)n return str.replace(/([A-Z])/g, ($1) => `-${$1.toLowerCase()}`)n}n

完整的 updateImportNode 實現如下:

function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {n const visitor: ts.Visitor = node => {n if (kind.isNamedImports(node)) {n const identifierName = node.getChildAt(1).getText()n return ts.createIdentifier(identifierName)n }nn if (kind.isStringLiteral(node)) {n const libName = node.getText().replace(/["]/g, )n if (identifierName) {n const fileName = camel2Dash(identifierName)n return ts.createLiteral(`${libName}/lib/${fileName}`)n }n }nn if (node.getChildCount()) {n return ts.visitEachChild(node, visitor, ctx)n }n return noden }n}n

以上,我們就成功實現了如下代碼轉換:

// beforenimport { Button } from antdnn// afternimport Button from antd/lib/buttonn

以上代碼整合起來,就是一個完整的 Transformer Plugin,完整代碼請見:newraina/learning-ts-transfomer-plugin

改進

剛才實現的只是一個最最精簡的版本,距離 babel-plugin-import 的完整功能還有很遠,比如:

  • 同時 Import 多個組件怎麼辦,如import { Button, Alert } from antd
  • Import 時用 as 重命名了怎麼辦,如import { Button as Btn } from antd
  • 如果 CSS 也要按需引入怎麼辦

以上都可以在 AST explorer 的幫助下找到 AST 轉換前後的區別,然後按照本文介紹的流程實現。

附註

  • 目前已有 TS Transformer Plugin 版的實現:Brooooooklyn/ts-import-plugin,文中部分代碼參考了它

推薦閱讀:

ThinkJS 3.0 如何實現對 TypeScript 的支持
什麼時候選擇 Babel,什麼時候選擇 TypeScript?
vscode編輯器打開大項目能夠快速預覽,這是如何做到的?軟體演算法比atom做的好?
TypeScript入門
如何看待Google和Microsoft在Angular JS 2 和 TypeScript上的合作?

TAG:TypeScript | 编译器 |