【你應該瞭解的】抽象語法樹AST

團隊:skFeTeam  本文做者:李世偉html

做爲前端程序員,webpack,rollup,babel,eslint這些是否是常常用到?他們是打包工具,代碼編譯工具,語法檢查工具。他們是如何實現的呢?本文介紹的抽象語法樹,就是他們用到的技術,是否是應該瞭解一下呢?前端

本文沒有晦澀難懂的理論,也沒有大段大段的代碼,徹底從零開始,小白閱讀也無任何障礙。經過本文的閱讀,您將會了解AST的基本原理以及使用方法。node

前言

什麼是抽象語法樹?webpack

  • AST(Abstract Syntax Tree)是源代碼的抽象語法結構樹狀表現形式。下面這張圖示意了一段JavaScript代碼的抽象語法樹的表現形式。

0.什麼是抽象語法樹.png

抽象語法樹有什麼用呢?git

  • IDE的錯誤提示、代碼格式化、代碼高亮、代碼自動補全等
  • JSLint、JSHint、ESLint對代碼錯誤或風格的檢查等
  • webpack、rollup進行代碼打包等
  • Babel 轉換 ES6 到 ES5 語法
  • 注入代碼統計單元測試覆蓋率

目錄

  • 1.AST解析器
  • 2.AST in Babel
  • 3.Demo with esprima
  • 4.思考題

1.AST解析器

1.1 JS Parser解析器

AST是如何生成的?程序員

  • 可以將JavaScript源碼轉化爲抽象語法樹(AST)的工具叫作JS Parser解析器。

JS Parser的解析過程包括兩部分github

  • 詞法分析(Lexical Analysis):將整個代碼字符串分割成最小語法單元數組
  • 語法分析(Syntax Analysis):在分詞基礎上創建分析語法單元之間的關係

1.JS_Parser的解析過程.png

常見的AST parserweb

  • 早期有uglifyjs和esprima
  • Espree,基於esprima,用於eslint
  • Acorn,號稱是相對於esprima性能更優,體積更小
  • Babylon,出自acorn,用於babel
  • Babel-eslint,babel團隊維護,用於配合使用ESLint

1.2 詞法分析(Lexical Analysis)

語法單元是被解析語法當中具有實際意義的最小單元,簡單的來理解就是天然語言中的詞語。chrome

Javascript 代碼中的語法單元主要包括如下這麼幾種:express

  • 關鍵字:例如 var、let、const等
  • 標識符:沒有被引號括起來的連續字符,多是一個變量,也多是 if、else 這些關鍵字,又或者是 true、false 這些內置常量
  • 運算符: +、-、 *、/ 等
  • 數字:像十六進制,十進制,八進制以及科學表達式等
  • 字符串:由於對計算機而言,字符串的內容會參與計算或顯示
  • 空格:連續的空格,換行,縮進等
  • 註釋:行註釋或塊註釋都是一個不可拆分的最小語法單元
  • 其餘:大括號、小括號、分號、冒號等

1.3 語法分析(Syntax Analysis)

組合分詞的結果,肯定詞語之間的關係,肯定詞語最終的表達含義,生成抽象語法樹。

1.4 示例

  • 以賦值語句爲例,使用esprima來解析:
var a = 1;
複製代碼
  • 詞法分析結果以下,能夠看到,分詞的結果是一個數組,每個元素都是一個最小的語法單元:
[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]
複製代碼
  • 語法分析結果以下,把分詞的結果按照相互的關係組成一個樹形結構:
{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "script"
}
複製代碼

1.5 工具網站

esprima/parser

  • 經典的JavaScript抽象語法樹解析器,網站提供的功能也很是豐富
  • 能夠在線查看分詞和抽象語法樹
  • Syntax展現抽象語法樹,Tokens展現分詞
    1.esprima-parser.png
  • 還提供了各類parse的性能比較,看起來Acorn的性能更優秀一點。
    1.各類parse的性能比較.png

AST Explorer

  • AST的可視化工具網站,可使用各類parse對代碼進行AST轉換
    1.AST-Explorer可視化工具.png

