本文在於給初次瞭解 ts 編譯器的前端同窗作一個初步引導,經過一系列由淺入深的示例後可以掌握 ts 編譯器的基本使用,包括 ast 遍歷,transform 函數編寫、表達式節點建立等,同時對 ts-loader、ts-node 原理作一個簡要分析。本文側重點在 transform 函數的編寫上。前端
這兩個函數能夠說是 ts compiler 最主要的兩個 api,它們均可以將 Typescript 代碼轉換成 JavaScript。node
區別在於 transpileModule 只處理文本而且不會進行類型檢查,它使用起來就是這樣:webpack
import ts from 'typescript'
// 直接生成對應js代碼。即使ts類型有錯誤,它仍然可以生成代碼。
const js = ts.transpileModule('/** 你的typescript代碼 **/').outputText
複製代碼
到這裏有經驗的前端同窗已經猜到 transpileOnly 的底層原理了,以前你可能想過全局@ts-ignoregit
這裏能夠給出一個 ts-loader 的最小實現(不保證能 work):github
import ts from 'typescript'
// 簡易版ts-loader
function loader(this: webpack.LoaderContext<LoaderOptions>, contents: string) {
const callback = this.async()
// transpileOnly 只作類型擦除
const result = ts.transpileModule(contents, {
compilerOptions: {},
})
// 返回生成的js
callback(null, result.outputText, null)
}
複製代碼
那麼 createProgram 是怎麼工做的?createProgram 會掃描 typescript 文件的 import 等語句,遍歷每一個 ts 文件進行編譯,同時會進行類型檢查,將類型異常拋出並終止編譯過程。它使用起來比 transpileModule 要複雜許多:web
import ts from 'typescript/lib/typescript'
const compilerOptions = {}
// 建立一個文件讀寫服務(依賴nodejs)
const compilerHost = ts.createCompilerHost(compilerOptions)
// 建立編譯程序
const program = ts.createProgram(['./entry.ts'], compilerOptions, compilerHost)
// 使用文件服務輸出文件
program.emit()
複製代碼
這其實就是 tsc 命令底層作的事情。這裏會發現 ts compiler 分出來一個 compilerHost,這個 compilerHost 是分裝了文件寫入、讀取等操做。固然這裏能夠對 host 進行劫持修改,攔截文件的輸出:正則表達式
const compilerHost = ts.createCompilerHost(compilerOptions)
// 攔截獲取文件
const originalGetSourceFile = compilerHost.getSourceFile
compilerHost.getSourceFile = fileName => {
// 這裏能夠作相似webpack alias的事情
console.log(fileName)
return originalGetSourceFile.call(compilerHost, fileName)
}
// 攔截寫入文件
compilerHost.writeFile = (fileName, data) => {
// data就是編譯生成的js代碼
console.log(fileName, data)
}
複製代碼
compilerHost 實現了將 compiler 和環境分離,compiler 自己只是一個編譯器的實現,文件寫入等操做能夠做爲一個 host 接口。算法
有了這個環境分離基礎,那麼就能夠實如今瀏覽器端運行 ts compiler!由於 ts compiler 自己編譯後也是 js,只須要提供一個瀏覽器端的 compilerHost 就能夠,typescript 官方提供了一個虛擬文件服務包@typescript/vfs 提供瀏覽器端兼容的 fs 服務:typescript
import ts from 'typescript'
import tsvfs from '@typescript/vfs' // 虛擬文件服務
import lzstring from 'lz-string' // 一個壓縮算法
// 從cdn建立上下文,包含了ts lib的類型庫,從cdn拉取
const fsMap = await tsvfs.createDefaultMapFromCDN(
compilerOptions,
ts.version,
true,
ts,
lzstring
)
// 能夠設置一個虛擬的文件,文件名index.ts,文件內容第二個參數
fsMap.set('index.ts', '/** typescript 代碼 **/')
const system = tsvfs.createSystem(fsMap)
// host是ts編譯器將文件操做隔離出來的部分
// 這裏能夠建立一個虛擬的文件服務,不依賴nodejs,在瀏覽器中可用
const host = tsvfs.createVirtualCompilerHost(system, compilerOptions, ts)
// 建立編譯程序
const program = ts.createProgram({
rootNames: [...fsMap.keys()],
options: compilerOptions,
host: host.compilerHost,
})
複製代碼
常用 typescript 來開發的前端同窗確定用過 ts-node 來執行 ts 文件,有沒有想過它的底層原理?api
有了以上 ts 編譯代碼的基礎,ts-node 作的事情應該就是:
讀取 ts 文件,使用 transpileModule 函數轉成 js,而後執行 eval
複製代碼
可是 eval 函數它不安全,它能夠修改全局上下文任何內容,在 nodejs 中 vm 模塊提供了更安全的 runInThisContext 函數,能夠控制可訪問範圍。
transpileModule 不會處理 import 語句來加載模塊,ts-node 借用了 nodejs 自帶的模塊加載機制,重寫了 require 函數加載:
import ts from 'typescript'
function registerExtension() {
// require函數編譯執行並加載ts文件
// require到的是js執行返回的exports
const old = require.extensions['.ts']
// 定義ts加載函數
require.extensions['.ts'] = function (m: any, filename) {
// module自帶的編譯方法
// 能夠執行js獲取exports變量,commonjs規範
const _compile = m._compile
// Module.prototype._compile方法,能夠對js文件進行編譯加載
// 可是翻了文檔並無指出,只有看nodejs源碼才知道
// https://github.com/nodejs/node/blob/da0ede1ad55a502a25b4139f58aab3fb1ee3bf3f/lib/internal/modules/cjs/loader.js#L1065
// https://github.com/nodejs/node/blob/da0ede1ad55a502a25b4139f58aab3fb1ee3bf3f/lib/internal/modules/cjs/loader.js#L1017
// 底層原理是runInThisContext
m._compile = function (code: string, fileName: string) {
// 使用ts compiler對ts文件進行編譯
const result = ts.transpileModule(code, {
compilerOptions: {},
})
// 使用默認的js編譯函數獲取返回值
return _compile.call(this, result, fileName)
}
return old(m, filename)
}
}
複製代碼
visitor 用來遍歷 ast 樹,transformer 用來對 ast 樹作轉換。在 ts 編譯器 api 中區分爲 forEachChild、visitEachChild,相似 forEach 和 map 的區別。
ts compiler 中的 ast 節點類型是 ts.Node。想要遍歷一段 ts 代碼的 ast,首先建立一個 sourceFile:
// 建立ast根結點
const rootNode = ts.createSourceFile(
`input.ts`,
`/** typescript代碼 **/`,
ts.ScriptTarget.ES2015,
/*setParentNodes */ true
)
複製代碼
ts 提供了 forEachChild 函數用來遍歷 ast 節點的子節點:
// 遍歷rootNode的子節點
ts.forEachChild(rootNode, node => {
// ast節點上有一個kind屬性表示類型
console.log(node.kind)
// 一般使用ts的類型守衛來區分節點類型:
// 例如判斷當前節點是不是import聲明
// node: ts.Node
if (ts.isImportDeclaration(node)) {
// node: ts.ImportDeclaration
// 節點上有一個getText方法用來打印節點文本內容,用於調試
console.log(node.getText())
}
})
複製代碼
forEachChild 只會向下遍歷一層,這裏須要遍歷整個 ast 樹,因此須要進行遞歸遍歷:
const traverse = (node: ts.Node) => {
console.log(node.getText())
// 遞歸遍歷每一個節點
ts.forEachChild(node, node => traverse(node))
}
複製代碼
有了以上 visitor 的基礎,就能夠實現分析代碼的 import 語句:
const traverse = (node: ts.Node) => {
if (ts.isImportDeclaration(node)) {
// 導入的包名或路徑 moduleSpecifier模塊標識
const library = node.moduleSpecifier.getText()
// 默認導入
const defaultImport = node.importClause?.name?.getText()
// 解構導入
const bindings = node.importClause?.namedBindings as ts.NamedImports
// names就是解構的變量名
const names = bindings.elements.map(item => item.getText())
}
// 遞歸遍歷
ts.forEachChild(node, node => traverse(node))
}
複製代碼
這樣就實現了單個文件的 import 獲取,若是須要獲取文件下全部代碼的 import 可使用 @nodelib/fs.walk 庫來獲取全部文件。
在 ts 編譯器編譯時,提供了一個 transform 接口用來修改 ast,createProgram 和 transpileModule 的 transform 接口以下:
// createProgram
const program = ts.createProgram(['input.ts'], options, compilerHost)
// 最後一個參數是 CustomTransformers
program.emit(undefined, undefined, undefined, undefined, transformers)
// transpileModule
// 第二個參數有transformers接口
ts.transpileModule(`/** typescript代碼 **/`, { transformers })
複製代碼
transformers 是一個對象,提供了 3 個生命週期鉤子,分別是編譯前、編譯時、編譯後:
ts.transpileModule(`/** typescript代碼 **/`, {
transformers: {
before: [], // before中的transformer能夠獲取到ts類型,能夠對ts類型ast進行操做
afterDeclarations: [],
after: [], // after中的transformer沒有ts類型,只能操做js代碼ast
},
})
複製代碼
有了 transformers 接口,就能夠編寫一個「簡單」的 transformer 了,首先是 visitEachChild 函數提供了 ast 修改能力:
export function visitNodes(node: ts.Node) {
// 遞歸,第二個函數參數返回值是ts.Node類型,會替換ast節點
// 能夠按 Array.prototype.map 理解
return ts.visitEachChild(node, childNode => visitNodes(childNode))
}
ts.transpileModule(`/** typescript代碼 **/`, {
transformers: {
// transform函數類型 高階函數
// transform :: TransformationContext -> ts.Node -> ts.Node
before: [context => node => visitNodes(node)],
},
})
複製代碼
在 visitNodes 遞歸函數中,能夠判斷節點類型和返回新節點替換,下面能夠實現一個「小需求」:
需求:利用編譯器實現一個註解功能,函數添加一個 jsDoc 註釋 @noexcept
,能夠在靜態編譯的時候自動添加 try catch,就像這樣:
/** * @noexcept */
function main (): number {
throw new Error()
}
main() // 不會異常,會將錯誤log出來
複製代碼
首先須要判斷節點是函數聲明節點:
// isFunctionDeclaration能夠判斷當前節點是函數聲明
if (ts.isFunctionDeclaration(node)) {
console.log(node.getText()) // 打印出函數定義完整內容,包括jsDoc註釋
}
複製代碼
而後獲取 jsDoc 的內容,判斷是否有@noexcept
:
if (ts.isFunctionDeclaration(node)) {
// getJSDocTags函數獲取到jsDoc上的tags
const enableNoexcept = !!ts.getJSDocTags(node).find(tag => {
// 判斷jsDoc註釋中是否有 @noexcept 註釋
return tag.tagName.escapedText === 'noexcept'
})
}
複製代碼
判斷函數使用了 @noexcept
註釋,下面就能夠對 ast 節點作修改了(用新節點替換)。
須要將函數體包裹一個 trycatch,首先獲取函數體:
if (ts.isFunctionDeclaration(node)) {
// 函數節點的屬性
node.decorators // 函數裝飾器
node.modifiers // 不知道是啥
node.asteriskToken // 不知道是啥
node.name // 函數名
node.typeParameters // 函數類型參數
node.parameters // 函數參數
node.type // 函數類型
node.body // 函數體 這個就是咱們須要的
// 示例:能夠建立一個一摸同樣的函數聲明節點clone:
return ts.factory.createFunctionDeclaration(
node.decorators,
node.modifiers,
node.asteriskToken,
node.name,
node.typeParameters,
node.parameters,
node.type,
node.body // 這裏修改一下就行了,加上try catch語句包裹
)
}
複製代碼
下面是重點,以 node.body 做爲 try 塊內容,建立一個 trycatch 語句
// 建立一個try catch語句
const tryStatement = ts.factory.createTryStatement(
node.body, // try塊內容。node.body是須要包裹的函數體內容,下面建立catch語句部分
// 建立一個catch
ts.factory.createCatchClause(
'error', // catch參數名
// catch塊內容
ts.factory.createBlock([])
),
undefined // finally就不建立了,只須要處理catch
)
/** * 上面的代碼作的事情就是下面這樣 * try { * 函數體 * } catch (error) { * 錯誤處理 * } */
複製代碼
可是 catch 語句一般是須要處理異常的,否則容易亂吞錯誤隱藏問題,這裏能夠給 catch 中添加一條 console.log 調用語句:
// 建立一個表達式語句
const consoleErrorStatement = ts.factory.createExpressionStatement(
// 建立一個函數調用表達式
ts.factory.createCallExpression(
// 建立調用語句,即console.log(error)
ts.factory.createIdentifier('console.log'), // 建立一個標識符(函數調用)
[], // 類型參數,無
[ts.factory.createIdentifier('error')] // 傳入參數
)
)
複製代碼
有了 try catch 語句和 consoleError 語句,下面就能夠組合起來,完整代碼:
// 這就是一個簡單的transform函數了
export const transformNoExcept = node => {
if (ts.isFunctionDeclaration(node)) {
const enable = !!ts.getJSDocTags(node).find(tag => {
return tag.tagName.escapedText === 'noexcept'
})
if (enable) {
return ts.factory.createFunctionDeclaration(
node.decorators,
node.modifiers,
node.asteriskToken,
node.name,
node.typeParameters,
node.parameters,
node.type,
ts.factory.createBlock([
ts.factory.createTryStatement(
node.body,
ts.factory.createCatchClause(
'error',
ts.factory.createBlock([
ts.factory.createExpressionStatement(
ts.factory.createCallExpression(
ts.factory.createIdentifier('console.log'),
[],
[ts.factory.createIdentifier('error')]
)
),
])
),
undefined
),
])
)
}
}
return node
}
複製代碼
給到 transpile 使用:
export function visitNodes(node: ts.Node) {
// 處理@noexcept註釋,添加tryccatch
const newNode = transformNoExcept(node)
if (node !== newNode) {
return newNode
}
return ts.visitEachChild(node, childNode => visitNodes(childNode))
}
ts.transpileModule(`/** typescript代碼 **/`, {
transformers: {
before: [context => node => visitNodes(node)],
},
})
複製代碼
上面已經瞭解瞭如何編寫一個 transform 函數,可是如何落地到實際項目呢?暴露 transform 接口的是 ts compiler API,而 tsc 和 tsconfig 並無給出接口。ts-loader 將 transform 接口暴露出來了:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.ts$/,
loader: 'ts-loader',
options: {
// getCustomTransformers的返回值就是transformers
getCustomTransformers: () => ({
// 這裏能夠設置本身的 transform函數
before: [context => node => node],
}),
},
},
],
},
}
複製代碼
使用 ts-compiler 能夠更準確地分析代碼,例如提取 imports 等。若是本身寫正則表達式來匹配代碼,容易漏掉 case,並且語法會隨語言標準變化。
能夠給 ts-compiler 編寫 transform 插件函數,實現自定義語法糖。
以上代碼開源在 github 上: