天天看點

AST抽象文法樹 JavaScript版

在javascript世界中,你可以認為抽象文法樹(AST)是最底層。 再往下,就是關于轉換和編譯的“黑魔法”領域了。

現在,我們拆解一個簡單的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檔案

// 給你一把"螺絲刀"——recast
const 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: ...
}
           

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, // 匿名化函數表達式.
    add.params,
    add.body
  ))
]);

//将AST對象重新轉回可以閱讀的代碼
//這一行其實是recast.parse的逆向過程,具體公式為
//recast.print(recast.parse(source)).code === source
const output = recast.print(ast).code;

console.log(output)
           

列印出來還保留着“原裝”的函數内容,連注釋都沒有變。

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

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

//輸出為
const add = function(a, b) {
  return a + b;
};
           

實戰進階:指令行修改js檔案

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

  • run: 通過指令行讀取js檔案,并轉化成ast以供處理。
  • tnt: 通過assert()和check(),可以驗證ast對象的類型。
  • visit: 周遊ast樹,擷取有效的AST對象并進行更改。

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

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節點周遊

#!/usr/bin/env node
const 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 node
const 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()

#!/usr/bin/env node
const 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);
      }
    });
});
           
#!/usr/bin/env node
const 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);
      }
    });
});
           

實戰:用AST修改源碼,導出全部方法

exportific.js

現在,我們想讓這個檔案中的函數改寫成能夠全部導出的形式,例如

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

想改變為

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

首先,我們先用builders憑空實作一個鍵頭函數

#!/usr/bin/env node
const 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

#!/usr/bin/env node
const 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前端工具

以下代碼添加作了兩個小改動

  1. 添加說明書--help,以及添加了--rewrite模式,可以直接覆寫檔案或預設為導出*.export.js檔案。
  2. 将之前代碼最後的 printSource(ast)替換成 writeASTFile(ast,filename,rewriteMode)

#!/usr/bin/env node
const 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為true
let 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

一下。

一定要注意exportific.js檔案頭有

#!/usr/bin/env node

接下來,正式釋出npm包!

如果你已經有了npm 帳号,請使用

npm login

登入

如果你還沒有npm帳号 https://www.npmjs.com/signup 非常簡單就可以注冊npm

然後,輸入

npm publish

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

npm i exportific -g
           

全局安裝這一個插件。

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

#!/usr/bin/env node

不同使用者或者不同的腳本解釋器有可能安裝在不同的目錄下,系統如何知道要去哪裡找你的解釋程式呢?

/usr/bin/env

就是告訴系統可以在PATH目錄中查找。 是以配置

#!/usr/bin/env node

, 就是解決了不同的使用者node路徑不同的問題,可以讓系統動态的去查找node來執行你的腳本檔案。

如果出現

No such file or directory

的錯誤?因為你的node安裝路徑沒有添加到系統的PATH中。是以去進行node環境變量配置就可以了。

要是你隻是想簡單的測試一下,那麼你可以通過

which node

指令來找到你本地的node安裝路徑,将

/usr/bin/env

改為你查找到的node路徑即可。

參考文章:https://segmentfault.com/a/1190000016231512?utm_source=tag-newest#comment-area

文章都是本人學習時的筆記整理,希望看完後能對您有所幫助,歡迎大家提意見,多多交流。

也有些文章是轉載的,如果存在轉載文章且沒有标注轉載位址的,請與我聯系,馬上處理。

自由轉載-非商用-非衍生-保持署名。

繼續閱讀