AST解析規範(The Estree Spec

  • 相同的JavaScript代碼,經過各類parser解析的AST結果都是同樣的,這是由於他們都參照了一樣的AST解析規範
  • The Estree Spec 規範是 Mozilla 的工程師給出的 SpiderMonkey 引擎輸出的 JavaScript AST 的規範文檔,也能夠參考:SpiderMonkey in MDN

2.AST in Babel

前面已經介紹了AST的內容,下面咱們來看看babel是如何使用AST的。

Babel的運行原理

Babel的工做過程通過三個階段,parse、transform、generate

  • parse階段,將源代碼轉換爲AST
  • transform階段,利用各類插件進行代碼轉換
  • generator階段,再利用代碼生成工具,將AST轉換成代碼

2.Babel的運行原理.png

Parse-解析

  • Babel 使用 @babel/parser 解析代碼,輸入的 js 代碼字符串根據 ESTree 規範生成 AST
  • Babel 使用的解析器是 babylon

Transform-轉換

  • 接收 AST 並對其進行遍歷,在此過程當中對節點進行添加、更新及移除等操做。也是Babel插件接入工做的部分。
  • Babel提供了@babel/traverse(遍歷)方法維護AST樹的總體狀態,方法的參數爲原始AST和自定義的轉換規則,返回結果爲轉換後的AST。

Generator-生成

  • 代碼生成步驟把最終(通過一系列轉換以後)的 AST 轉換成字符串形式的代碼,同時還會建立源碼映射(source maps)。
  • 遍歷整個 AST,而後構建能夠表示轉換後代碼的字符串。
  • Babel使用 @babel/generator 將修改後的 AST 轉換成代碼,生成過程能夠對是否壓縮以及是否刪除註釋等進行配置,而且支持 sourceMap。

3.Demo with esprima

瞭解了babel的運行原理,咱們根據babel的三個步驟來動手寫一個demo,加深對AST的理解。

咱們準備使用esprima來模擬兩個代碼轉換的功能:
  • 把 == 改成全等 ===
  • 把parseInt(a) 改成 parseInt(a,10)

轉換前的代碼,before.js:

function fun1(opt) {
  if (opt.status == 1) {
      console.log('1');
  }
}
function fun2(age) {
  if (parseInt(age) >= 18) {
      console.log('2');
  }
}
複製代碼

指望轉換後的代碼,after.js:

function fun1(opt) {
    if (opt.status === 1) {//==變成===
        console.log('1');
    }
}
function fun2(age) {
    if (parseInt(age, 10) >= 18) {//parseInt(a)變成parseInt(a,10)
        console.log('2');
    }
}
複製代碼
  1. 開始動手,先引入工具包
//引入工具包
const esprima = require('esprima');//JS語法樹模塊
const estraverse = require('estraverse');//JS語法樹遍歷各節點
const escodegen = require('escodegen');//JS語法樹反編譯模塊
const fs = require('fs');//讀寫文件
複製代碼
  1. 使用esprima parse把源代碼轉換成AST。怎麼樣,是否是很簡單,一句代碼就搞定了。
const before = fs.readFileSync('./before.js', 'utf8');
const ast = esprima.parseScript(before);
複製代碼
  1. 遍歷AST,找到符合轉換規則的代碼進行轉換
estraverse.traverse(ast, {
  enter: (node) => {
    toEqual(node);//把 == 改成全等 ===
    setParseInt(node); //把 parseInt(a) 改成 parseInt(a,10)
  }
});
複製代碼
  1. 再來看看toEqual和setParseInt函數的實現
function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}

function setParseInt(node) {
  //判斷節點類型,方法名稱,方法的參數的數量,數量爲1就增長第二個參數
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增長參數,其實就是數組操做
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}
複製代碼
  1. 最後,把轉換後的AST生成字符串代碼,寫入文件。
//生成目標代碼
const code = escodegen.generate(ast);
//寫入文件
fs.existsSync('./after.js') && fs.unlinkSync('./after.js');
fs.writeFileSync('./after.js', code, 'utf8');
複製代碼

好了,打開after.js文件看看,是否是已經轉換成功了?是否是和咱們指望的同樣?有沒有一種babel的感受?是的,其實babel也是這麼作的,只不過它的轉換規則函數至關的複雜,由於須要考慮各類JavaScript的語法狀況,工做量巨大,這也就是babel最核心的地方。

