什麼是抽象語法樹(Abstract Syntax Tree ,AST
)?
百度百科是這麼解釋的:
在電腦科學中,抽象語法樹(Abstract Syntax Tree,AST),或簡稱語法樹(Syntax tree),是原始碼語法結構的一種抽象表示。它以樹狀的形式表現程式語言的語法結構,樹上的每個節點都表示原始碼中的一種結構。
聽起來還是很繞,沒關係,你可以簡單理解為 它就是你所寫程式碼的的樹狀結構化表現形式。
有了這棵樹,我們就可以通過操縱這顆樹,精準的定位到宣告語句、賦值語句、運算語句等等,實現對程式碼的分析、優化、變更等操作。
AST在日常業務中也許很難涉及到,有可能你還沒有聽過,但其實很多時候你已經在使用它了,只是沒有太多關注而已,現在流行的 webpack
,eslint
等很多外掛或者包都有涉及~
抽象語法樹能做什麼?
聊到AST
的用途,其應用非常廣泛,下面我簡單羅列了一些:
IDE
的錯誤提示、程式碼格式化、程式碼高亮、程式碼自動補全等JSLint
、JSHint
對程式碼錯誤或風格的檢查等webpack
、rollup
進行程式碼打包等CoffeeScript
、TypeScript
、JSX
等轉化為原生Javascript
其實它的用途,還不止這些,如果說你已經不滿足於實現枯燥的業務功能,想寫出類似react
、vue
這樣的牛逼框架,或者想自己搞一套類似webpack
、rollup
這樣的前端自動化打包工具,那你就必須弄懂AST
。
如何生成AST?
在瞭解如何生成AST
之前,有必要了解一下Parser
(常見的Parser
有esprima
、traceur
、acorn
、shift
等)
JS Parser
其實是一個解析器,它是將js
原始碼轉化為抽象語法樹(AST
)的解析器。
整個解析過程主要分為以下兩個步驟:
- 分詞:將整個程式碼字串分割成最小語法單元陣列
- 語法分析:在分詞基礎上建立分析語法單元之間的關係
什麼是語法單元?
語法單元是被解析語法當中具備實際意義的最小單元,簡單的來理解就是自然語言中的詞語。
舉個例子來說,下面這段話:
“2019年是祖國70週年”
我們可以把這句話拆分成最小單元,即:2019年、是、祖國、70、週年。
這就是我們所說的分詞,也是最小單元,因為如果我們把它再拆分出去的話,那就沒有什麼實際意義了。
Javascript
程式碼中的語法單元主要包括以下這麼幾種:
- 關鍵字:例如
var
、let
、const
等 - 識別符號:沒有被引號括起來的連續字元,可能是一個變數,也可能是
if
、else
這些關鍵字,又或者是true
、false
這些內建常量 - 運算子:
+
、-
、*
、/
等 - 數字:像十六進位制,十進位制,八進位制以及科學表示式等語法
- 字串:因為對計算機而言,字串的內容會參與計算或顯示
- 空格:連續的空格,換行,縮排等
- 註釋:行註釋或塊註釋都是一個不可拆分的最小語法單元
- 其他:大括號、小括號、分號、冒號等
如果我們以最簡單的複製語句為例的話,如下?
var a = 1;
複製程式碼
通過分詞,我們可以得到如下結果:
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "1"
},
{
"type": "Punctuator",
"value": ";"
}
]
複製程式碼
為了方便,我直接在 esprima/parser 這個網站生成分詞的~
什麼是語法分析?
上面我們已經得到了我們分詞的結果,需要將詞彙進行一個立體的組合,確定詞語之間的關係,確定詞語最終的表達含義。
簡單來說語法分析是對語句和表示式識別,確定之前的關係,這是個遞迴過程。
上面我們通過語法分析,可以得到如下結果:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "script"
}
複製程式碼
這就是 var a = 1
所轉換的 AST
;
這裡推薦一下astexplorer AST
的視覺化工具,astexplorer,可以直接進行對程式碼進行AST
轉換~
AST 到底怎麼用?
上面畫了很大篇幅聊了聊AST
是什麼以及它是如何生成的,說到底,還是不知道AST
這玩意有啥用,到底怎麼使用。。
ok~ 接下來我們來一起見證奇蹟。
我相信大部分同學對 babel
這個庫不陌生,現在的做前端模組化開發過程中中一定少不了它,因為它可以幫你將 ECMAScript 2015+
版本的程式碼轉換為向後相容的 JavaScript
語法,以便能夠執行在當前和舊版本的瀏覽器或其他環境中,你不用為新語法的相容性考慮~
而實際上呢,babel
中的很多功能都是靠修改 AST
實現的。
首先,我們來看一個簡單的例子,我們如何將 es6
中的 箭頭函式
轉換成 es5
中的 普通函式,即:
const sum=(a,b)=>a+b;
複製程式碼
我們如何將上面簡單的 sum
箭頭函式轉換成下面的形式:
const sum = function(a,b){
return a+b;
}
複製程式碼
想想看,有什麼思路?
如果說你不瞭解 AST
的話,這無疑是一個很困難的問題,根本無從下手,如果你瞭解 AST
的話,這將是一個非常 easy
的例子。
接下來我們看看如何實現?
安裝依賴
需要操作 AST
程式碼,這裡,我們需要藉助兩個庫,分別是 @babel/core
和 babel-types
。
其中 @babel/core
是 babel
的核心庫,用來實現核心轉換引擎,babel-types
型別判斷,用於生成AST
零部件
cd
到一個你喜歡的目錄,通過 npm -y init
初始化操作後,通過 npm i @babel/core babel-types -D
安裝依賴
目標分析
要進行轉換之前,我們需要進行分析,首先我們先通過 astexplorer 檢視目的碼跟我們現在的程式碼有什麼區別。
原始碼的 AST
結構如下:
// 原始碼的 AST
{
"type": "Program",
"start": 0,
"end": 21,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 21,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 20,
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"name": "sum"
},
"init": {
"type": "ArrowFunctionExpression",
"start": 10,
"end": 20,
"id": null,
"expression": true,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 11,
"end": 12,
"name": "a"
},
{
"type": "Identifier",
"start": 13,
"end": 14,
"name": "b"
}
],
"body": {
"type": "BinaryExpression",
"start": 17,
"end": 20,
"left": {
"type": "Identifier",
"start": 17,
"end": 18,
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 19,
"end": 20,
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
複製程式碼
目的碼的 AST
結構如下:
// 目的碼的 `AST`
{
"type": "Program",
"start": 0,
"end": 48,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 48,
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 47,
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"name": "sum"
},
"init": {
"type": "FunctionExpression",
"start": 12,
"end": 47,
"id": null,
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 22,
"end": 23,
"name": "a"
},
{
"type": "Identifier",
"start": 25,
"end": 26,
"name": "b"
}
],
"body": {
"type": "BlockStatement",
"start": 28,
"end": 47,
"body": [
{
"type": "ReturnStatement",
"start": 32,
"end": 45,
"argument": {
"type": "BinaryExpression",
"start": 39,
"end": 44,
"left": {
"type": "Identifier",
"start": 39,
"end": 40,
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 43,
"end": 44,
"name": "b"
}
}
}
]
}
}
}
],
"kind": "const"
}
],
"sourceType": "module"
}
複製程式碼
其中裡面的 start
和 end
我們不用在意,其只是為了標記其所在程式碼的位置。
我們關心的是 body
裡面的內容,通過對比,我們發現主要不同就在於 init
這一段,一個是 ArrowFunctionExpression
, 另一個是 FunctionExpression
, 我們只需要將 ArrowFunctionExpression
下的內容改造成跟 FunctionExpression
即可。
小試牛刀
我們建一個 arrow.js
的檔案,引入上面的兩個庫,即
//babel 核心庫,用來實現核心轉換引擎
const babel = require('@babel/core')
//型別判斷,生成AST零部件
const types = require('babel-types')
//原始碼
const code = `const sum=(a,b)=>a+b;` //目的碼 const sum = function(a,b){ return a + b }
複製程式碼
這裡我們需要用到 babel
中的 transform
方法,它可以將 js
程式碼轉換成 AST
,過程中可以通過使用 plugins
對 AST
進行改造,最終生成新的 AST
和 js
程式碼,其整個過程用網上一個比較貼切的圖就是:
關於 babel transform
詳細用法,這裡不多做討論,感興趣的話可以去官網瞭解~
其主要用法如下:
//transform方法轉換code
//babel先將程式碼轉換成ast,然後進行遍歷,最後輸出code
let result = babel.transform(code,{
plugins:[
{
visitor
}
]
})
複製程式碼
其核心在於外掛 visitor
的實現。
它是一個外掛物件,可以對特定型別的節點進行處理,這裡我們需要處理的節點是ArrowFunctionExpression
,它常見的配置方式有兩種:
一種是單一處理,結構如下,其中 path
代表當前遍歷的路徑 path.node
代表當前變數的節點
let visitor = {
ArrowFunctionExpression(path){
}
}
複製程式碼
另一種是用於輸入和輸出雙向處理,結構如下,引數 node
表示當前遍歷的節點
let visitor = {
ArrowFunctionExpression : {
enter(node){
},
leave(node){
}
}
}
複製程式碼
這裡我們只需要處理一次,所以採用第一種方式。
通過分析目的碼的 AST
,我們發現,需要一個 FunctionExpression
, 這時候我們就需要用到 babel-types
,它的作用就是幫助我們生產這些節點
我們通過其 npm
包的文件檢視,構建一個 FunctionExpression
需要的引數如下:
參照 AST
我們可以看到其 id
為 null
,params
是原 ArrowFunctionExpression
中的 params
,body
是一個blockStatement
,我們也可以通過檢視 babel-types
文件,用 t.blockStatement(body, directives)
來建立,依次類推,照貓畫虎,最終得到的結果如下:
//原 params
let params = path.node.params;
//建立一個blockStatement
let blockStatement = types.blockStatement([
types.returnStatement(types.binaryExpression(
'+',
types.identifier('a'),
types.identifier('b')
))
]);
//建立一個函式
let func = types.functionExpression(null, params, blockStatement, false, false);
複製程式碼
最後通過 path.replaceWith(func);
將其替換即可;
完成程式碼如下:
//babel 核心庫,用來實現核心轉換引擎
const babel = require('@babel/core')
//型別判斷,生成AST零部件
const types = require('babel-types')
//原始碼
const code = `const sum=(a,b)=>a+b;` //目的碼 const sum = function(a,b){ return a + b }
//外掛物件,可以對特定型別的節點進行處理
let visitor = {
//代表處理 ArrowFunctionExpression 節點
ArrowFunctionExpression(path){
let params = path.node.params;
//建立一個blockStatement
let blockStatement = types.blockStatement([
types.returnStatement(types.binaryExpression(
'+',
types.identifier('a'),
types.identifier('b')
))
]);
//建立一個函式
let func = types.functionExpression(null, params, blockStatement, false, false);
//替換
path.replaceWith(func);
}
}
//transform方法轉換code
//babel先將程式碼轉換成ast,然後進行遍歷,最後輸出code
let result = babel.transform(code,{
plugins:[
{
visitor
}
]
})
console.log(result.code);
複製程式碼
執行程式碼,列印結果如下:
至此,我們的函式轉換完成,達到預期效果。
怎麼樣,有沒有很神奇!!
其實也沒那麼複雜,主要是要分析其 AST
的結構,只要弄懂我們需要幹什麼,那麼放手去做就是~
pass
:細心的同學發現,上面的程式碼其實可以優化的,因為我們的 returnStatement
其實也是跟原始碼的 returnStatement
是一致的,我們只需要拿來複用即可,因此上述的程式碼還可以改成下面這樣:
let blockStatement = types.blockStatement([
types.returnStatement(path.node.body)
]);
複製程式碼
趁熱打鐵
上面剛剛認識瞭如何使用 AST
進行程式碼改造,不妨趁熱打鐵,再來試試下面這個問題。
如何將 es6
中的 class
改造成 es5
的 function
形式~
原始碼
// 原始碼
class Person {
constructor(name) {
this.name=name;
}
sayName() {
return this.name;
}
}
複製程式碼
目的碼
// 目的碼
function Person(name) {
this.name = name;
}
Person.prototype.getName = function () {
return this.name;
};
複製程式碼
有了上面的基礎,照貓畫虎即可,這裡我將不在贅述,過程很重要,這裡我僅貼上核心的轉換程式碼,以供參考。
ClassDeclaration (path) {
let node = path.node; //當前節點
let id = node.id; //節點id
let methods = node.body.body; // 方法陣列
let constructorFunction = null; // 構造方法
let functions = []; // 目標方法
methods.forEach(method => {
//如果是構造方法
if ( method.kind === 'constructor' ) {
constructorFunction = types.functionDeclaration(id, method.params, method.body, false, false);
functions.push(constructorFunction)
} else {
//普通方法
let memberExpression = types.memberExpression(types.memberExpression(id, types.identifier('prototype'), false), method.key, false);
let functionExpression = types.functionExpression(null, method.params, method.body, false, false);
let assignmentExpression = types.assignmentExpression('=', memberExpression, functionExpression);
functions.push(types.expressionStatement(assignmentExpression));
}
})
//判斷,replaceWithMultiple用於多重替換
if(functions.length === 1){
path.replaceWith(functions[0])
}else{
path.replaceWithMultiple(functions)
}
}
複製程式碼
總結
日常工作中,我們大多數時候關注的只是 js
程式碼本身,而沒有通過 AST
去重新認識和詮釋自己的程式碼~
本文也只是通過對 AST
的一些介紹,起一個拋磚引玉的作用,讓你對它 有一個初步的認識,對它不在感覺那麼陌生。
對程式碼的追求和探索是無止境的~
如果你願意,你可以通過它構建任何你想要的js
程式碼~
加油!