淺析AST抽象語法樹及如何利用AST轉換JS代碼


  在學習AST之前,可以結合此篇博客(淺析代碼編譯過程 )一起看。

  抽象語法樹(Abstract Syntax Tree)也稱為AST語法樹,指的是源代碼語法所對應的樹狀結構。也就是說,對於一種具體編程語言下的源代碼,通過構建語法樹的形式將源代碼中的語句映射到樹中的每一個節點上。

  如果你查看目前任何主流的項目中的 devDependencies,會發現前些年的不計其數的插件誕生。我們歸納一下有:javascript轉譯、代碼壓縮、css預處理器、elint、pretiier,等。有很多js模塊我們不會在生產環境用到,但是它們在我們的開發過程中充當着重要的角色。所有的上述工具,不管怎樣,都建立在了AST這個巨人的肩膀上。

  所有的上述工具,不管怎樣,都建立在了AST這個巨人的肩膀上。

一、JavaScript語法解析

1、什么是AST抽象語法樹

It is a hierarchical program representation that presents source code structure according to the grammar of a programming language, each AST node corresponds to an item of a source code.

  估計很多同學看完這段官方的定義一臉懵逼,可以通過一個簡單的例子來看語法樹具體長什么樣子。有如下代碼:

  我們可以發現,程序代碼本身可以被映射成為一棵語法樹(實際上,真正AST每個節點會有更多的信息。但是,這是大體思想。從純文本中,我們將得到樹形結構的數據,每個條目和樹中的節點一一對應。),而通過操縱語法樹,我們能夠精准的獲得程序代碼中的某個節點。例如聲明語句,賦值語句,而這是用正則表達式所不能准確體現的地方。

  JavaScript的語法解析器Espsrima提供了一個在線解析的工具,你可以借助於這個工具,將JavaScript代碼解析為一個JSON文件表示的樹狀結構。

2、有什么用

  聊到AST的用途,其應用非常廣泛,下面我簡單羅列了一些:

  • IDE的錯誤提示、代碼格式化、代碼高亮、代碼自動補全等
  • JSLintJSHint對代碼錯誤或風格的檢查等
  • webpackrollup進行代碼打包等
  • CoffeeScriptTypeScriptJSX等轉化為原生Javascript

  其實它的用途,還不止這些,如果說你已經不滿足於實現枯燥的業務功能,想寫出類似reactvue這樣的牛逼框架,或者想自己搞一套類似webpackrollup這樣的前端自動化打包工具,那你就必須弄懂AST

  抽象語法樹的作用非常的多,比如編譯器、IDE、壓縮優化代碼等。在JavaScript中,雖然我們並不會常常與AST直接打交道,但卻也會經常的涉及到它。例如使用UglifyJS來壓縮代碼,實際這背后就是在對JavaScript的抽象語法樹進行操作。 在一些實際開發過程中,我們也會用到抽象語法樹,下面通過一個小例子來看看怎么進行JavaScript的語法解析以及對節點的遍歷與操縱。

二、如何生成AST?

  在了解如何生成AST之前,有必要了解一下Parser(常見的Parseresprimatraceuracornshift等)。JS Parser其實是一個解析器,它是將js源碼轉化為抽象語法樹(AST)的解析器。整個解析過程主要分為以下兩個步驟:

  • 分詞(也就是詞法分析):將整個代碼字符串分割成最小語法單元數組
  • 語法分析:在分詞基礎上建立分析語法單元之間的關系
1、什么是語法單元?

  語法單元是被解析語法當中具備實際意義的最小單元,簡單的來理解就是自然語言中的詞語。舉個例子來說,下面這段話:“2019年是祖國70周年”,我們可以把這句話拆分成最小單元,即:2019年、是、祖國、70、周年。

  這就是我們所說的分詞,也是最小單元,因為如果我們把它再拆分出去的話,那就沒有什么實際意義了。

  Javascript 代碼中的語法單元主要包括以下這么幾種:

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

  如果我們以最簡單的復制語句為例的話,如下:

var a = 1

  通過分詞,我們可以得到如下結果:

[ { "type": "Keyword", "value": "var" }, { "type": "Identifier", "value": "a" }, { "type": "Punctuator", "value": "=" }, { "type": "Numeric", "value": "1" }, { "type": "Punctuator", "value": ";" } ]