再回頭看看咱們寫的demo,是徹底遵循babel的三個步驟來作的。第一步parse和第三步generate都很是簡單,一句話的事,沒什麼好說的。重點是Transform,轉換規則函數的實現,有人可能會問,你怎麼知道,toEqual和setParseInt轉換函數要這麼寫呢?

好的,爲了回答這個問題,咱們來看看這兩個規則的代碼轉換先後的AST就明白了。

  • 把 == 改成全等 ===

a==b的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "==",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
複製代碼

a===b的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "BinaryExpression",
                "operator": "===",
                "left": {
                    "type": "Identifier",
                    "name": "a"
                },
                "right": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
複製代碼

比較上面兩個AST,是否是隻有一個"operator"字段有區別,一個是==, 另外一個是===。

再來看看toEqual函數,是否是明白了?只要修改一下node.operator的值就能完成轉換了。

function toEqual(node) {
  if (node.operator === '==') {
    node.operator = '===';
  }
}
複製代碼
  • 把parseInt(a) 改成 parseInt(a,10)

parseInt(a)的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}
複製代碼

parseInt(a, 10)的AST以下:

{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "Identifier",
                    "name": "parseInt"
                },
                "arguments": [
                    {
                        "type": "Identifier",
                        "name": "a"
                    },
                    {
                        "type": "Literal",
                        "value": 10,
                        "raw": "10"
                    }
                ]
            }
        }
    ],
    "sourceType": "script"
}
複製代碼

比較這兩個AST,看到了嗎?只是arguments數組多了下面這個元素。

{
    "type": "Literal",
    "value": 10,
    "raw": "10"
}
複製代碼

因此在轉換規則函數中,咱們把這個元素加進去就能實現轉換了。是否是很是簡單?

function setParseInt(node) {
  //判斷節點類型,方法名稱,方法的參數的數量,數量爲1就增長第二個參數
  if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
    node.arguments.push({//增長參數,其實就是數組操做
      "type": "Literal",
      "value": 10,
      "raw": "10"
    });
  }
}
複製代碼

好了,到此爲止,這個Demo應該徹底理解了吧。

4.思考題

看到這裏,你已經明白了AST的原理以及使用方法。下面來看一道題目,檢驗一下學習成果。

假設a是一個對象,var a = { b : 1},那麼a.b和a['b'] ,哪一個性能更高呢?

a.b和a['b']的寫法,你們常常會用到,也許沒有注意過這兩種寫法會有性能差別。事實上,有人作過測試,二者的性能差距不大,a.b會比a['b']性能稍微好一點。那麼,爲何a.b比a['b']性能稍微好一點呢?

我認爲,a.b能夠直接解析b爲a的屬性,而a['b']可能會多一個判斷的過程,由於[]裏面的內容多是一個變量,也多是個常量。

這種說法看起來好像頗有道理,事實上是否是這樣呢?有沒有什麼證據來證實這個說法嗎?

好吧,要想解釋清楚這個問題,就只能從V8引擎提及了。

4.V8引擎.png

js代碼能在cpu上運行,主要是js引擎的功勞,V8引擎是google開發,應用在chrome瀏覽器和nodejs上,是一個經典的js引擎。上圖能夠看出,在V8引擎中,js從源代碼到機器碼的轉譯主要有三個步驟:Parser(AST) ->Ignition(Bytecode)->TurboFan(Machine Code)

  • Parser:負責將JavaScript源碼轉換爲Abstract Syntax Tree (AST)
  • Ignition:interpreter,即解釋器,負責將AST轉換爲Bytecode,解釋執行Bytecode;同時收集TurboFan優化編譯所需的信息,好比函數參數的類型
  • TurboFan:compiler,即編譯器,利用Ignitio所收集的類型信息,將Bytecode轉換爲優化的彙編代碼

Parser-AST解析器

  • 你們應該很熟悉了,就是咱們今天介紹的AST解析器

Ignition-解釋器

  • 把AST解析成一種類彙編語言,樣子和彙編語言很類似,叫作Bytecode。這種語言和CPU無關,不一樣cpu的機器上生成的Bytecode都是相同的。

TurboFan-編譯器

  • 你們知道,每種cpu的架構和指令集是不一樣的,對應的彙編語言會有差別。V8在這一步,針對不一樣的cpu,把Bytecode解析成適合不一樣cpu的彙編語言。V8能夠支持十幾種cpu的彙編語言。

