用過 ant-design 的同學可能對 babel-plugin-import 有印象,它可以幫助實現模組的按需引用,比如:
import { Button } from 'antd'複製程式碼
在使用該 Plugin 之後會被轉換成:
import Button from 'antd/lib/button'複製程式碼
在一個沒有使用 antd 全部元件的專案裡,這樣做可以明顯減少打包後的程式碼體積。
可是,如果你在一個沒有使用 Babel 的 TypeScript 專案裡,想要實現類似的功能,該怎麼辦呢?
這就要用到本文的主角:custom transformation,這是從 TypeScript@2.3 開始引入的新能力,他讓我們可以部分修改 TS 從原始碼轉換成的語法樹,從而控制生成的 JavaScript 程式碼,最終完成上述的轉換。
先讓我們從 TS 中程式碼語法樹的樣子說起。
預備知識
1. 抽象語法樹(AST)
AST 是為了方便計算機理解原始碼、用於表達原始碼語法結構的樹狀結構,由稱作節點(Node)的資料結構組成。
例如:
const name: string = 'Tom'複製程式碼
上面這段程式碼在 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 選項:
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
... // other loader's options
getCustomTransformers: () => ({ before: [yourImportedTransformer] })
}
}複製程式碼
詳見 ts-loader 文件。
實際編寫一個 transformer Plugin
目標
我們的目標就是實現文章開頭程式碼示例中的轉換:
// before
import { Button } from 'antd'
// after
import Button from 'antd/lib/button'複製程式碼
瞭解需要改什麼
Custom Transformer 操作是 AST,所以我們需要了解程式碼轉換前後的 AST 區別在哪裡。
轉換前:
import { Button } from 'antd'複製程式碼
程式碼的 AST 如下:
轉換後:
import Button from 'antd/lib/button'複製程式碼
程式碼的 AST 如下:
可以看出,我們需要做的轉換有兩處:
- 替換 ImportClause 的子節點,但保留其中的 Identifier
- 替換 StringLiteral 為原來的值加上上面的 Identifier
那麼,該如何找到並替換對應的節點呢?
如何遍歷並替換節點
TS 提供了兩個方法遍歷 AST:
ts.forEachChild
ts.visitEachChild
兩個方法的區別是:
forEachChild 只能遍歷 AST,visitEachChild 在遍歷的同時,提供給此方法的 visitor 回撥的返回節點,會被用來替換當前遍歷的節點,因此我們可以利用 visitEachChild 來遍歷並替換節點。
先看一下這個方法的簽名:
/**
* Visits each child of a Node using the supplied visitor, possibly returning a new Node of the same kind in its place.
*
* @param node The Node whose children will be visited.
* @param visitor The callback used to visit each child.
* @param context A lexical environment context for the visitor.
*/
function visitEachChild<T extends Node>(node: T, visitor: Visitor, context: TransformationContext): T複製程式碼
假設我們已經拿到了 AST 的根節點 SourceFile 和 TransformationContext,我們就可以用以下程式碼遍歷 AST:
ts.visitEachChild(SourceFile, visitor, ctx)
function visitor(node) {
if(node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}複製程式碼
注意:visitor 的返回節點會被用來替換 visitor 正在訪問的節點。
如何建立節點
TS 中 AST 節點的工廠函式全都以 create 開頭,在編輯器裡敲下:ts.create,程式碼補全列表裡就能看到很多很多和節點建立有關的方法:
比如,建立一個 1+2 的節點:
ts.createAdd(ts.createNumericLiteral('1'), ts.createNumericLiteral('2'))複製程式碼
如何判斷節點型別
前面說過,ts.SyntaxKind
裡儲存了所有的節點型別。同時,每個節點中都有一個 kind 欄位標明它的型別。我們可以用以下程式碼判斷節點型別:
if(node.kind === ts.SyntaxKind.ImportDeclaration) {
// Get it!
}複製程式碼
也可以用 ts-is-kind 模組簡化判斷:
import * as kind from 'ts-is-kind'
if(kind.isImportDeclaration(node)) {
// Get it!
}複製程式碼
那麼,我們之前的 visitor 就可以繼續補充下去:
import * as kind from 'ts-is-kind'
function visitor(node) {
if(kind.isImportDeclaration(node)) {
const updatedNode = updateImportNode(node, ctx)
return updateNode
}
return node
}複製程式碼
因為 Import 語句不能巢狀在其他語句下面,所以 ImportDeclaration 只會出現在 SourceFile 的下一級子節點上,因此上面的程式碼並沒有對 node 做深層遞迴遍歷。
只要 updateImportNode 函式完成了之前圖中表現出的 AST 轉換,我們的工作就完成了。
如何更新 ImportDeclaration 節點
下面關注 updateImportNode 怎麼實現。
我們已經拿到了 ImportDeclaration 節點,還記得到底要幹什麼嗎?
- 用 Identifier 替換 NamedImports 的子節點
- 修改 StringLiteral 的值
為了方便找到需要的節點,我們對 ImportDeclaration 做遞迴遍歷,只對 NamedImports 和 StringLiteral 做特殊處理:
function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {
const visitor: ts.Visitor = node => {
if (kind.isNamedImports(node)) {
// ...
}
if (kind.isStringLiteral(node)) {
// ...
}
if (node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}
}複製程式碼
首先處理 NamedImports。
在 AST explorer 的幫助下,可以發現 NamedImports 包含了三部分,兩個大括號和一個叫 Button 的 Identifier,我們在 isNamedImports 的判斷下,直接返回這個 Identifier,就可以取代原先的 NamedImports:
if (kind.isNamedImports(node)) {
const identifierName = node.getChildAt(1).getText()
// 返回的節點會被用於取代原節點
return ts.createIdentifier(identifierName)
}複製程式碼
再處理 StringLiteral。
發現要返回新的 StringLiteral,要用到 isNamedImports 判斷裡提取出來的 identifierName。因此我們先把 identifierName 提取到外層定義,作為 updateImportNode 的內部狀態。
同時,antd/lib 目錄下的檔名沒有大寫字母,因此要把 identifierName 中首字母大寫去掉:
if (kind.isStringLiteral(node)) {
const libName = node.getText().replace(/[\"\']/g, '')
if (identifierName) {
const fileName = camel2Dash(identifierName)
return ts.createLiteral(`${libName}/lib/${fileName}`)
}
}
// from: https://github.com/ant-design/babel-plugin-import
function camel2Dash(_str: string) {
const str = _str[0].toLowerCase() + _str.substr(1)
return str.replace(/([A-Z])/g, ($1) => `-${$1.toLowerCase()}`)
}複製程式碼
完整的 updateImportNode 實現如下:
function updateImportNode(node: ts.Node, ctx: ts.TransformationContext) {
const visitor: ts.Visitor = node => {
if (kind.isNamedImports(node)) {
const identifierName = node.getChildAt(1).getText()
return ts.createIdentifier(identifierName)
}
if (kind.isStringLiteral(node)) {
const libName = node.getText().replace(/[\"\']/g, '')
if (identifierName) {
const fileName = camel2Dash(identifierName)
return ts.createLiteral(`${libName}/lib/${fileName}`)
}
}
if (node.getChildCount()) {
return ts.visitEachChild(node, visitor, ctx)
}
return node
}
}複製程式碼
以上,我們就成功實現瞭如下程式碼轉換:
// before
import { Button } from 'antd'
// after
import Button from 'antd/lib/button'複製程式碼
以上程式碼整合起來,就是一個完整的 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,文中部分程式碼參考了它
作者:newraina
簡介:百姓網前端工程師。
原文連結:知乎專欄本文僅為作者個人觀點,不代表百姓網立場。
鼓勵作者寫出更好的文章,
掃碼立即打賞!