🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

把程式碼變成“可改的樹”:一文讀懂前端 AST 的原理與實戰

image

把程式碼變成“可改的樹”:一文讀懂前端 AST 的原理與實戰

前言:什麼是 AST(抽象語法樹)?

在現代前端開發中,AST(Abstract Syntax Tree,抽象語法樹)指的是“用樹來表達一段程式碼的語法結構”。通俗理解:把程式碼先讀懂、拆解成一個個語法“節點”,再把這些節點按層級連成一棵樹,後續工具就能“對樹動手術”。

AST 的用途非常廣,是許多工具的基礎:

  • Babel:把新語法轉成舊語法,或做各類程式碼改寫
  • ESLint:根據規則檢查/修復程式碼問題
  • Prettier:根據統一風格重新排版程式碼
  • 以及更多編譯、打包、重構、分析類工具

🌰 舉一個特別形象的例子:小時候學英語的時候,老師經常會告訴我們在閱讀長難句的時候,需要先找到主謂賓,然後再去理解句子中的其他成分。

在程式碼的世界裡,解析 AST 就有點像分析句子的“主謂賓定狀補”:先理清主幹再看細節。有了 AST,其他前端工具能準確理解程式碼結構,從而實現重構、優化、自動修復,甚至生成新程式碼。

例如這段程式碼:

const sum = 1 + 2;

AST(簡化後)會描述出:

  • VariableDeclaration(這是一個變數聲明)
    • Identifier: sum(定義的變數名是 sum)
    • BinaryExpression: +(賦值的內容是一個二元表達式)
    • Literal: 1(表達式的左值)
    • Literal: 2(表達式的右值)

image

Parse:解析程式碼生成 AST(抽象語法樹)

實際上,我們編寫的程式碼都是以字串的形式儲存在檔案中的,而我們要想讓計算機理解這些程式碼,就需要將這些程式碼轉換成計算機能夠理解的資料結構,這個過程就是解析程式碼生成 AST(抽象語法樹)。

從字串到解析出抽象語法樹,中間會經歷兩個步驟:詞法分析(Lexical Analysis)和語法分析(Syntax Analysis)。

image

詞法分析(Lexical Analysis)

詞法分析(Lexical Analysis)是編譯器的第一步,它把源碼從“字元流”切分成有意義的最小單元:Token(記號)。

簡單理解:

  • 把連續的字元切成有意義的小塊,這些小塊就叫 Token。
  • 每個 Token 通常包含:type(類型,如 Identifier 標識符、Punctuator 符號等)、value(字面值)、以及位置信息(start/endloc)。
  • 这一階段只做“切詞”,不建立層級結構(所以不知道誰包含誰)。

以簡單的 n * n 程式碼舉例,最終會形成如下 token 列表:

[
  { type: { ... }, value: "n", start: 0, end: 1, loc: { ... } },
  { type: { ... }, value: "*", start: 2, end: 3, loc: { ... } },
  { type: { ... }, value: "n", start: 4, end: 5, loc: { ... } },
]

詞法分析僅僅是編譯的第一步,通常情況下,開發者不會直接使用詞法分析的結果,因為僅僅將字串轉換成 token 還不夠,我們還需要將 token 轉換成抽象語法樹。

語法分析(Syntax Analysis)

語法分析(Syntax Analysis)是編譯器的第二步,它把“線性的 token 列表”組裝成“有父子層級的抽象語法樹(AST)”。

現代前端開發中,能夠實現將源碼解析為抽象語法樹的工具有很多:Babel、Acorn、Espree、Esprima 等。本文將介紹如何使用 Espree 來實現將源碼解析為抽象語法樹。

Espree 提供了 parse 方法,可以傳入源碼與可選配置,然後返回 AST:

const espree = require("espree");
const ast = espree.parse("var a = 1;");
console.log("🚀 ~ ast:", ast);
🚀 ~ ast: Node {
  type: 'Program',
  start: 0,
  end: 10,
  body: [
    Node {
      type: 'VariableDeclaration',
      start: 0,
      end: 10,
      declarations: [
        {
          type: 'VariableDeclarator',
          start: 4,
          end: 9,
          id: Node { type: 'Identifier', start: 4, end: 5, name: 'a' },
          init: Node { type: 'Literal', start: 8, end: 9, value: 1, raw: '1' }
        }
      ],
      kind: 'var'
    }
  ],
  sourceType: 'script'
}
  • Program:整段程式碼的“根節點”(最外層容器)。body 裡按順序放著每一條語句。
  • VariableDeclaration:一條“變數聲明”語句,這裡的 kindvar(也可能是 letconst)。
  • declarations:聲明列表。因為 JS 允許 var a = 1, b = 2; 這樣的多聲明,所以這裡是陣列。
  • VariableDeclarator:一次具體的“聲明”。它有兩個關鍵子節點:
    • id:被聲明的變數是誰(這裡是一個 Identifier,名字為 a)。
    • init:這個變數被賦的初始值(這裡是一個 Literal,數值 1)。
  • Identifier:標識符節點,表示變數名、函數名等(此處為 a)。
  • Literal:字面量節點,表示具體的值(此處為 1)。
  • start/end:在源碼字串中的起止位置(基於字元索引),有助於錯誤提示、高亮、程式碼修改定位等。
  • sourceType:腳本類型,script 表示是 CommonJS 模塊,module 表示是 ES Module。