4.V8引擎代碼解析.png

如今,咱們就來比較一下a.b和a['b']在V8的解析下,到底有什麼不一樣

  • a.b的測試代碼以下:
function test001() {
    var a = { b: 1 };
    console.log(a.b)
}
test001();
複製代碼
  • a['b']的測試代碼以下:
function test002() {
    var a = { b: 1 };
    console.log(a['b'])
}
test002();
複製代碼

先看下他們生成的Bytecode

  • a.b的Bytecode代碼以下:
[generated bytecode for function: test001]
Parameter count 1
Frame size 32
   16 E> 000001F6C03D7192 @    0 : a0                StackCheck 
   33 S> 000001F6C03D7193 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         000001F6C03D7198 @    6 : 27 fa fb          Mov r1, r0
   46 S> 000001F6C03D719B @    9 : 13 01 01          LdaGlobal [1], [1]
         000001F6C03D719E @   12 : 26 f9             Star r2
   54 E> 000001F6C03D71A0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         000001F6C03D71A4 @   18 : 26 fa             Star r1
   60 E> 000001F6C03D71A6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         000001F6C03D71AA @   24 : 26 f8             Star r3
   54 E> 000001F6C03D71AC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         000001F6C03D71B1 @   31 : 0d                LdaUndefined 
   63 S> 000001F6C03D71B2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)
複製代碼
  • a['b']的Bytecode代碼以下:
[generated bytecode for function: test002]
Parameter count 1
Frame size 32
   16 E> 0000022E1C7D6DC2 @    0 : a0                StackCheck 
   33 S> 0000022E1C7D6DC3 @    1 : 79 00 00 29 fa    CreateObjectLiteral [0], [0], #41, r1
         0000022E1C7D6DC8 @    6 : 27 fa fb          Mov r1, r0
   46 S> 0000022E1C7D6DCB @    9 : 13 01 01          LdaGlobal [1], [1]
         0000022E1C7D6DCE @   12 : 26 f9             Star r2
   54 E> 0000022E1C7D6DD0 @   14 : 28 f9 02 03       LdaNamedProperty r2, [2], [3]
         0000022E1C7D6DD4 @   18 : 26 fa             Star r1
   59 E> 0000022E1C7D6DD6 @   20 : 28 fb 03 05       LdaNamedProperty r0, [3], [5]
         0000022E1C7D6DDA @   24 : 26 f8             Star r3
   54 E> 0000022E1C7D6DDC @   26 : 57 fa f9 f8 07    CallProperty1 r1, r2, r3, [7]
         0000022E1C7D6DE1 @   31 : 0d                LdaUndefined 
   66 S> 0000022E1C7D6DE2 @   32 : a4                Return 
Constant pool (size = 4)
Handler Table (size = 0)
複製代碼

比較一下二者的Bytecode,你會發現它們徹底相同,這就說明,這兩種寫法在Bytecode層及如下的執行,性能是沒有差異的。事實上,它們有差異,就只能往上找,上面就只有Parser階段了。咱們再來看看它們的AST有什麼區別。

  • a.b的AST代碼以下:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": false,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Identifier",
                    "name": "b"
                }
            }
        }
    ],
    "sourceType": "script"
}
複製代碼
  • a['b']的AST代碼以下:
{
    "type": "Program",
    "body": [
        {
            "type": "ExpressionStatement",
            "expression": {
                "type": "MemberExpression",
                "computed": true,
                "object": {
                    "type": "Identifier",
                    "name": "a"
                },
                "property": {
                    "type": "Literal",
                    "value": "b",
                    "raw": "'b'"
                }
            }
        }
    ],
    "sourceType": "script"
}
複製代碼

咱們發現惟一的區別就是"computed"屬性,a.b是false,a['b']是true,說明在解析成AST時,a['b']比a.b多了一個計算的過程。由此咱們判定,二者微小的差別應該就在這裏。好了,證據找到了,如今應該沒有疑問了吧。

收尾

看到這裏,你不但瞭解了AST的相關知識,還知道了V8引擎是如何解析js代碼的,是否是有所收穫呢?若是你以爲這篇文章對你有用,還請順便點個贊,很是感謝(90度鞠躬)。

想了解skFeTeam更多的分享文章,能夠點這裏,謝謝~

相關文章
相關標籤/搜索