用過 ant-design 的同窗可能對 babel-plugin-import 有印象,它能夠幫助實現模塊的按需引用,好比:前端
import { Button } from 'antd'複製代碼
在使用該 Plugin 以後會被轉換成:node
import Button from 'antd/lib/button'複製代碼
在一個沒有使用 antd 所有組件的項目裏,這樣作能夠明顯減小打包後的代碼體積。
但是,若是你在一個沒有使用 Babel 的 TypeScript 項目裏,想要實現相似的功能,該怎麼辦呢?git
這就要用到本文的主角:custom transformation,這是從 TypeScript@2.3 開始引入的新能力,他讓咱們能夠部分修改 TS 從源碼轉換成的語法樹,從而控制生成的 JavaScript 代碼,最終完成上述的轉換。github
先讓咱們從 TS 中代碼語法樹的樣子提及。typescript
AST 是爲了方便計算機理解源代碼、用於表達源代碼語法結構的樹狀結構,由稱做節點(Node)的數據結構組成。babel
例如:前端工程師
const name: string = 'Tom'複製代碼
上面這段代碼在 TS 會解析成下圖所示的 AST:antd
確切來講,上圖其實是語法樹而不是抽象語法樹,由於節點裏面仍然包含了「冒號」等多餘信息,還不夠「抽象」,可是,由於在以後處理的過程當中實際面對的就是這樣的語法樹,所以在這裏不作嚴格的區分。數據結構
TS 中全部 AST 的根節點都是 SourceFile,顧名思義,這是一個附加了源文件信息的 AST 節點(Node)。編輯器
源碼中只有一個變量聲明語句,該聲明生成了如下結構:
在 TypeScript/typescript.d.ts 源碼中,用枚舉類型 SyntaxKind 定義了全部的 AST 節點類型,到目前爲止近 300 個,能夠看出來 AST 的樹形結構很是得精確細緻,想手動分析記憶比較困難,能夠藉助 AST explorer 這個可視化工具幫助理解代碼的 AST 結構。
和 Babel 以及其餘編譯到 JavaScript 的工具相似,TS 的編譯流程包含如下三步:
解析 -> 轉換 -> 生成
包含了如下幾個關鍵部分:
圖示以下:
咱們的標題中所指的 transformer Plugin 就是在 Emitter 階段起做用。
tsc
命令不支持直接配置 transformer 的參數,你能夠手動引入 typescript 來本身編譯,固然,目前最方便的辦法是在 Webpack + ts-loader 的項目中,給 ts-loader 配置 getCustomTransformers 選項:
{
test: /\.tsx?$/,
loader: 'ts-loader',
options: {
... // other loader's options
getCustomTransformers: () => ({ before: [yourImportedTransformer] })
}
}複製代碼
詳見 ts-loader 文檔。
咱們的目標就是實現文章開頭代碼示例中的轉換:
// 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 以下:
能夠看出,咱們須要作的轉換有兩處:
那麼,該如何找到並替換對應的節點呢?
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 轉換,咱們的工做就完成了。
下面關注 updateImportNode 怎麼實現。
咱們已經拿到了 ImportDeclaration 節點,還記獲得底要幹什麼嗎?
爲了方便找到須要的節點,咱們對 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 { Button, Alert } from 'antd'
import { Button as Btn } from 'antd'
以上均可以在 AST explorer 的幫助下找到 AST 轉換先後的區別,而後按照本文介紹的流程實現。
做者:newraina
簡介:百姓網前端工程師。
原文連接:知乎專欄本文僅爲做者我的觀點,不表明百姓網立場。