AST抽象語法樹:最基礎的 Javascript 重點知識

(給前端大全加星標,提高前端技能)javascript

做者:劉羽衝前端

segmentfault.com/a/1190000016231512vue

抽象語法樹(AST),是一個很是基礎而重要的知識點,但國內的文檔卻幾乎一片空白。java

本文將帶你們從底層瞭解AST,而且經過發佈一個小型前端工具,來帶你們瞭解AST的強大功能node

Javascript就像一臺精妙運做的機器,咱們能夠用它來完成一切天馬行空的構思。react

咱們對javascript生態瞭如指掌,卻常忽視javascript自己。這臺機器,到底是哪些零部件在支持着它運行?webpack

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

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

人生第一次拆解Javascript

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

咱們把機器,拆成一個一個小零件,一個個齒輪與螺釘,用巧妙的機械原理銜接在一塊兒…

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

經過抽象語法樹解析,咱們能夠像童年時拆解玩具同樣,透視Javascript這臺機器的運轉,而且從新按着你的意願來組裝。

如今,咱們拆解一個簡單的add函數
function add(a, b) {
    return a + b
}

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

用力拆開,它成了三塊:

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

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

{
    name: 'add'
    type: 'identifier'
    ...
}

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

[
    {
        name: 'a'
        type: 'identifier'
        ...
    },
    {
        name: 'b'
        type: 'identifier'
        ...
    }
]

接下來,咱們繼續拆開body

咱們發現,body實際上是一個BlockStatement(塊狀域)對象,用來表示是{return a + b}

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

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

繼續打開BinaryExpression,它成了三部分,left,operator,right

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

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

圖片

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

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

送給你的AST螺絲刀:recast

輸入命令:

npm i recast -S

你便可得到一把操縱語法樹的螺絲刀

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

parse.js

// 給你一把"螺絲刀"——recastconst recast = require("recast");// 你的"機器"——一段代碼// 咱們使用了很奇怪格式的代碼,想測試是否能維持代碼結構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: 'FunctionDeclaration',
    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("const", [
  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

#!/usr/bin/env nodeconst recast  = require('recast')
recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function({node}) {
        console.log(node)
        return false
      }
    });
});

recast.visit將AST對象內的節點進行逐個遍歷。

注意

  • 你想操做函數聲明,就使用visitFunctionDelaration遍歷,想操做賦值表達式,就使用visitExpressionStatement。 只要在 AST對象文檔中定義的對象,在前面加visit,便可遍歷。
  • 經過node能夠取到AST對象
  • 每一個遍歷函數後必須加上return false,或者選擇如下寫法,不然報錯:
#!/usr/bin/env nodeconst recast  = require('recast')
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進行測試。

#!/usr/bin/env node

在全部使用recast.run()的文件頂部都須要加入這一行,它的意義咱們最後再討論。

TNT —— 判斷AST對象類型

TNT,即recast.types.namedTypes,就像它的名字同樣火爆,它用來判斷AST對象是否爲指定的類型。

TNT.Node.assert(),就像在機器裏埋好的炸藥,當機器不能無缺運轉時(類型不匹配),就炸燬機器(報錯退出)

TNT.Node.check(),則能夠判斷類型是否一致,並輸出False和True

上述Node能夠替換成任意AST對象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

read.js

#!/usr/bin/env nodeconst recast = require("recast");const TNT = recast.types.namedTypes
recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.value
        // 判斷是否爲ExpressionStatement,正確則輸出一行字。
        if(TNT.ExpressionStatement.check(node)){
          console.log('這是一個ExpressionStatement')
        }
        this.traverse(path);
      }
    });
});

read.js

#!/usr/bin/env nodeconst recast = require("recast");const TNT = recast.types.namedTypes
recast.run(function(ast, printSource) {
  recast.visit(ast, {
      visitExpressionStatement: function(path) {
        const node = path.node
        // 判斷是否爲ExpressionStatement,正確不輸出,錯誤則全局報錯
        TNT.ExpressionStatement.assert(node)
        this.traverse(path);
      }
    });
});

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

實戰:用AST修改源碼,導出所有方法

exportific.js

如今,咱們但願將demo中的function所有

咱們想讓這個文件中的函數改寫成可以所有導出的形式,例如

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

想改變爲

exports.add = (a, b) => {
  return a + b
}

除了使用fs.read讀取文件、正則匹配替換文本、fs.write寫入文件這種笨拙的方式外,咱們能夠==用AST優雅地解決問題==。

首先,咱們先用builders憑空實現一個鍵頭函數

exportific.js

#!/usr/bin/env nodeconst recast = require("recast");const {
  identifier:id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression,
  blockStatement} = recast.types.builders
