Javascrip—AST抽象語法樹(8)

前端啟航發表於2018-10-19

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

本文將帶大家從底層瞭解AST,並且通過釋出一個小型前端工具,來帶大家瞭解AST的強大功能

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: '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函式拆解完畢,用圖表示就是

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

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

請檢視 AST物件文件

送給你的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: ...
}
複製程式碼

你也可以繼續使用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.
複製程式碼

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 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()

read.js

#!/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);
      }
    });
});
複製程式碼

read.js

#!/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);
      }
    });
});
複製程式碼

命令列輸入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優雅地解決問題==。

查詢AST物件文件

首先,我們先用builders憑空實現一個鍵頭函式 exportific.js

#!/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

exportific.js

#!/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前端工具 上面講了那麼多,仍然只體現在理論階段。

但通過簡單的改寫,就能通過recast製作成一個名為exportific的原始碼編輯工具。

以下程式碼新增作了兩個小改動

1.新增說明書--help,以及新增了--rewrite模式,可以直接覆蓋檔案或預設為匯出*.export.js檔案。 2.將之前程式碼最後的 printSource(ast)替換成 writeASTFile(ast,filename,rewriteMode)

** exportific.js**

#!/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一下。

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

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

#!/usr/bin/env node
複製程式碼

否則在使用時將報錯。

接下來,正式釋出npm包! 如果你已經有了npm 帳號,請使用npm login登入

如果你還沒有npm帳號 www.npmjs.com/signup 非常簡單就可以註冊npm

然後,輸入 npm publish

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

npm i exportific -g
複製程式碼

全域性安裝這一個外掛。

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

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

這裡推薦一下我的前端學習交流群:784783012,裡面都是學習前端的,如果你想製作酷炫的網頁,想學習程式設計。自己整理了一份2018最全面前端學習資料,從最基礎的HTML+CSS+JS【炫酷特效,遊戲,外掛封裝,設計模式】到移動端HTML5的專案實戰的學習資料都有整理,送給每一位前端小夥伴,有想學習web前端的,或是轉行,或是大學生,還有工作中想提升自己能力的,正在學習的小夥伴歡迎加入學習。

相關文章