從Babel開始認識AST抽象語法樹

2023-01-10 12:01:02

前言

AST抽象語法樹想必大家都有聽過這個概念,但是不是隻停留在聽過這個層面呢。其實它對於程式設計來講是一個非常重要的概念,當然也包括前端,在很多地方都能看見AST抽象語法樹的影子,其中不乏有vue、react、babel、webpack、typeScript、eslint等。簡單來說但凡需要編譯的地方你基本都能發現AST的存在。

babel是用來將javascript高階語法編譯成瀏覽器能夠執行的語法,我們可以從babel出發來了解AST抽象語法樹。

如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新文章~

babel編譯流程

瞭解AST抽象語法樹之前我們先來簡單瞭解一下babel的編譯流程,以及AST在babel編譯過程中起到了什麼作用?

我這裡畫了張圖方便理解babel編譯的整個流程

  • parse: 用於將原始碼編譯成AST抽象語法樹
  • transform: 用於對AST抽象語法樹進行改造
  • generator: 用於將改造後的AST抽象語法樹轉換成目的碼

很明顯AST抽象語法樹在這裡充當了一箇中間人的身份,作用就是可以通過對AST的操作還達到原始碼到目的碼的轉換過程,這將會比暴力使用正則匹配要優雅的多。

AST抽象語法樹

在電腦科學中,抽象語法樹(Abstract Syntax Tree,AST) 是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構。

雖然在日常業務中我們可能很少會涉及到AST層面,但如果你想在babelwebpack等前端工程化上有所深度,AST將是你深入的基礎。

預覽AST

說了這麼多,那麼AST到底長什麼樣呢?

接下來我們可以通過工具AST Explorer來直觀的感受一下!

比如我們如下程式碼:

let fn = () => {
  console.log('前端南玖')
}

它最終生成的AST是這樣的:

  • AST抽象語法樹是原始碼語法結構的一種抽象表示
  • 每個包含type屬性的資料結構,都是一個AST節點
  • 它以樹狀的形式表現程式語言的語法結構,每個節點都表示原始碼中的一種結構

AST結構

為了統一ECMAScript標準的語法表達。社群中衍生出了ESTree Spec,是目前前端所遵循的一種語法表達標準。

節點型別

型別 說明
File 檔案 (頂層節點包含 Program)
Program 整個程式節點 (包含 body 屬性代表程式體)
Directive 指令 (例如 "use strict")
Comment 程式碼註釋
Statement 語句 (可獨立執行的語句)
Literal 字面量 (基本資料型別、複雜資料型別等值型別)
Identifier 識別符號 (變數名、屬性名、函數名、引數名等)
Declaration 宣告 (變數宣告、函數宣告、Import、Export 宣告等)
Specifier 關鍵字 (ImportSpecifier、ImportDefaultSpecifier、ImportNamespaceSpecifier、ExportSpecifier)
Expression 表示式

公共屬性

型別 說明
type AST 節點的型別
start 記錄該節點程式碼字串起始下標
end 記錄該節點程式碼字串結束下標
loc 內含 line、column 屬性,分別記錄開始結束的行列號
leadingComments 開始的註釋
innerComments 中間的註釋
trailingComments 結尾的註釋
extra 額外資訊

AST是如何生成的

一般來講生成AST抽象語法樹都需要javaScript解析器來完成

JavaScript解析器通常可以包含四個組成部分:

  • 詞法分析器(Lexical Analyser)
  • 語法解析器(Syntax Parser)
  • 位元組碼生成器(Bytecode generator)
  • 位元組碼直譯器(Bytecode interpreter)

詞法分析

這裡主要是對程式碼字串進行掃描,然後與定義好的 JavaScript 關鍵字元做比較,生成對應的Token。Token 是一個不可分割的最小單元。

詞法分析器裡,每個關鍵字是一個 Token ,每個識別符號是一個 Token,每個操作符是一個 Token,每個標點符號也都是一個 Token,詞法分析過程中不會關心單詞與單詞之間的關係.

除此之外,還會過濾掉源程式中的註釋和空白字元、換行符、空格、製表符等。最終,整個程式碼將被分割進一個tokens列表

javaScript中常見的token主要有:

關鍵字:var、let、const等
識別符號:沒有被引號括起來的連續字元,可能是一個變數,也可能是 if、else 這些關鍵字,又或者是 true、false 這些內建常數
運運算元: +、-、 *、/ 等
數位:像十六進位制,十進位制,八進位制以及科學表示式等
字串:變數的值等
空格:連續的空格,換行,縮排等
註釋:行註釋或塊註釋都是一個不可拆分的最小語法單元
標點:大括號、小括號、分號、冒號等

比如我們還是這段程式碼:

let fn = () => {
  console.log('前端南玖')
}

它在經過詞法分析後生成的token是這樣的:

工具:esprima