把上面的結構“壓扁”成一棵更直觀的小樹,可以這樣理解:

Program
└── VariableDeclaration (kind: var)
    └── VariableDeclarator
        ├── id: Identifier (name: a)
        └── init: Literal (value: 1)

需要特別注意的是:Espree 預設支持 ES5 語法,如果需要支持 ES6 語法,需要傳入 ecmaVersion 參數,指定 ES6 語法。

ESTree 規範 與 AST 調試工具

在上文我們提到過,能夠實現將源碼解析為抽象語法樹的工具有很多:Babel、Acorn、Espree、Esprima 等。我們轉為 AST 的目的是最終要使用 AST 進行程式碼的轉換,但是如何能確保 AST 的規範性呢?

簡單來說:不同解析器如果各自定義一套“樹的長相”,後續處理的工具就沒法通用。為了解決“同一種語言、不同解析器產出的 AST 長得不一樣”的問題,於是就制定了接口規範 — ESTree

在上文我們使用 Espree 打印了 AST。可能有些同學會有這樣的疑問:不清楚這些“節點類型”從何而來、去哪查?ESTree 定義了常見節點的命名、字段和層級關係,可參考文檔:github.com/estree/estr…

image

與此同時,我們也可以使用 AST 調試工具 來快速查看 AST 結構,比如 AST Explorerastexplorer.net)。 在實際開發中,我們會經常使用 AST Explorer 來查看 AST 結構,以便於我們更好地理解程式碼的結構。

Traverse:AST 遍歷

目前,我們已經得到了一棵 AST 樹結構,但這還遠遠不夠,我們還需要對 AST 樹進行遍歷,以便於我們更好地理解程式碼的結構,甚至是對 AST 樹的特定節點進行修改。

遍歷方式:深度優先

AST 遍歷採用的是深度優先遍歷,簡單說就是:先一頭扎到底,走不通了再回頭看其他分支。我們用一個實際例子來理解:

function square(n) {
  return n * n;
}

這段程式碼對應的 AST(簡化版)長這樣:

