使用ts-compiler來遍歷AST處理你的代碼

前言

本文在於給初次瞭解 ts 編譯器的前端同窗作一個初步引導,經過一系列由淺入深的示例後可以掌握 ts 編譯器的基本使用,包括 ast 遍歷,transform 函數編寫、表達式節點建立等,同時對 ts-loader、ts-node 原理作一個簡要分析。本文側重點在 transform 函數的編寫上。前端

1. 認識 ts compiler 之 createProgram 和 transpileModule

這兩個函數能夠說是 ts compiler 最主要的兩個 api,它們均可以將 Typescript 代碼轉換成 JavaScript。node

transpileModule

區別在於 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 是怎麼工做的?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 接口。算法

在瀏覽器端使用 typescript compiler

有了這個環境分離基礎,那麼就能夠實如今瀏覽器端運行 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,
})
複製代碼

ts-node 原理

常用 typescript 來開發的前端同窗確定用過 ts-node 來執行 ts 文件,有沒有想過它的底層原理?api

有了以上 ts 編譯代碼的基礎,ts-node 作的事情應該就是:

讀取 ts 文件,使用 transpileModule 函數轉成 js,而後執行 eval
複製代碼
  1. 可是 eval 函數它不安全,它能夠修改全局上下文任何內容,在 nodejs 中 vm 模塊提供了更安全的 runInThisContext 函數,能夠控制可訪問範圍。

  2. 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)
  }
}
複製代碼

2. 認識 ts compiler 之 ast visitor、transformer

visitor 用來遍歷 ast 樹,transformer 用來對 ast 樹作轉換。在 ts 編譯器 api 中區分爲 forEachChild、visitEachChild,相似 forEach 和 map 的區別。

使用 visitor 遍歷 ast

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 庫來獲取全部文件。

使用 transformer 轉換 ast

在 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)],
  },
})
複製代碼

ts-loader 使用 transform 函數

上面已經瞭解瞭如何編寫一個 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],
          }),
        },
      },
    ],
  },
}
複製代碼

總結

  1. 使用 ts-compiler 能夠更準確地分析代碼,例如提取 imports 等。若是本身寫正則表達式來匹配代碼,容易漏掉 case,並且語法會隨語言標準變化。

  2. 能夠給 ts-compiler 編寫 transform 插件函數,實現自定義語法糖。


以上代碼開源在 github 上:

ts-compiler

相關文章
相關標籤/搜索