2、什么是語法分析?

  上面我們已經得到了我們分詞的結果,需要將詞匯進行一個立體的組合,確定詞語之間的關系,確定詞語最終的表達含義。

  簡單來說語法分析是對語句和表達式識別,確定之前的關系,這是個遞歸過程。

  上面我們通過語法分析,可以得到如下結果:

{ "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進行語法解析,從而找到這些節點。但是,我們仍然需要一個語法解析器才能順利的進行工作,在JavaScript的語法解析領域,一個流行的開源項目是Esprima,我們可以利用這個工具來完成任務。此外,我們需要借助Node來構建能夠在命令行運行的JS代碼。

1、准備工作

  為了能夠完成后面的工作,你需要確保安裝了Node環境。首先創建項目的基本目錄結構,以及初始化NPM。

mkdir esprima-tutorial cd esprima-tutorial npm install esprima --save  

  在根目錄新建index.js文件,初試代碼如下

    var fs = require('fs'), esprima = require('esprima'); function analyzeCode(code) { // 1 
 } // 2 
    if (process.argv.length < 3) { console.log('Usage: index.js file.js'); process.exit(1); } // 3 
    var filename = process.argv[2]; console.log('Reading ' + filename); var code = fs.readFileSync(filename); analyzeCode(code); console.log('Done');  

  在上面的代碼中:

(1)函數analyzeCode用於執行主要的代碼分析工作,這里我們暫時預留下來這部分工作待后面去解決。

(2)我們需要確保用戶在命令行中指定了分析文件的具體位置,這可以通過查看process.argv的長度來得到。為什么?你可以參考Node的官方文檔:

The first element will be ‘node’, the second element will be the name of the JavaScript file. The next elements will be any additional command line arguments.

(3)獲取文件,並將文件傳入到analyzeCode函數中進行處理

2、解析代碼和遍歷AST

  借助Esprima解析代碼非常簡單,只要使用一個方法即可:

var ast = esprima.parse(code); 

  esprima.parse()方法接收兩種類型的參數:字符串或Node的Buffer對象,它也可以收附加的選項作為參數。解析后返回結果即為抽象語法樹(AST),AST遵守Mozilla SpiderMonkey的解析器API。例如代碼:

var answer = 6 * 7;

  解析后的結果為:

{ "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "answer" }, "init": { "type": "BinaryExpression", "operator": "*", "left": { "type": "Literal", "value": 6, "raw": "6" }, "right": { "type": "Literal", "value": 7, "raw": "7" } } } ], "kind": "var" } ], "sourceType": "script" }

  代碼:6*7,解析結果為:

{ "type": "Program", "body": [ { "type": "ExpressionStatement", "expression": { "type": "BinaryExpression", "operator": "*", "left": { "type": "Literal", "value": 6, "raw": "6" }, "right": { "type": "Literal", "value": 7, "raw": "7" } } } ], "sourceType": "script" }

  可以自行在此解析工具里試試:https://esprima.org/demo/parse.html#

  我們可以發現每個節點都有一個type,根節點的type為Program。type也是所有節點都共有的,其他的屬性依賴於節點的type。例如上面實例的程序中,我們可以發現根節點下面的子節點的類型為EspressionStatement,依此類推。

  為了能夠分析代碼,我們需要對得到的AST進行遍歷,我們可以借助Estraverse進行節點的遍歷。執行如下命令進行安裝該NPM包:

npm install estraverse --save

  基本用法如下:

 function analyzeCode(code) { var ast = esprima.parse(code); estraverse.traverse(ast, { enter: function (node) { console.log(node.type); } }); }

  上面的代碼會輸出遇到的語法樹上每個節點的類型。

3、獲取分析數據

  為了完成需求,我們需要遍歷語法樹,並統計每個函數調用和聲明的次數。因此,我們需要知道兩種節點類型。首先是函數聲明:

 { "type": "FunctionDeclaration", "id": { "type": "Identifier", "name": "myAwesomeFunction" }, "params": [ ... ], "body": { "type": "BlockStatement", "body": [ ... ] } } 

  對函數聲明而言,其節點類型為FunctionDeclaration,函數的標識符(即函數名)存放在id節點中,其中name子屬性即為函數名。params和body分別為函數的參數列表和函數體。

  我們再來看函數調用:

    "expression": { "type": "CallExpression", "callee": { "type": "Identifier", "name": "myAwesomeFunction" }, "arguments": [] } 

  對函數調用而言,即節點類型為CallExpression,callee指向被調用的函數。有了上面的了解,我們可以繼續完成我們的程序如下:

 function analyzeCode(code) { var ast = esprima.parse(code); var functionsStats = {}; //1 
      
        var addStatsEntry = function (funcName) { //2 
            if (!functionsStats[funcName]) { functionsStats[funcName] = { calls: 0, declarations: 0 }; } }; // 3 
 estraverse.traverse(ast, { enter: function (node) { if (node.type === 'FunctionDeclaration') { addStatsEntry(node.id.name); //4 
                    functionsStats[node.id.name].declarations++; } else if (node.type === 'CallExpression' && node.callee.type === 'Identifier') { addStatsEntry(node.callee.name); functionsStats[node.callee.name].calls++; //5 
 } } }); } 

(1)我們創建了一個對象functionStats用來存放函數的調用和聲明的統計信息,函數名作為key。

(2)函數addStatsEntry用於實現存放統計信息。

(3)遍歷AST

(4)如果發現了函數聲明,增加一次函數聲明

(5)如果發現了函數調用,增加一次函數調用

4、處理結果

  最后進行結果的處理,我們只需要遍歷查看functionStats中的信息就可以得到結果。創建結果處理函數如下:

 function processResults(results) { for (var name in results) { if (results.hasOwnProperty(name)) { var stats = results[name]; if (stats.declarations === 0) { console.log('Function', name, 'undeclared'); } else if (stats.declarations > 1) { console.log('Function', name, 'decalred multiple times'); } else if (stats.calls === 0) { console.log('Function', name, 'declared but not called'); } } } } 

  然后,在analyzeCode函數的末尾調用該函數即可,如下:processResults(functionsStats);

5、測試

  創建測試文件demo.js如下:

    function declaredTwice() {}
    function main() {  
        undeclared();  
    }
    function unused() {}
    function declaredTwice() {}
    main();  

  我們看到declaredTwice聲明了2次,undeclared未聲明,unused聲明了但未調用。執行如下命令:

node index.js demo.js

  你將得到如下的處理結果:

Reading demo.js
Function declaredTwice decalred multiple times
Function undeclared undeclared
Function unused declared but not called
Done 

四、babel如何利用AST轉換JS代碼的

  我相信大部分同學對 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 的例子。接下來我們看看如何實現:

1、安裝依賴

  需要操作 AST 代碼,這里,我們需要借助兩個庫,分別是 @babel/corebabel-types。其中 @babel/corebabel 的核心庫,用來實現核心轉換引擎,babel-types 類型判斷,用於生成AST零部件。

  cd 到一個你喜歡的目錄,通過 npm -y init 初始化操作后,通過 npm i @babel/core babel-types -D 安裝依賴。

2、目標分析

  要進行轉換之前,我們需要進行分析,首先我們先通過 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" }

  其中里面的 startend 我們不用在意,其只是為了標記其所在代碼的位置。

  我們關心的是 body 里面的內容,通過對比,我們發現主要不同就在於 init 這一段,一個是 ArrowFunctionExpression , 另一個是 FunctionExpression , 我們只需要將 ArrowFunctionExpression 下的內容改造成跟 FunctionExpression 即可。

3、開始操作

  我們建一個 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 ,過程中可以通過使用 pluginsAST 進行改造,最終生成新的 ASTjs 代碼,其整個過程用網上一個比較貼切的圖就是:

  關於 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 我們可以看到其 idnullparams 是原 ArrowFunctionExpression 中的 paramsbody 是一個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);

  執行代碼,打印結果如下:

  至此,我們的函數轉換完成,達到預期效果。

  細心的同學發現,上面的代碼其實可以優化的,因為我們的 returnStatement 其實也是跟源代碼的 returnStatement 是一致的,我們只需要拿來復用即可,因此上述的代碼還可以改成下面這樣:
    let blockStatement = types.blockStatement([ types.returnStatement(path.node.body) ]);

五、利用AST將class類轉換為es5

  上面剛剛認識了如何使用 AST 進行代碼改造,不妨趁熱打鐵,再來試試下面這個問題。如何將 es6 中的 class 改造成 es5function 形式
// 源代碼
class Person { constructor(name) { this.name=name; } sayName() { return this.name; } } // 目標代碼
function Person(name) { this.name = name; } Person.prototype.getName = function () { return this.name; };

  有了上面的基礎,照貓畫虎即可:分別比對2種AST語法差異,然后進行比對替換。下面是核心轉換插件

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 去重新認識和詮釋自己的代碼。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM