AST抽象語法樹—最基礎的javascript重點知識

2020-09-23 流浪的思維


javascript就像一臺精妙運作的機器,我們可以用它來完成一切天馬行空的構思。

我們對javascript生態了如指掌,卻常忽視javascript本身。這臺機器,究竟是哪些零部件在支持著它運行?

AST在日常業務中也許很難涉及到,但當你不止於想做一個工程師,而想做工程師的工程師,寫出vue、react之類的大型框架,或類似webpack、vue-cli前端自動化的工具,或者有批量修改源碼的工程需求,那你必須懂得AST。AST的能力十分強大,且能幫你真正吃透javascript的語言精髓。

事實上,在javascript世界中,你可以認為抽象語法樹(AST)是最底層。 再往下,就是關於轉換和編譯的「黑魔法」領域了。

人生第一次拆解Javascript

小時候,當我們拿到一個螺絲刀和一臺機器,人生中最令人懷念的夢幻時刻便開始了:

我們把機器,拆成一個一個小零件,一個個齒輪與螺釘,用巧妙的機械原理銜接在一起...

當我們把它重新照不同的方式組裝起來,這時,機器重新又跑動了起來——世界在你眼中如獲新生。

通過抽象語法樹解析,我們可以像童年時拆解玩具一樣,透視Javascript這臺機器的運轉,並且重新按照你的意願來組裝。

現在,我們拆解一個簡單的add函數

function add(a, b) { return a + b}

首先,我們拿到的這個語法塊,是一個FunctionDeclaration(函數定義)對象。

用力拆開,它成了三塊:

  • 一個id,就是它的名字,即add
  • 兩個params,就是它的參數,即[a, b]
  • 一塊body,也就是大括號內的一堆東西

add沒辦法繼續拆下去了,它是一個最基礎Identifier(標誌)對象,用來作為函數的唯一標誌,就像人的姓名一樣。

{ name: &39; type: &39; ...}

params繼續拆下去,其實是兩個Identifier組成的數組。之後也沒辦法拆下去了。

[ { name: &39; type: &39; ... }, { name: &39; type: &39; ... }]

接下來,我們繼續拆開body
我們發現,body其實是一個BlockStatement(塊狀域)對象,用來表示是{return a + b}

打開Blockstatement,裡面藏著一個ReturnStatement(Return域)對象,用來表示return a + b

繼續打開ReturnStatement,裡面是一個BinaryExpression(二項式)對象,用來表示a + b

繼續打開BinaryExpression,它成了三部分,leftoperatorright

  • operator+
  • left 裡面裝的,是Identifier對象 a
  • right 裡面裝的,是Identifer對象 b

就這樣,我們把一個簡單的add函數拆解完畢,用圖表示就是



看!抽象語法樹(Abstract Syntax Tree),的確是一種標準的樹結構。

那麼,上面我們提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 這一個個小部件的說明書去哪查?

請查看 AST對象文檔

送給你的AST螺絲刀:recast

輸入命令:

npm i recast -S

你即可獲得一把操縱語法樹的螺絲刀

接下來,你可以在任意js文件下操縱這把螺絲刀,我們新建一個parse.js示意:

parse.js

// 給你一把&34;——recastconst recast = require(&34;);// 你的&34;——一段代碼// 我們使用了很奇怪格式的代碼,想測試是否能維持代碼結構const code = ` function add(a, b) { return a + // 有什麼奇怪的東西混進來了 b } `// 用螺絲刀解析機器const ast = recast.parse(code);// ast可以處理很巨大的代碼文件// 但我們現在只需要代碼塊的第一個body,即add函數const add = ast.program.body[0]console.log(add)

輸入node parse.js你可以查看到add函數的結構,與之前所述一致,通過AST對象文檔可查到它的具體屬性:

FunctionDeclaration{ type: &39;, id: ... params: ... body: ...}

你也可以繼續使用console.log透視它的更內層,如:

console.log(add.params[0])

console.log(add.body.body[0].argument.left)

recast.types.builders 製作模具

一個機器,你只會拆開重裝,不算本事。

拆開了,還能改裝,才算上得了臺面。

recast.types.builders裡面提供了不少「模具」,讓你可以輕鬆地拼接成新的機器。

最簡單的例子,我們想把之前的function add(a, b){...}聲明,改成匿名函數式聲明const add = function(a ,b){...}

如何改裝?

第一步,我們創建一個VariableDeclaration變量聲明對象,聲明頭為const, 內容為一個即將創建的VariableDeclarator對象。

第二步,創建一個VariableDeclarator,放置add.id在左邊, 右邊是將創建的FunctionDeclaration對象

第三步,我們創建一個FunctionDeclaration,如前所述的三個組件,id params body中,因為是匿名函數id設為空,params使用add.params,body使用add.body。

這樣,就創建好了const add = function(){}的AST對象。

在之前的parse.js代碼之後,加入以下代碼

// 引入變量聲明,變量符號,函數聲明三種「模具」const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders// 將準備好的組件置入模具,並組裝回原來的ast對象。ast.program.body[0] = variableDeclaration(&34;, [ variableDeclarator(add.id, functionExpression( null, // Anonymize the function expression. add.params, add.body ))]);//將AST對象重新轉回可以閱讀的代碼const output = recast.print(ast).code;console.log(output)

可以看到,我們列印出了

const add = function(a, b) { return a + // 有什麼奇怪的東西混進來了 b};

最後一行

const output = recast.print(ast).code;

其實是recast.parse的逆向過程,具體公式為

recast.print(recast.parse(source)).code === source

列印出來還保留著「原裝」的函數內容,連注釋都沒有變。

我們其實也可以列印出美化格式的代碼段:

const output = recast.prettyPrint(ast, { tabWidth: 2 }).code

輸出為

const add = function(a, b) { return a + b;};

現在,你是不是已經產生了「我可以通過AST樹生成任何js代碼」的幻覺?

我鄭重告訴你,這不是幻覺。

實戰進階:命令行修改js文件

除了parse/print/builder以外,Recast的三項主要功能:

  • run: 通過命令行讀取js文件,並轉化成ast以供處理。
  • tnt: 通過assert()和check(),可以驗證ast對象的類型。
  • visit: 遍歷ast樹,獲取有效的AST對象並進行更改。

我們通過一個系列小務來學習全部的recast工具庫:

創建一個用來示例文件,假設是demo.js

demo.js

function add(a, b) { return a + b}function sub(a, b) { return a - b}function commonDivision(a, b) { while (b !== 0) { if (a > b) { a = sub(a, b) } else { b = sub(b, a) } } return a}

recast.run —— 命令行文件讀取

新建一個名為read.js的文件,寫入
read.js

recast.run( function(ast, printSource){ printSource(ast)})

命令行輸入

node read demo.js

我們查以看到js文件內容列印在了控制臺上。

我們可以知道,node read可以讀取demo.js文件,並將demo.js內容轉化為ast對象。

同時它還提供了一個printSource函數,隨時可以將ast的內容轉換回源碼,以方便調試。

recast.visit —— AST節點遍歷

read.js

39;recast&!/usr/bin/env nodeconst recast = require(&39;)recast.run(function(ast, printSource) { recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.node printSource(node) this.traverse(path) } })});

調試時,如果你想輸出AST對象,可以console.log(node)

如果你想輸出AST對象對應的源碼,可以printSource(node)

命令行輸入`
node read demo.js`進行測試。

E83E8C; --tt-darkmode-color: !/usr/bin/env nodeconst recast = require(&34;);const TNT = recast.types.namedTypesrecast.run(function(ast, printSource) { recast.visit(ast, { visitExpressionStatement: function(path) { const node = path.value // 判斷是否為ExpressionStatement,正確則輸出一行字。 if(TNT.ExpressionStatement.check(node)){ console.log(&39;) } this.traverse(path); } });});

read.js

34;recast&!/usr/bin/env nodeconst recast = require(&34;);const { identifier:id, expressionStatement, memberExpression, assignmentExpression, arrowFunctionExpression, blockStatement} = recast.types.buildersrecast.run(function(ast, printSource) { // 一個塊級域 {} console.log(&39;) printSource(blockStatement([])) // 一個鍵頭函數 ()=>{} console.log(&39;) printSource(arrowFunctionExpression([],blockStatement([]))) // add賦值為鍵頭函數 add = ()=>{} console.log(&39;) printSource(assignmentExpression(&39;,id(&39;),arrowFunctionExpression([],blockStatement([])))) // exports.add賦值為鍵頭函數 exports.add = ()=>{} console.log(&39;) printSource(expressionStatement(assignmentExpression(&39;,memberExpression(id(&39;),id(&39;)), arrowFunctionExpression([],blockStatement([])))))});

上面寫了我們一步一步推斷出exports.add = ()=>{}的過程,從而得到具體的AST結構體。

使用node exportific demo.js運行可查看結果。

接下來,只需要在獲得的最終的表達式中,把id(&39;)替換成遍歷得到的函數名,把參數替換成遍歷得到的函數參數,把blockStatement([])替換為遍歷得到的函數塊級作用域,就成功地改寫了所有函數!

另外,我們需要注意,在commonDivision函數內,引用了sub函數,應改寫成exports.sub

exportific.js

34;recast&39;=&39;exports&39;exports&!/usr/bin/env nodeconst recast = require(&34;);const { identifier: id, expressionStatement, memberExpression, assignmentExpression, arrowFunctionExpression} = recast.types.buildersconst fs = require(&39;)const path = require(&39;)// 截取參數const options = process.argv.slice(2)//如果沒有參數,或提供了-h 或--help選項,則列印幫助if(options.length===0 || options.includes(&39;) || options.includes(&39;)){ console.log(` 採用commonjs規則,將.js文件內所有函數修改為導出形式。 選項: -r 或 --rewrite 可直接覆蓋原有文件 `) process.exit(0)}// 只要有-r 或--rewrite參數,則rewriteMode為truelet rewriteMode = options.includes(&39;) || options.includes(&39;)// 獲取文件名const clearFileArg = options.filter((item)=>{ return ![&39;,&39;,&39;,&39;].includes(item)})// 只處理一個文件let filename = clearFileArg[0]const writeASTFile = function(ast, filename, rewriteMode){ const newCode = recast.print(ast).code if(!rewriteMode){ // 非覆蓋模式下,將新文件寫入*.export.js下 filename = filename.split(&39;).slice(0,-1).concat([&39;,&39;]).join(&39;) } // 將新代碼寫入文件 fs.writeFileSync(path.join(process.cwd(),filename),newCode)}recast.run(function (ast, printSource) { let funcIds = [] recast.types.visit(ast, { visitFunctionDeclaration(path) { //獲取遍歷到的函數名、參數、塊級域 const node = path.node const funcName = node.id const params = node.params const body = node.body funcIds.push(funcName.name) const rep = expressionStatement(assignmentExpression(&39;, memberExpression(id(&39;), funcName), arrowFunctionExpression(params, body))) path.replace(rep) return false } }) recast.types.visit(ast, { visitCallExpression(path){ const node = path.node; if (funcIds.includes(node.callee.name)) { node.callee = memberExpression(id(&39;), node.callee) } return false } }) writeASTFile(ast,filename,rewriteMode)})

現在嘗試一下

node exportific demo.js

已經可以在當前目錄下找到源碼變更後的demo.export.js文件了。

npm發包

編輯一下package.json文件

{ &34;: &34;, &34;: &34;, &34;: &34;, &34;: &34;, &34;: { &34;: &34; }, &34;: [], &34;: &34;, &34;: &34;, &34;: { &34;: &34; }}

注意bin選項,它的意思是將全局命令exportific指向當前目錄下的exportific.js

這時,輸入npm link 就在本地生成了一個exportific命令。

之後,只要哪個js文件想導出來使用,就exportific XXX.js一下。

這是在本地的玩法,想和大家一起分享這個前端小工具,只需要發布npm包就行了。

同時,一定要注意exportific.js文件頭有

E83E8C; --tt-darkmode-color: E83E8C; --tt-darkmode-color: #E83E8C;">npm publish

沒有任何繁瑣步驟,絲毫審核都沒有,你就發布了一個實用的前端小工具exportific 。任何人都可以通過

npm i exportific -g

全局安裝這一個插件。

提示:==在試驗教程時,請不要和我的包重名,修改一下發包名稱。==

結語

我們對javascript再熟悉不過,但透過AST的視角,最普通的js語句,卻煥發出驚心動魄的美感。你可以通過它批量構建任何javascript代碼!

童年時,這個世界充滿了新奇的玩具,再普通的東西在你眼中都如同至寶。如今,計算機語言就是你手中的大玩具,一段段AST對象的拆分組裝,構建出我們所生活的網絡世界。

相關焦點

  • 旖美信息技術前端開發經理陳國興:抽象語法樹與Javascript
    在「前端工程實踐」分會場,旖美信息技術前端開發經理陳國興帶來了《抽象語法樹與JavaScript》的主題分享。會後,51CTO記者根據陳國興在WOT2018全球軟體與運維技術峰會的演講內容進行了整理。何為抽象語法樹抽象語法樹是程序代碼語法的抽象表示,以樹狀的形式表現程式語言的語法結構。
  • 英語最基礎的語法知識 2019高考英語必考重點語法知識總結
    英語最基礎的語法知識 2019高考英語必考重點語法知識總結在高中英語學習中有很多重要的基礎語法知識需要我們掌握,下面小編為大家整理一些2019高考英語必考重點語法知識,供參考!高中基礎語法知識總結一.表語從句1. 定義:用作表語的從句叫做表語從句。2.
  • 30k反爬大神總結了一份AST語法詳解!★
    首先我們得知道為什麼要了解AST大家都知道反爬蟲中JS是最難的,要想破解JS逆向,就必須要掌握AST語法,目前常用的一些前端插件或者工具 比如:javascript轉譯,代碼壓縮,css預處理器,elint,pretiier等功能的實現都是建立在AST的基礎之上。
  • 30k反爬大神總結了一份AST語法詳解!建議收藏!
    首先我們得知道為什麼要了解AST大家都知道反爬蟲中JS是最難的,要想破解JS逆向,就必須要掌握AST語法,目前常用的一些前端插件或者工具 比如:javascript轉譯,代碼壓縮,css預處理器,elint,pretiier等功能的實現都是簡歷在AST的基礎之上。
  • Python 中最流行的十個標準庫
    如何解析 Python 原始碼提取原始碼中信息的方法之一是將它們轉換成抽象語法樹(Abstract Syntax Tree,AST)。然後,我們就可以遍歷這棵樹,並收集目標節點。但最重要的一點是,我們只想收集 Python 標準庫,而不是所有在倉庫中使用的包,比如本地定義的庫,它們只有在倉庫中才有意義。
  • 用於代碼生成的基於樹的Transformer結構
    隨著深度學習技術的發展,研究人員已針對此問題應用了各種神經體系結構,例如序列到序列(Seq2Seq)模型或序列到樹(Seq2Tree)模型。尤其是,最先進的方法通過預測語法規則序列來生成代碼。也就是說,系統保留已生成代碼的部分抽象語法樹(AST),並預測將用於擴展特定節點的語法規則。語法規則的分類面臨兩個主要挑戰。第一個挑戰是長時依賴問題。代碼元素可能取決於另一個遙遠的元素。
  • Css語法特點是什麼?Css和html、javascript的關係是什麼?
    cssCss語法特點是什麼?Css樣式表主要由很多樣式規則組成,規則主要由兩部分構成+選擇器+一條或多條聲明(屬性和值)。Css和html、javascriptCss和html、javascript的關係是什麼?
  • 建立JavaScript知識架構
    知識架構,我們可以把它理解為知識的「目錄」或者索引,它能夠幫助我們把零散的知識組織起來,也能夠幫助我們發現一些知識上的盲區。當然,知識的架構是有優劣之分的,最重要的就是邏輯性和完備性。如果我們要給JavaScript知識做一個頂層目錄,該怎麼做呢?
  • HTML5 語法基礎二(筆記)
    HTML5 語法基礎二(筆記)一、 HTML 語法簡介1、HTML 介紹1-1、HTML的全稱1二、HTML 語句的基本格式1、XHTML1.0 語法公式2、HTML5.0 語句基本格式及建議1、XHTML1.0 語法基本格式<標籤 屬性="屬性值" 屬性="屬性值">內容標籤>
  • 第一篇:JavaScript基本語法
    html><head><title>這是登錄頁面</title><meta http-equiv="Content-Type" content="text/html; charset=utf-8" /><script type="text/javascript
  • JavaScript基礎——前端不懂它,會再多框架也不過只是會用而已!
    在介紹JavaScript編譯器原理之前,小編和大家一起重溫下基本的編譯器原理,因為這是最基礎的,了解清楚了我們更能了解JavaScript編譯器。編譯程序一般步驟分為:詞法分析、語法分析、語義檢查、代碼優化和生成字節碼。具體的編譯流程如下圖:分詞/詞法分析(Tokenizing/Lexing)所謂的分詞,就好比我們將一句話,按照詞語的最小單位進行分割。
  • 高考英語13個語法考點 高中英語重點語法知識歸納
    高考英語13個語法考點 高中英語重點語法知識歸納很多高中生不知道英語語法方面有哪些重點知識,高考英語經常出現的語法考點有哪些呢?下面小編為大家介紹一下!高考容易出現的13個語法知識總結1.
  • 如何用Mocha測試JavaScript - 基礎知識
    Mocha是最受歡迎的Node.js測試框架之一,它看起來很令人生畏,實際上卻很容易入門。本教程是關於Mocha基礎知識。在本教程結束之前,我們將成功地使用Mocha編寫第一個測試。我們會了解如何設置Mocha,如何分組測試以及如何使用斷言庫。明天我將發布第2部分,其重點是更高級的測試技術,以及將測試與實際的代碼整合在一起! Mocha的文檔還不錯。
  • javascript進階必備的二叉樹知識
    許多實際問題抽象出來的數據結構往往是二叉樹形式,即使是一般的樹也能簡單地轉換為二叉樹,而且二叉樹的存儲結構及其算法都較為簡單,因此二叉樹顯得特別重要。二叉樹特點是每個結點最多只能有兩棵子樹,且有左右之分。二叉樹中的節點最多只能有兩個子節點:左側子節點和右側子節點。我們接下來主要來實現一個二叉搜索樹(BST)。
  • 初中英語:全冊語法知識「網絡圖」
    初中英語:全冊語法知識「網絡圖」在英語的學習中,單詞和語法就是學習的重點,相比之下,語法知識的掌握要比單詞要難得多,語法知識之間環環相扣,要是其中一個很小部分的知識沒有掌握好的話,那麼後續的英語學習是會受到影響的。
  • 木蘭程式語言重現——範圍語法「下限..上限 by 間隔」,重構
    在支持 Python 的 range 之外,木蘭還支持如下的等價語法。測試木蘭測試用例:900 -> 1068運行測試.py,檢驗所有木蘭測試代碼片段:143 -> 144未變test語法樹.py,確保生成的語法樹與原始版本一致:63實現:1720 -> 1898分析器/語法分析器