造輪子利器:AST 與前端編譯

簡介

在計算機科學中,抽象語法樹是源代碼語法結構的一種抽象表示。它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構。之所以說語法是 “抽象” 的,是因爲這裏的語法並不會表示出真實語法中出現的每個細節。

——維基百科

在前端基建中,ast 可以說是必不可少的。對 ast 進行操作,其實就相當於對源代碼進行操作。

ast 的應用包括:

  1. 開發輔助:eslint、prettier、ts 檢查

  2. 代碼變更:壓縮、混淆、css modules

  3. 代碼轉換:jsx、vue、ts 轉換爲 js

ast 的生成

通過詞法分析和語法分析,可以得出一顆 ast。

  1. 詞法分析

詞法分析的過程是將代碼餵給有限狀態機,結果是將代碼單詞轉換爲令牌(token),一個 token 包含的信息包括其的種類、屬性值等。

例如將 const a = 1 + 1 轉換爲 token 的話,結果大概如下

[
  {type: 關鍵字, value: const}, 
  {type: 標識符, value: a},
  {type: 賦值操作符, value: =},
  {type: 常數, value: 1},
  {type: 運算符, value: +}, 
  {type: 常數, value: 1},
]
  1. 語法分析

面對一串代碼,先通過詞法分析,獲得第一個 token,爲其建立一個 ast 節點,此時的 ast 節點的屬性以及子節點都不完整。

爲了補充這些缺少的部分,接下來移動到下一個單詞,生成 token,並且將其轉換成子節點,添加進現有的 ast 中,然後重複這個 移動 & 生成 的遞歸的過程。

  1. 讀取 const,生成一個 VariableDeclaration 節點

  2. 讀取 a,新建 VariableDeclarator 節點

  3. 讀取 =

  4. 讀取 1,新建 NumericLiteral 節點

  5. 將 NumericLiteral 賦值給 VariableDeclarator 的 init 屬性

  6. 將 VariableDeclarator 賦值給 VariableDeclaration 的 declaration 屬性

前端編譯

隨着前端技術和理念的不斷進步,湧現了各種新奇的代碼以及帶來了新的項目組織方式。

但在這些新技術中,有許多代碼不能在瀏覽器中直接執行,比如 typescript、jsx 等,這種情況下我們的項目就需要通過編譯、打包,將其轉換爲可以直接在瀏覽器中執行的代碼。

以 webpack 爲例,打包工具作用就是基於代碼的 import、export、require 構建依賴樹,將其做成一個或多個 bundles。它解決的是模塊化的問題,但它自帶的能力只能支持 javascript 以及 json 文件,而平時我們遇到的 ts、jsx、vue 文件,則需要先經過編譯工具的編譯。例如如果我們想用 webpack 對含有 ts 文件的項目進行打包,進行如下配置。

// webpack.config.js
const path = require('path');

module.exports = {
  // ...
  module: {
    rules: [{ 
      test: /.ts$/,
      use: 'babel-loader',
      options: {
            presets: [
              '@babel/typescript'
            ]
      }
    }],
  },
};

配置的含義則是:當 webpack 解析到. ts 文件時,先使用 babel-loader 進行轉換,再進行打包。

操作 ast 進行代碼編譯

編譯工具

常見的編譯工具有這幾種

  1. babel:目前最主流的編譯工具,使用 javascript 編寫。

  2. esbuild:使用 Go 語言開發的打包工具(也包含了編譯功能), 被 Vite 用於開發環境的編譯。

  3. swc:使用 rust 編寫的編譯工具。

在對外提供直接操作 ast 的能力上,babel 和 swc 使用的是訪問者模式,插件編寫上有較多的共通之處,最大的區別就是語言不同。esbuild 沒有對外提供直接操作 ast 的能力,但是可以通過接入其他的編譯工具達到操作 ast 的效果。

編譯過程

代碼編譯的過程分爲三步,接(parse)、化(transform)、發(generate)

parse 的過程則是上文中提到的,將代碼從字符串轉化爲樹狀結構的 ast。

transform 則是對 ast 節點進行遍歷,遍歷的過程中對 ast 進行修改。

generate 則是將被修改過的 ast,重新生成爲代碼。

編譯插件

一般我們提起 babel,就會想到是用來將新標準的 js 轉化爲兼容性更高的舊標準 js。

如果 babel 默認的編譯效果不能滿足我們的需求,那我們要如何插手編譯過程,將 ast 修改成我們想要的 ast 呢。此時就需要用到 babel plugin,也就是 babel 插件。

就如同上文中的配置

const path = require('path');

module.exports = {
  module: {
    rules: [{ 
      test: /.ts$/,
      use: 'babel-loader',
      options: {
            presets: [
              // presets也就是已經配置好的插件集合,也就是“預設”
              '@babel/preset-typescript'
            ]
      }
    }],
  },
};

除了以上將 typescript 轉換爲 javscript 的插件外,日常中我們還會用到許多其他的插件 / 預設,例如

  1. @babel/react 轉換 react 文件

  2. react-refresh/babel 熱刷新

  3. babel-plugin-react-css-modules css 模塊化 避免樣式污染

  4. istanbul 收集代碼覆蓋率

  5. ......

Hello plugin!

babel 在將代碼轉換爲 ast 後,會對 ast 進行遍歷,在遍歷時,會應用插件所提供的訪問者對相應的 ast 節點進行訪問,以達到修改 ast 的目的。

我們在編寫插件時,首先我們需要知道我們想要訪問的 ast 節點對應的類型是什麼。假如我們要對函數類型的 ast 節點進行修改,先來看看這一段代碼經過 babel 的轉換以後會生成什麼樣的 ast。

https://astexplorer.net/

function a() {};
const b = function() {
}
const c = () ={};

ast:(刪除部分結構後)

 [
    {
        "type""FunctionDeclaration",
        "id"{
            "type""Identifier",
           
            "name""a"
        }
    },
    {
        "type""VariableDeclaration",
        "declarations"[
            {
                "type""VariableDeclarator",
                "id"{
                    "type""Identifier",
                    "name""b"
                },
                "init"{
                    "type""FunctionExpression",
                }
            }
        ],
        "kind""const"
    },
    {
        "type""VariableDeclaration",
        "declarations"[
            {
                "type""VariableDeclarator",
                "id"{
                    "type""Identifier",
                    "name""c"
                },
                "init"{
                    "type""ArrowFunctionExpression",
                }
            }
        ],
        "kind""const"
    }
]

新建 my-plugin.js,在其中編寫如下代碼,對外暴露出訪問者,babel 在遍歷到對應節點時會調用相應的訪問者。

// my-plugin.js
module.exports = () =({
    visitor: {
        // 對一種ast節點進行訪問
        FunctionDeclaration: {
            enter: enterFunction,
            // 在babel對ast進行深度優先遍歷時,
            // 我們有enter和exit兩次機會訪問同一個ast節點。 
            exit: exitFunction
        },
        // 對多種ast節點進行訪問
        "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression"{
            enter: enterFunction
        }
        // 使用“別名”進行訪問
        Function: enterFunction
    }
})

function enterFunction() {
    console.log('hello plugin!')
};
function exitFunction() {};

接入插件

// .babelrc
{
    "plugins"[
        './my-plugin.js'
    ]
}

實踐

接下來我們來通過編寫一個完整的 babel 插件來看看如何對 ast 進行修改:打印出函數的執行時間

代碼:

async function a() {
    function b() {
        for (let i = 0; i < 10000000; i += Math.random()) {}
    }
    b();
    await new Promise(resolve ={
        setTimeout(resolve, 1000);
    })
}

運行效果:

b cost: 219 // 函數b耗時219毫秒
anonymous cost: 0 // promise中的匿名函數耗時0毫秒
a cost: 1222 // 函數a耗時1222毫秒

實現思路:在函數的第一行,插入一個 ast 節點,定義一個變量記錄剛開始執行函數的時間。在函數的的最後一行,以及 return 時,打印出當前時間與開始時間的差。

新增 & 插入節點

除了手寫一個 ast 節點以外我們可以通過兩種 babel 爲我們提供的輔助工具生成 ast 節點

  1. @babel/types

一個工具集,可以用來新建、校驗節點等。

在這裏我們需要新建一個 var fnName_start_time = Date.now()的 ast 節點。

import * as t from 'babel-types';

function functionEnter(path, state) {
    const node = path.node;
    const fnName = node.name || node.id?.name || 'anonymous';
    // 新建一個變量聲明
    const ast = t.variableDeclarator(
        // 變量名
        t.identifier(`${fnName}_start_time`),
        // Date.now()
        t.callExpression(
            t.memberExpression(
                t.identifier('Date'),
                t.identifier('now')
            ),
            // 參數爲空
            []
        )
    );
}
  1. @babel/template

通過模板的方式,可以直接將代碼轉換成 ast,也可以替換掉模版中的節點,方便快捷。

import template from "babel-template";

const varName = `${fnName}_start_time`;
// 直接通過代碼生成
const ast = template(`const ${varName} = Date.now()`)();
// 或者
const ast = template('const fnName = Date.now()')({
    fnName: t.identifier(varName)
})

在生成了我們想要的 ast 節點後,我們可以將其插入到我們現有的節點中。

function functionEnter(path, state) {
    const node = path.node;
    const fnName = node.name || node.id?.name || 'anonymous';
    const varName = `${fnName}_start_time`;
    const start = template(`const ${varName} = Date.now()`)();
    const end = template(
        `console.log('${fnName} cost:', Date.now() - ${varName})`
    )();
    
    if (!node.body) {
        return;
    }
    // 插入到容器中,函數的第一行添加const fnName_start_time = Date.now()
    path.get('body').unshiftContainer('body', start);
    path.get('body').pushContainer('body', end);
}

module.exports = () =({
    visitor: {
        Function: enterFunction
    }
})

主動遍歷 & 停止遍歷 & 狀態

雖然我們在函數的第一行和最後一行添加了相應的代碼,但是還是不能完整的實現我們需要的功能——如果函數在執行最後一行之前進行了 return,則不能打印出耗時數據。

找出該函數進行 return 的 ast 節點,在 return 之前,先把函數的耗時打印出來。

function a() {
    if (Math.random() > 0.5) {
        // 需要逮出來的return
        return 'a';
    }
    function b() {
        // 需要跳過的return
        return 'b';
    }
}

通過主動遍歷的方法,我們把 returnStatement 的訪問者放到 Function 的訪問者中。

當我們進行主動遍歷時,需要跳過子節點中的函數節點的遍歷,因爲我們的目的只是在遍歷函數 a 節點時,訪問其 return,而不想去修改子函數節點的 return。

function functionEnter(path, state) {
    // 主動遍歷
    path.traverse({
        // 訪問遍歷到子函數,則跳過子函數及其的子節點遍歷
        Function(innerPath) {
            innerPath.skip();
        },
        // 訪問類型爲ReturnStatement的子節點
        ReturnStatement: returnEnter
        // 傳遞狀態
    }{ fnName })
}

function returnEnter(path, state) {
    // 讀取狀態
    const { fnName } = state;

    // 代碼爲resutn xxx; 新建 const fnName_result = xxx 的節點
    const resultVar = t.identifier(`${fnName}_result`);
    const returnResult = template(`const RESULT_VAR = RESULT`)({
        RESULT_VAR: resultVar,
        RESULT: path.node.argument || t.identifier('undefined')
    })

    // 插入兄弟節點
    path.insertBefore(returnResult);
    
    // 修改return xxx爲
    // return (console.log('耗時'), fnName_result)
    const varName = `${fnName}_start_time`;
    const end = template(
        `console.log('${fnName} cost:', Date.now() - ${varName})`
    )();
    const ast = t.sequenceExpression([
        end.expression,
        resultVar
    ]);
    path.node.argument = ast;
}

最終效果

// 原代碼
function a() {
    function b() {
        for (let i = 0; i < 10000000; i += Math.random()) {}
        function c() {
            for (let i = 0; i < 10000000; i += Math.random()) {}
        }
        return c();
    }
    b();
    for (let i = 0; i < 10000000; i += Math.random()) {}
}
// 經過babel編譯的代碼
function a() {
  var a_start_time = Date.now();
  function b() {
    var b_start_time = Date.now();
    for (var i = 0; i < 10000000; i += Math.random()) {}
    function c() {
      var c_start_time = Date.now();
      for (var _i = 0; _i < 10000000; _i += Math.random()) {}
      console.log('c cost:', Date.now() - c_start_time);
    }
    var b_result = c();
    return console.log('b cost:', Date.now() - b_start_time), b_result;
    console.log('b cost:', Date.now() - b_start_time);
  }
  b();
  for (var i = 0; i < 10000000; i += Math.random()) {}
  console.log('a cost:', Date.now() - a_start_time);
}
// 運行後控制檯打印結果
c cost: 290
b cost: 603
a cost: 895

歡迎關注公衆號 ELab 團隊 收貨大廠一手好文章~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/tDIIZUV4sDZ0lkPX0FPAHQ