玩轉 ast- 手寫 babel 插件篇
AST
抽象語法樹是什麼?
-
抽象語法樹(Abstract Syntax Tree,AST)是源代碼語法結構的一種抽象表示
-
它以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構
-
每個包含 type 屬性的數據結構,都是一個 AST 節點;
以下是普通函數 function ast(){}, 轉換爲 ast 後的格式:
抽象語法樹用途有哪些?
-
代碼語法的檢查、代碼風格的檢查、代碼的格式化、代碼的高亮、代碼錯誤提示、代碼自動補全、實現一套代碼適配多端運行等等
-
優化變更代碼,改變代碼結構使達到想要的結構
webpack、Lint 等這些工具的原理都是通過 JavaScript Parser 把源代碼轉化爲一顆抽象語法樹(AST),通過操縱這顆樹,我們可以精準的定位到聲明語句、賦值語句、運算語句等等,實現對代碼的分析、優化、變更等操作。
JavaScript 解析器
那什麼是 JavaScript 解析器呢?
JavaScript 解析器的作用,把 JavaScript 源碼轉化爲抽象語法樹。
瀏覽器會把 js 源碼通過解析器轉換爲 ast,再進一步轉換爲字節碼或者直接生成機器碼,進行渲染和執行。
一般來說,每個 js 引擎都有自己的抽象語法樹格式,Chrome 的 v8 引擎, firfox 的 Spider Monkey 引擎等。
JavaScript 解析器通常可以包含四個組成部分。
-
詞法分析器(Lexical Analyser)
-
語法解析器(Syntax Parser)
-
字節碼生成器(Bytecode generator)
-
字節碼解釋器(Bytecode interpreter)
詞法分析器(Lexical Analyser)
首先詞法分析器會掃描(scanning)代碼,將一行行源代碼,通過 switch case 把源碼?“/ 爲一個個小單元,jS 代碼有哪些語法單元呢?大致有以下這些:
-
空白:JS 中連續的空格、換行、縮進等這些如果不在字符串裏,就沒有任何實際邏輯意義,所以把連續的空白符直接組合在一起作爲一個語法單元。
-
註釋:行註釋或塊註釋,雖然對於人類來說有意義,但是對於計算機來說知道這是個 “註釋” 就行了,並不關心內容,所以直接作爲一個不可再拆的語法單元
-
字符串:對於機器而言,字符串的內容只是會參與計算或展示,裏面再細分的內容也是沒必要分析的
-
數字:JS 語言裏就有 16、10、8 進制以及科學表達法等數字表達語法,數字也是個具備含義的最小單元
-
標識符:沒有被引號擴起來的連續字符,可包含字母、_、$、及數字(數字不能作爲開頭)。標識符可能代表一個變量,或者 true、false 這種內置常量、也可能是 if、return、function 這種關鍵字,是哪種語義,分詞階段並不在乎,只要正確切分就好了。
-
運算符:+、-、*、/、>、< 等等
-
括號:(...)可能表示運算優先級、也可能表示函數調用,分詞階段並不關注是哪種語義,只把 “(” 或“)”當做一種基本語法單元
就是一個字符一個字符地遍歷,然後通過 switch case 分情況討論,整個實現方法就是順序遍歷和大量的條件判斷。以 @babel/parser 源碼爲例:
getTokenFromCode(code) {
switch (code) {
case 46:
this.readToken_dot();
return;
case 40:
++this.state.pos;
this.finishToken(10);
return;
case 41:
++this.state.pos;
this.finishToken(11);
return;
case 59:
++this.state.pos;
this.finishToken(13);
return;
// 此處省略...
case 92:
this.readWord();
return;
default:
if (isIdentifierStart(code)) {
this.readWord(code);
return;
}
}
}
readToken_dot() {
// charCodeAt一個一個向後移
const next = this.input.charCodeAt(this.state.pos + 1);
if (next >= 48 && next <= 57) {
this.readNumber(true);
return;
}
if (next === 46 && this.input.charCodeAt(this.state.pos + 2) === 46) {
this.state.pos += 3;
this.finishToken(21);
} else {
++this.state.pos;
this.finishToken(16);
}
}
語法解析器
將上一步生成的數組,根據語法規則,轉爲抽象語法樹(Abstract Syntax Tree,簡稱 AST)。
以 const a = 1 爲例,詞法分析中獲得了 const 這樣一個 token,並判斷這是一個關鍵字,根據這個 token 的類型,判斷這是一個變量聲明語句。以 @babel/parser 源碼爲例,執行 parseVarStatement 方法。
parseVarStatement(node, kind, allowMissingInitializer = false) {
const {
isAmbientContext
} = this.state;
const declaration = super.parseVarStatement(node, kind, allowMissingInitializer || isAmbientContext);
if (!isAmbientContext) return declaration;
for (const {
id,
init
} of declaration.declarations) {
if (!init) continue;
if (kind !== "const" || !!id.typeAnnotation) {
this.raise(TSErrors.InitializerNotAllowedInAmbientContext, {
at: init
});
} else if (init.type !== "StringLiteral" && init.type !== "BooleanLiteral" && init.type !== "NumericLiteral" && init.type !== "BigIntLiteral" && (init.type !== "TemplateLiteral" || init.expressions.length > 0) && !isPossiblyLiteralEnum(init)) {
this.raise(TSErrors.ConstInitiailizerMustBeStringOrNumericLiteralOrLiteralEnumReference, {
at: init
});
}
}
return declaration;
}
經過這一步的處理,最終 const a = 1 會變成如下 ast 結構:
字節碼生成器
字節碼生成器的作用,是將抽象語法樹轉爲 JavaScript 引擎可以執行的二進制代碼。目前,還沒有統一的 JavaScript 字節碼的格式標準,每種 JavaScript 引擎都有自己的字節碼格式。最簡單的做法,就是將語義單位翻成對應的二進制命令。
字節碼解釋器
字節碼解釋器的作用是讀取並執行字節碼。
幾種常見的解析器
Esprima
這是第一個用 JavaScript 編寫的符合 EsTree 規範的 JavaScript 的解析器,後續多個編譯器都是受它的影響
acorn
acorn 和 Esprima 很類似,輸出的 ast 都是符合 EsTree 規範的,目前 webpack 的 AST 解析器用的就是 acorn
@babel/parser(babylon)
babel 官方的解析器,最初 fork 於 acorn,後來完全走向了自己的道路,從 babylon 改名之後,其構建的插件體系非常強大
uglify-js
**用於混淆和壓縮代碼,**因爲一些原因,uglify-js 自己 [內部實現了一套 AST 規範,也正是因爲它的 AST 是自創的,不是標準的 ESTree,es6 以後新語法的 AST,都不支持,所以沒有辦法壓縮最新的 es6 的代碼,如果需要壓縮,可以用類似 babel 這樣的工具先轉換成 ES5。
esbuild
esbuild 是用 go 編寫的下一代 web 打包工具,它擁有目前最快的打包記錄和壓縮記錄,snowpack 和 vite 的也是使用它來做打包工具,爲了追求卓越的性能,目前沒有將 AST 進行暴露,也無法修改 AST,無法用作解析對應的 JavaScript。
babel
下面先介紹下 babel 相關工具庫,以及一些 API,瞭解完這些基礎概念後,我們會利用這些工具操作 AST 來編寫一個 babel 插件。
-
@babel/parser 可以把源碼轉換成 AST
-
@babel/traverse 用於對 AST 的遍歷,維護了整棵樹的狀態,並且負責替換、移除和添加節點
-
@babel/generate 可以把 AST 生成源碼,同時生成 sourcemap
-
@babel/types 用於 AST 節點的 Lodash 式工具庫, 它包含了構造、驗證以及變換 AST 節點的方法,對編寫處理 AST 邏輯非常有用
-
@babel/template 可以簡化 AST 的創建邏輯
-
@babel/code-frame 可以打印代碼位置
-
@babel/core Babel 的編譯器,核心 API 都在這裏面,比如常見的 transform、parse, 並實現了插件功能
-
babylon Babel 的解析器,以前叫 babel parser, 是基於 acorn 擴展而來,擴展了很多語法, 可以支持 es2020、jsx、typescript 等語法
-
babel-types-api :https://babeljs.io/docs/en/babel-types.html
-
Babel 插件手冊:https://github.com/brigand/babel-plugin-handbook/blob/master/translations/zh-Hans/README.md#asts
Visitor
-
訪問者模式 Visitor 對於某個對象或者一組對象,不同的訪問者,產生的結果不同,執行操作也不同
-
Visitor 的對象定義了用於 AST 中獲取具體節點的方法
-
Visitor 上掛載以節點 type 命名的方法,當遍歷 AST 的時候,如果匹配上 type,就會執行對應的方法
path
-
node 當前 AST 節點
-
parent 父 AST 節點
-
parentPath 父 AST 節點的路徑
-
scope 作用域
-
get(key) 獲取某個屬性的 path
-
set(key, node) 設置某個屬性
-
is 類型 (opts) 判斷當前節點是否是某個類型
-
find(callback) 從當前節點一直向上找到根節點 (包括自己)
-
findParent(callback) 從當前節點一直向上找到根節點 (不包括自己)
-
insertBefore(nodes) 在之前插入節點
-
insertAfter(nodes) 在之後插入節點
-
replaceWith(replacement) 用某個節點替換當前節點
-
replaceWithMultiple(nodes) 用多個節點替換當前節點
-
replaceWithSourceString(replacement) 把源代碼轉成 AST 節點再替換當前節點
-
remove() 刪除當前節點
-
traverse(visitor, state) 遍歷當前節點的子節點, 第 1 個參數是節點,第 2 個參數是用來傳遞數據的狀態
-
skip() 跳過當前節點子節點的遍歷
-
stop() 結束所有的遍歷
-
getSibling(key) 獲取某個下標的兄弟節點
-
getNextSibling() 獲取下一個兄弟節點
-
getPrevSibling() 獲取上一個兄弟節點
-
getAllPrevSiblings() 獲取之前的所有兄弟節點
-
getAllNextSiblings() 獲取之後的所有兄弟節點
scope
• scope.bindings 當前作用域內聲明所有變量
• scope.path 生成作用域的節點對應的路徑
• scope.references 所有的變量引用的路徑
• getAllBindings() 獲取從當前作用域一直到根作用域的集合
• getBinding(name) 從當前作用域到根使用域查找變量
• getOwnBinding(name) 在當前作用域查找變量
• parentHasBinding(name, noGlobals) 從當前父作用域到根使用域查找變量
• removeBinding(name) 刪除變量
• hasBinding(name, noGlobals) 判斷是否包含變量
• moveBindingTo(name, scope) 把當前作用域的變量移動到其它作用域中
• generateUid(name) 生成作用域中的唯一變量名, 如果變量名被佔用就在前面加下劃線
• scope.dump() 打印自底向上的 作用域與變量信息到控制檯
實現 eslint 插件
上面這些概念在我們編寫 babel 插件時會用到,接下來我們來實現一個 eslint 移除 console.log() 插件吧!
先看下 console.log('a') 的 AST 長什麼樣子?
可以看到 console.log('a') 是一個 type 爲 “ExpressionStatement” 的節點,所以我們只需要遍歷 AST,當遇到 type=ExpressionStatement 的節點刪除即可!來一起實現吧~
- 引入基礎包
var fs = require("fs");
//babel核心模塊,裏面包含transform方法用來轉換源代碼。
const core = require('@babel/core');
//用來生成或者判斷節點的AST語法樹的節點
let types = require("@babel/types");
- 遍歷 AST 節點,babel 插件的語法都是固定的,裏面包含 visitor,我們只需在 visitor 裏面處理 ExpressionStatement 即可,
//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
// babel插件的語法都是固定的,裏面包含visitor
return {
pre(file) {
file.set('errors', []);
},
// 訪問器
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
const { node } = path
if (node.callee.object && node.callee.object.name === 'console') {
errors.push(path.buildCodeFrameError(`代碼中不能出現console語句`, Error));
if (fix) {
path.parentPath.remove();
}
}
}
},
post(file) {
// console.log(...file.get('errors'));
}
}
};
- 完整實現:
var fs = require("fs");
//babel核心模塊,裏面包含transform方法用來轉換源代碼。
const core = require('@babel/core');
//用來生成或者判斷節點的AST語法樹的節點
let types = require("@babel/types");
//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
// babel插件的語法都是固定的,裏面包含visitor
return {
pre(file) {
file.set('errors', []);
},
// 訪問器
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
const { node } = path
if (node.callee.object && node.callee.object.name === 'console') {
errors.push(path.buildCodeFrameError(`代碼中不能出現console語句`, Error));
if (fix) {
path.parentPath.remove();
}
}
}
},
post(file) {
// console.log(...file.get('errors'));
}
}
};
core.transformFile("eslint/source.js",{
plugins: [eslintPlugin({ fix: false })]
}, function(err, result) {
result; // => { code, map, ast }
console.log(result.code);
})
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_j2lcoNK7XIsAJbmddFMtA