{
  type: "FunctionDeclaration",
  id: {
    type: "Identifier",
    name: "square"
  },
  params: [{
    type: "Identifier",
    name: "n"
  }],
  body: {
    type: "BlockStatement",
    body: [{
      type: "ReturnStatement",
      argument: {
        type: "BinaryExpression",
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}

遍歷的過程

我們從最外層的 FunctionDeclaration(函數聲明)開始,它有三個重要屬性:id(函數名)、params(參數)、body(函數體)。然後就像剝洋蔥一樣,一層層往裡走。下圖清晰展示了 AST 結構與遍歷的過程:

image

首先看 id 屬性—函數名id 是一個 Identifier 節點,記錄著函數名叫 square。這個節點沒有子節點了,所以看完就繼續往下。

接著看 params 屬性—參數params 是個陣列,裡面有一個 Identifier 節點,代表參數 n

然後進入 body —函數體body 是個 BlockStatement(塊語句節點),也就是 {} 包裹的部分。它自己也有個 body 屬性,這個屬性下裝著函數體內的語句。

深入到 BlockStatement 的 body 屬性:陣列裡有個 ReturnStatement 節點,它的 argument(返回值)指向一個 BinaryExpression(二元表達式節點),也就是 n * n 這個乘法運算。

最後到達葉子節點BinaryExpressionoperator(操作符 *)、left(左邊的 n)和 right(右邊的 n)。注意 operator 只是個普通的字串值,不是節點;而 leftright 才是真正的 Identifier 節點。

節點 vs 屬性:新手容易混淆的點(我剛開始的時候也很懵)

很多初學者會困惑:節點和屬性到底是什麼關係?

可以這樣理解:只有帶 type 字段的物件才是"節點"。比如 { type: "Identifier", name: "n" } 是一個節點,而 idparamsbody 這些只是"屬性名",它們的才可能是節點。

舉個例子:FunctionDeclaration 這個節點下有 idparamsbody 三個屬性,這三個屬性名本身不是節點,但它們的值(Identifier、陣列裡的 IdentifierBlockStatement)都是節點。

所以遍歷 AST 的過程,就是沿著這些屬性(邊),不斷訪問子節點(頂點),直到走遍整棵樹。

使用 Estraverse 遍歷 AST

手工遞歸遍歷 AST 太麻煩,我們可以借助現成的工具庫 Estraverse 來幫我們"走遍"整棵樹。

Estraverse 的核心概念:進入(enter)與離開(leave)

  • enter:第一次訪問到這個節點時觸發
  • leave:處理完這個節點的所有子節點、準備往回走時觸發

這種設計讓我們可以在"進入時"做一些準備工作,在"離開時"做一些收尾工作,非常靈活。

遍歷上文的 AST

Estraverse 提供了 traverse 方法,可以傳入 AST 樹和遍歷回調函數。回調函數的參數是當前遍歷到的節點。

我們繼續用剛才 function square(n) { return n * n; } 的例子,用 Estraverse 遍歷並打印每個節點的類型:

const espree = require("espree");
const estraverse = require("estraverse");

const ast = espree.parse(`function square(n) {
  return n * n;
}`);

estraverse.traverse(ast, {
  enter: function (node) {
    console.log("🚀 ~ enter: ~ node.type:", node.type);
  },
  leave: function (node) {
    console.log("🚀 ~ leave: ~ node.type:", node.type);
  },
});

運行後的輸出是這樣的:

🚀 ~ enter: ~ node.type: Program
🚀 ~ enter: ~ node.type: FunctionDeclaration
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: BlockStatement
🚀 ~ enter: ~ node.type: ReturnStatement
🚀 ~ enter: ~ node.type: BinaryExpression
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ enter: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: Identifier
🚀 ~ leave: ~ node.type: BinaryExpression
🚀 ~ leave: ~ node.type: ReturnStatement
🚀 ~ leave: ~ node.type: BlockStatement
🚀 ~ leave: ~ node.type: FunctionDeclaration
🚀 ~ leave: ~ node.type: Program

這也正好符合我們上文分析的 AST 結構與遍歷的過程。透過這種方式,我們就能訪問到 AST 中的每一個節點,做統計、收集資訊、分析程式碼結構等各種操作。

如何修改 AST 節點?

光是看還不夠,很多時候我們需要改。Estraverse 提供了 replace 方法,可以在遍歷的同時修改節點。但是生成的內容仍然是一棵 AST 樹,我們還需要借助 escodegen 庫將 AST 樹轉回程式碼(這裡就不進行過多講解)。比如我們想把程式碼中的乘法 * 改成加法 +

const espree = require("espree");
const estraverse = require("estraverse");
const escodegen = require("escodegen"); // 用於將 AST 轉回程式碼

const code = `function square(n) {
  return n * n;
}`;

const ast = espree.parse(code);

// 遍歷並修改 AST
estraverse.replace(ast, {
  enter: function (node) {
    // 找到二元表達式節點,且操作符是 *
    if (node.type === "BinaryExpression" && node.operator === "*") {
      node.operator = "+"; // 改成加法
    }
  },
});

const newCode = escodegen.generate(ast);
console.log("修改後的程式碼:", newCode);

透過上述程式碼,我們成功把 n * n 改成了 n + n。實際上,這就是 AST 轉換的基本原理:解析 → 遍歷 → 修改 → 生成新程式碼。Babel、ESLint 自動修復、程式碼重構工具,本質上都是在做這件事。

修改後的程式碼: function square(n) {
  return n + n;
}

總結

AST 在現代前端開發工具鏈中扮演著非常重要的角色,是程式碼檢查、程式碼格式化等工具的基礎。本文講解了 AST 抽象語法樹的 解析、遍歷與編輯,幫助大家從編譯原理的角度逐步理解抽象語法樹。

我們選擇的解析和遍歷庫是 espree 和 estraverse,相比於 Babel 等提供了更多上層封裝的庫而言,它們的返回內容更簡潔,使用場景更加通用,但實際上由於很多解析庫都遵循 ESTree 規範,所以使用方式和結果都大同小異。對於剛開始上手的讀者來說,讀懂 AST 結構是一件比較困難的事情,在這裡推薦大家多使用 AST Explorer 來對比查看簡單程式碼的 AST 結構來更好地理解抽象語法樹。


原文出處:https://juejin.cn/post/7556549862563004426


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝19   💬9   ❤️5
669
🥈
我愛JS
📝4   💬14   ❤️7
257
🥉
御魂
💬1  
3
#5
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付