團隊:skFeTeam 本文做者:李世偉html
做爲前端程序員,webpack,rollup,babel,eslint這些是否是常常用到?他們是打包工具,代碼編譯工具,語法檢查工具。他們是如何實現的呢?本文介紹的抽象語法樹,就是他們用到的技術,是否是應該瞭解一下呢?前端
本文沒有晦澀難懂的理論,也沒有大段大段的代碼,徹底從零開始,小白閱讀也無任何障礙。經過本文的閱讀,您將會了解AST的基本原理以及使用方法。node
什麼是抽象語法樹?webpack
- AST(Abstract Syntax Tree)是源代碼的抽象語法結構樹狀表現形式。下面這張圖示意了一段JavaScript代碼的抽象語法樹的表現形式。
抽象語法樹有什麼用呢?git
- IDE的錯誤提示、代碼格式化、代碼高亮、代碼自動補全等
- JSLint、JSHint、ESLint對代碼錯誤或風格的檢查等
- webpack、rollup進行代碼打包等
- Babel 轉換 ES6 到 ES5 語法
- 注入代碼統計單元測試覆蓋率
AST是如何生成的?程序員
- 可以將JavaScript源碼轉化爲抽象語法樹(AST)的工具叫作JS Parser解析器。
JS Parser的解析過程包括兩部分github
- 詞法分析(Lexical Analysis):將整個代碼字符串分割成最小語法單元數組
- 語法分析(Syntax Analysis):在分詞基礎上創建分析語法單元之間的關係
常見的AST parserweb
- 早期有uglifyjs和esprima
- Espree,基於esprima,用於eslint
- Acorn,號稱是相對於esprima性能更優,體積更小
- Babylon,出自acorn,用於babel
- Babel-eslint,babel團隊維護,用於配合使用ESLint
語法單元是被解析語法當中具有實際意義的最小單元,簡單的來理解就是天然語言中的詞語。chrome
Javascript 代碼中的語法單元主要包括如下這麼幾種:express
- 關鍵字:例如 var、let、const等
- 標識符:沒有被引號括起來的連續字符,多是一個變量,也多是 if、else 這些關鍵字,又或者是 true、false 這些內置常量
- 運算符: +、-、 *、/ 等
- 數字:像十六進制,十進制,八進制以及科學表達式等
- 字符串:由於對計算機而言,字符串的內容會參與計算或顯示
- 空格:連續的空格,換行,縮進等
- 註釋:行註釋或塊註釋都是一個不可拆分的最小語法單元
- 其餘:大括號、小括號、分號、冒號等
組合分詞的結果,肯定詞語之間的關係,肯定詞語最終的表達含義,生成抽象語法樹。
var a = 1;
複製代碼
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "1"
},
{
"type": "Punctuator",
"value": ";"
}
]
複製代碼
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
}
],
"sourceType": "script"
}
複製代碼
- 經典的JavaScript抽象語法樹解析器,網站提供的功能也很是豐富
- 能夠在線查看分詞和抽象語法樹
- Syntax展現抽象語法樹,Tokens展現分詞
- 還提供了各類parse的性能比較,看起來Acorn的性能更優秀一點。
- AST的可視化工具網站,可使用各類parse對代碼進行AST轉換
AST解析規範(The Estree Spec)
- 相同的JavaScript代碼,經過各類parser解析的AST結果都是同樣的,這是由於他們都參照了一樣的AST解析規範
- The Estree Spec 規範是 Mozilla 的工程師給出的 SpiderMonkey 引擎輸出的 JavaScript AST 的規範文檔,也能夠參考:SpiderMonkey in MDN
前面已經介紹了AST的內容,下面咱們來看看babel是如何使用AST的。
Babel的工做過程通過三個階段,parse、transform、generate
- parse階段,將源代碼轉換爲AST
- transform階段,利用各類插件進行代碼轉換
- generator階段,再利用代碼生成工具,將AST轉換成代碼
Parse-解析
- Babel 使用 @babel/parser 解析代碼,輸入的 js 代碼字符串根據 ESTree 規範生成 AST
- Babel 使用的解析器是 babylon
Transform-轉換
- 接收 AST 並對其進行遍歷,在此過程當中對節點進行添加、更新及移除等操做。也是Babel插件接入工做的部分。
- Babel提供了@babel/traverse(遍歷)方法維護AST樹的總體狀態,方法的參數爲原始AST和自定義的轉換規則,返回結果爲轉換後的AST。
Generator-生成
- 代碼生成步驟把最終(通過一系列轉換以後)的 AST 轉換成字符串形式的代碼,同時還會建立源碼映射(source maps)。
- 遍歷整個 AST,而後構建能夠表示轉換後代碼的字符串。
- Babel使用 @babel/generator 將修改後的 AST 轉換成代碼,生成過程能夠對是否壓縮以及是否刪除註釋等進行配置,而且支持 sourceMap。
瞭解了babel的運行原理,咱們根據babel的三個步驟來動手寫一個demo,加深對AST的理解。
- 把 == 改成全等 ===
- 把parseInt(a) 改成 parseInt(a,10)
轉換前的代碼,before.js:
function fun1(opt) {
if (opt.status == 1) {
console.log('1');
}
}
function fun2(age) {
if (parseInt(age) >= 18) {
console.log('2');
}
}
複製代碼
指望轉換後的代碼,after.js:
function fun1(opt) {
if (opt.status === 1) {//==變成===
console.log('1');
}
}
function fun2(age) {
if (parseInt(age, 10) >= 18) {//parseInt(a)變成parseInt(a,10)
console.log('2');
}
}
複製代碼
//引入工具包
const esprima = require('esprima');//JS語法樹模塊
const estraverse = require('estraverse');//JS語法樹遍歷各節點
const escodegen = require('escodegen');//JS語法樹反編譯模塊
const fs = require('fs');//讀寫文件
複製代碼
const before = fs.readFileSync('./before.js', 'utf8');
const ast = esprima.parseScript(before);
複製代碼
estraverse.traverse(ast, {
enter: (node) => {
toEqual(node);//把 == 改成全等 ===
setParseInt(node); //把 parseInt(a) 改成 parseInt(a,10)
}
});
複製代碼
function toEqual(node) {
if (node.operator === '==') {
node.operator = '===';
}
}
function setParseInt(node) {
//判斷節點類型,方法名稱,方法的參數的數量,數量爲1就增長第二個參數
if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
node.arguments.push({//增長參數,其實就是數組操做
"type": "Literal",
"value": 10,
"raw": "10"
});
}
}
複製代碼
//生成目標代碼
const code = escodegen.generate(ast);
//寫入文件
fs.existsSync('./after.js') && fs.unlinkSync('./after.js');
fs.writeFileSync('./after.js', code, 'utf8');
複製代碼
好了,打開after.js文件看看,是否是已經轉換成功了?是否是和咱們指望的同樣?有沒有一種babel的感受?是的,其實babel也是這麼作的,只不過它的轉換規則函數至關的複雜,由於須要考慮各類JavaScript的語法狀況,工做量巨大,這也就是babel最核心的地方。
再回頭看看咱們寫的demo,是徹底遵循babel的三個步驟來作的。第一步parse和第三步generate都很是簡單,一句話的事,沒什麼好說的。重點是Transform,轉換規則函數的實現,有人可能會問,你怎麼知道,toEqual和setParseInt轉換函數要這麼寫呢?
好的,爲了回答這個問題,咱們來看看這兩個規則的代碼轉換先後的AST就明白了。
- 把 == 改成全等 ===
a==b的AST以下:
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "BinaryExpression",
"operator": "==",
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
"type": "Identifier",
"name": "b"
}
}
}
],
"sourceType": "script"
}
複製代碼
a===b的AST以下:
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "BinaryExpression",
"operator": "===",
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
"type": "Identifier",
"name": "b"
}
}
}
],
"sourceType": "script"
}
複製代碼
比較上面兩個AST,是否是隻有一個"operator"字段有區別,一個是==, 另外一個是===。
再來看看toEqual函數,是否是明白了?只要修改一下node.operator的值就能完成轉換了。
function toEqual(node) {
if (node.operator === '==') {
node.operator = '===';
}
}
複製代碼
- 把parseInt(a) 改成 parseInt(a,10)
parseInt(a)的AST以下:
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "parseInt"
},
"arguments": [
{
"type": "Identifier",
"name": "a"
}
]
}
}
],
"sourceType": "script"
}
複製代碼
parseInt(a, 10)的AST以下:
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "Identifier",
"name": "parseInt"
},
"arguments": [
{
"type": "Identifier",
"name": "a"
},
{
"type": "Literal",
"value": 10,
"raw": "10"
}
]
}
}
],
"sourceType": "script"
}
複製代碼
比較這兩個AST,看到了嗎?只是arguments數組多了下面這個元素。
{
"type": "Literal",
"value": 10,
"raw": "10"
}
複製代碼
因此在轉換規則函數中,咱們把這個元素加進去就能實現轉換了。是否是很是簡單?
function setParseInt(node) {
//判斷節點類型,方法名稱,方法的參數的數量,數量爲1就增長第二個參數
if (node.type === 'CallExpression' && node.callee.name === 'parseInt' && node.arguments.length === 1) {
node.arguments.push({//增長參數,其實就是數組操做
"type": "Literal",
"value": 10,
"raw": "10"
});
}
}
複製代碼
好了,到此爲止,這個Demo應該徹底理解了吧。
看到這裏,你已經明白了AST的原理以及使用方法。下面來看一道題目,檢驗一下學習成果。
假設a是一個對象,var a = { b : 1},那麼a.b和a['b'] ,哪一個性能更高呢?
a.b和a['b']的寫法,你們常常會用到,也許沒有注意過這兩種寫法會有性能差別。事實上,有人作過測試,二者的性能差距不大,a.b會比a['b']性能稍微好一點。那麼,爲何a.b比a['b']性能稍微好一點呢?
我認爲,a.b能夠直接解析b爲a的屬性,而a['b']可能會多一個判斷的過程,由於[]裏面的內容多是一個變量,也多是個常量。
這種說法看起來好像頗有道理,事實上是否是這樣呢?有沒有什麼證據來證實這個說法嗎?
好吧,要想解釋清楚這個問題,就只能從V8引擎提及了。
js代碼能在cpu上運行,主要是js引擎的功勞,V8引擎是google開發,應用在chrome瀏覽器和nodejs上,是一個經典的js引擎。上圖能夠看出,在V8引擎中,js從源代碼到機器碼的轉譯主要有三個步驟:Parser(AST) ->Ignition(Bytecode)->TurboFan(Machine Code)
- Parser:負責將JavaScript源碼轉換爲Abstract Syntax Tree (AST)
- Ignition:interpreter,即解釋器,負責將AST轉換爲Bytecode,解釋執行Bytecode;同時收集TurboFan優化編譯所需的信息,好比函數參數的類型
- TurboFan:compiler,即編譯器,利用Ignitio所收集的類型信息,將Bytecode轉換爲優化的彙編代碼
Parser-AST解析器
Ignition-解釋器
TurboFan-編譯器
如今,咱們就來比較一下a.b和a['b']在V8的解析下,到底有什麼不一樣
function test001() {
var a = { b: 1 };
console.log(a.b)
}
test001();
複製代碼
function test002() {
var a = { b: 1 };
console.log(a['b'])
}
test002();
複製代碼
先看下他們生成的Bytecode
[generated bytecode for function: test001]
Parameter count 1
Frame size 32
16 E> 000001F6C03D7192 @ 0 : a0 StackCheck
33 S> 000001F6C03D7193 @ 1 : 79 00 00 29 fa CreateObjectLiteral [0], [0], #41, r1
000001F6C03D7198 @ 6 : 27 fa fb Mov r1, r0
46 S> 000001F6C03D719B @ 9 : 13 01 01 LdaGlobal [1], [1]
000001F6C03D719E @ 12 : 26 f9 Star r2
54 E> 000001F6C03D71A0 @ 14 : 28 f9 02 03 LdaNamedProperty r2, [2], [3]
000001F6C03D71A4 @ 18 : 26 fa Star r1
60 E> 000001F6C03D71A6 @ 20 : 28 fb 03 05 LdaNamedProperty r0, [3], [5]
000001F6C03D71AA @ 24 : 26 f8 Star r3
54 E> 000001F6C03D71AC @ 26 : 57 fa f9 f8 07 CallProperty1 r1, r2, r3, [7]
000001F6C03D71B1 @ 31 : 0d LdaUndefined
63 S> 000001F6C03D71B2 @ 32 : a4 Return
Constant pool (size = 4)
Handler Table (size = 0)
複製代碼
[generated bytecode for function: test002]
Parameter count 1
Frame size 32
16 E> 0000022E1C7D6DC2 @ 0 : a0 StackCheck
33 S> 0000022E1C7D6DC3 @ 1 : 79 00 00 29 fa CreateObjectLiteral [0], [0], #41, r1
0000022E1C7D6DC8 @ 6 : 27 fa fb Mov r1, r0
46 S> 0000022E1C7D6DCB @ 9 : 13 01 01 LdaGlobal [1], [1]
0000022E1C7D6DCE @ 12 : 26 f9 Star r2
54 E> 0000022E1C7D6DD0 @ 14 : 28 f9 02 03 LdaNamedProperty r2, [2], [3]
0000022E1C7D6DD4 @ 18 : 26 fa Star r1
59 E> 0000022E1C7D6DD6 @ 20 : 28 fb 03 05 LdaNamedProperty r0, [3], [5]
0000022E1C7D6DDA @ 24 : 26 f8 Star r3
54 E> 0000022E1C7D6DDC @ 26 : 57 fa f9 f8 07 CallProperty1 r1, r2, r3, [7]
0000022E1C7D6DE1 @ 31 : 0d LdaUndefined
66 S> 0000022E1C7D6DE2 @ 32 : a4 Return
Constant pool (size = 4)
Handler Table (size = 0)
複製代碼
比較一下二者的Bytecode,你會發現它們徹底相同,這就說明,這兩種寫法在Bytecode層及如下的執行,性能是沒有差異的。事實上,它們有差異,就只能往上找,上面就只有Parser階段了。咱們再來看看它們的AST有什麼區別。
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "MemberExpression",
"computed": false,
"object": {
"type": "Identifier",
"name": "a"
},
"property": {
"type": "Identifier",
"name": "b"
}
}
}
],
"sourceType": "script"
}
複製代碼
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "MemberExpression",
"computed": true,
"object": {
"type": "Identifier",
"name": "a"
},
"property": {
"type": "Literal",
"value": "b",
"raw": "'b'"
}
}
}
],
"sourceType": "script"
}
複製代碼
咱們發現惟一的區別就是"computed"屬性,a.b是false,a['b']是true,說明在解析成AST時,a['b']比a.b多了一個計算的過程。由此咱們判定,二者微小的差別應該就在這裏。好了,證據找到了,如今應該沒有疑問了吧。
看到這裏,你不但瞭解了AST的相關知識,還知道了V8引擎是如何解析js代碼的,是否是有所收穫呢?若是你以爲這篇文章對你有用,還請順便點個贊,很是感謝(90度鞠躬)。