[
    {
        "type": "Keyword",
        "value": "let"
    },
    {
        "type": "Identifier",
        "value": "fn"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Punctuator",
        "value": "("
    },
    {
        "type": "Punctuator",
        "value": ")"
    },
    {
        "type": "Punctuator",
        "value": "=>"
    },
    {
        "type": "Punctuator",
        "value": "{"
    },
    {
        "type": "Identifier",
        "value": "console"
    },
    {
        "type": "Punctuator",
        "value": "."
    },
    {
        "type": "Identifier",
        "value": "log"
    },
    {
        "type": "Punctuator",
        "value": "("
    },
    {
        "type": "String",
        "value": "'前端南玖'"
    },
    {
        "type": "Punctuator",
        "value": ")"
    },
    {
        "type": "Punctuator",
        "value": "}"
    }
]

拆分出來的每個字元都是一個token

語法分析

這個過程也稱為解析,是將詞法分析產生的token按照某種給定的形式文法轉換成AST的過程。也就是把單詞組合成句子的過程。在轉換過程中會驗證語法,語法如果有錯的話,會丟擲語法錯誤。

還是上面那段程式碼,在經過語法分析後生成的AST是這樣的:

工具:AST Explorer

{
    "type": "VariableDeclaration",  // 節點型別: 變數宣告
    "declarations": [   // 宣告
      {
        "type": "VariableDeclarator",  
        "id": {
          "type": "Identifier",  // 識別符號
          "name": "fn"  // 變數名
        },
        "init": {
          "type": "ArrowFunctionExpression",    // 箭頭函數表示式
          "id": null,
          "generator": false,
          "async": false,
          "params": [],  // 函數引數
          "body": {  // 函數體
            "type": "BlockStatement",  // 語句塊
            "body": [   
              {
                "type": "ExpressionStatement",  // 表示式語句
                "expression": {
                  "type": "CallExpression", 
                  "callee": {
                    "type": "MemberExpression",
                    "object": {
                      "type": "Identifier",
                        "identifierName": "console"
                      },
                      "name": "console"
                    },
                    "computed": false,
                    "property": {
                      "type": "Identifier",
                      "name": "log"
                    }
                  },
                  "arguments": [  // 函數引數
                    {
                      "type": "StringLiteral",  // 字串
                      "extra": {
                        "rawValue": "前端南玖",
                        "raw": "'前端南玖'"
                      },
                      "value": "前端南玖"
                    }
                  ]
                }
            ],
            "directives": []
          }
        }
      }
    ],
    "kind": "let"    // 變數宣告型別
  }

在得到AST抽象語法樹之後,我們就可以通過改造AST語法樹來轉換成自己想要生成的目的碼。

常見的解析器

第一個用JavaScript編寫的符合EsTree規範的JavaScript的解析器,後續多個編譯器都是受它的影響

一個小巧、快速的 JavaScript 解析器,完全用 JavaScript 編寫

babel官方的解析器,最初fork於acorn,後來完全走向了自己的道路,從babylon改名之後,其構建的外掛體系非常強大

UglifyJS 是一個 JavaScript 解析器、縮小器、壓縮器和美化器工具包。

esbuild是用go編寫的下一代web打包工具,它擁有目前最快的打包記錄和壓縮記錄,snowpack和vite的也是使用它來做打包工具,為了追求卓越的效能,目前沒有將AST進行暴露,也無法修改AST,無法用作解析對應的JavaScript。

AST應用

瞭解完AST,你會發現我們可以用它做許多複雜的事情,我們先來利用@babel/core簡單實現一個移除console的外掛來感受一下吧。

這個其實就是找規律,你只要知道console語句在AST上是怎樣表現的就能夠通過這一特點精確找到所有的console語句並將其移出就好了。

  • 先來看下console語句的AST長什麼樣

很明顯它是一個表示式節點,所以我們只需要找到name為console的表示式節點刪除即可。

  • 編寫plugin
const babel  = require("@babel/core")
let originCode = `
    let fn = () => {
        const a = 1
        console.log('前端南玖')
        if(a) {
            console.log(a)
        }else {
            return false
        }
    }
`


let removeConsolePlugin = function() {
    return {
        // 存取器
        visitor: {
            CallExpression(path, state) {
                const { node } = path

                if(node?.callee?.object?.name === 'console') {
                    console.log('找到了console語句')
                    path.parentPath.remove()
                }
            }
        }
    }
}

const options = {
    plugins: [removeConsolePlugin()]
}
let res = babel.transformSync(originCode, options)

console.dir(res.code)

從執行結果來看,它找到了兩個console語句,並且都將它們移除了

這就是對AST的簡單應用,學會AST能做的遠不止這些像前端大部分比較高階的內容都能看到它的存在。後面會繼續更新Babel以及外掛的用法。

原文首發地址點這裡,歡迎大家關注公眾號 「前端南玖」,如果你想進前端交流群一起學習,請點這裡

我是南玖,我們下期見!!!