recast.run(function(ast, printSource) {
  // 一個塊級域 {}
  console.log('\n\nstep1:')
  printSource(blockStatement([]))

  // 一個鍵頭函數 ()=>{}
  console.log('\n\nstep2:')
  printSource(arrowFunctionExpression([],blockStatement([])))

  // add賦值爲鍵頭函數  add = ()=>{}
  console.log('\n\nstep3:')
  printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))

  // exports.add賦值爲鍵頭函數  exports.add = ()=>{}
  console.log('\n\nstep4:')
  printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
    arrowFunctionExpression([],blockStatement([])))))
});

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

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

接下來,只須要在得到的最終的表達式中,把id(‘add’)替換成遍歷獲得的函數名,把參數替換成遍歷獲得的函數參數,把blockStatement([])替換爲遍歷獲得的函數塊級做用域,就成功地改寫了全部函數!

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

exportific.js

#!/usr/bin/env nodeconst recast = require("recast");const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression} = recast.types.builders
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)
      // 這是上一步推導出來的ast結構體
      const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
        arrowFunctionExpression(params, body)))
      // 將原來函數的ast結構體,替換成推導ast結構體
      path.replace(rep)
      // 中止遍歷
      return false
    }
  })


  recast.types.visit(ast, {
    // 遍歷全部的函數調用
    visitCallExpression(path){
      const node = path.node;
      // 若是函數調用出如今函數定義中,則修改ast結構
      if (funcIds.includes(node.callee.name)) {
        node.callee = memberExpression(id('exports'), node.callee)
      }
      // 中止遍歷
      return false
    }
  })
  // 打印修改後的ast源碼
  printSource(ast)
})
一步到位,發一個最簡單的exportific前端工具

上面講了那麼多,仍然只體如今理論階段。

但經過簡單的改寫,就能經過recast製做成一個名爲exportific的源碼編輯工具。

如下代碼添加做了兩個小改動

  • 添加說明書—help,以及添加了—rewrite模式,能夠直接覆蓋文件或默認爲導出*.export.js文件。
  • 將以前代碼最後的 printSource(ast)替換成 writeASTFile(ast,filename,rewriteMode)

exportific.js

#!/usr/bin/env nodeconst recast = require("recast");const {
  identifier: id,
  expressionStatement,
  memberExpression,
  assignmentExpression,
  arrowFunctionExpression} = recast.types.builders
const fs = require('fs')const path = require('path')// 截取參數const options = process.argv.slice(2)//若是沒有參數,或提供了-h 或--help選項,則打印幫助if(options.length===0 || options.includes('-h') || options.includes('--help')){
  console.log(`
    採用commonjs規則,將.js文件內全部函數修改成導出形式。

    選項: -r  或 --rewrite 可直接覆蓋原有文件
    `)
  process.exit(0)}// 只要有-r 或--rewrite參數,則rewriteMode爲truelet rewriteMode = options.includes('-r') || options.includes('--rewrite')// 獲取文件名const clearFileArg = options.filter((item)=>{
  return !['-r','--rewrite','-h','--help'].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('.').slice(0,-1).concat(['export','js']).join('.')
  }
  // 將新代碼寫入文件
  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('=', memberExpression(id('exports'), 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('exports'), node.callee)
      }
      return false
    }
  })

  writeASTFile(ast,filename,rewriteMode)
})

如今嘗試一下

node exportific demo.js

已經能夠在當前目錄下找到源碼變動後的demo.export.js文件了。

npm發包

編輯一下package.json文件

{
  "name": "exportific",
  "version": "0.0.1",
  "description": "改寫源碼中的函數爲可exports.XXX形式",
  "main": "exportific.js",
  "bin": {
    "exportific": "./exportific.js"
  },
  "keywords": [],
  "author": "wanthering",
  "license": "ISC",
  "dependencies": {
    "recast": "^0.15.3"
  }
}

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

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

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

這是在本地的玩法,想和你們一塊兒分享這個前端小工具,只須要發佈npm包就好了。

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

#!/usr/bin/env node

不然在使用時將報錯。

接下來,正式發佈npm包!

若是你已經有了npm 賬號,請使用npm login登陸

若是你尚未npm賬號 https://www.npmjs.com/signup ...

而後,輸入
npm publish

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

npm i exportific -g

全局安裝這一個插件。

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

結語

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

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

因此不得不說軟件工程師是一個幸福的工做,你心中住的仍然是那個午後的少年,永遠有無數新奇等你發現,永遠有無數夢想等你構建。

項目代碼:https://github.com/wanthering...

推薦閱讀

(點擊標題可跳轉閱讀)

前端與編譯原理:用 JS 寫一個 JS 解釋器

前端碼農之蛻變 — AST(抽象語法樹)

前端構建:3 類 13 種熱門工具的選型參考

以爲本文對你有幫助?請分享給更多人

關注「前端大全」加星標,提高前端技能

圖片