What is AST
什么是AST?AST是Abstract Syntax Tree(抽象語(yǔ)法樹(shù))的縮寫。
傳說(shuō)中的程序員三大浪漫是編譯原理、圖形學(xué)、操作系統(tǒng),不把AST玩轉(zhuǎn)牺荠,顯得逼格不夠,而本文目標(biāo)就是為你揭示AST在現(xiàn)代化JavaScript項(xiàng)目中的應(yīng)用驴一。
var a = 42
function addA(d){
return a + d;
}
按照語(yǔ)法規(guī)則書寫的代碼休雌,是用來(lái)讓開(kāi)發(fā)者可閱讀、可理解的肝断。對(duì)編譯器等工具來(lái)講杈曲,它可以理解的就是抽象語(yǔ)法樹(shù)了,在網(wǎng)站javascript-ast里胸懈,可以直觀看到由源碼生成的圖形化語(yǔ)法樹(shù)
生成抽象語(yǔ)法樹(shù)需要經(jīng)過(guò)兩個(gè)階段:
- 分詞(tokenize)
- 語(yǔ)義分析(parse)
其中担扑,分詞是將源碼source code分割成語(yǔ)法單元,語(yǔ)義分析是在分詞結(jié)果之上分析這些語(yǔ)法單元之間的關(guān)系趣钱。
以var a = 42這句代碼為例涌献,簡(jiǎn)單理解,可以得到下面分詞結(jié)果
[
{type:'identifier',value:'var'},
{type:'whitespace',value:' '},
{type:'identifier',value:'a'},
{type:'whitespace',value:' '},
{type:'operator',value:'='},
{type:'whitespace',value:' '},
{type:'num',value:'42'},
{type:'sep',value:';'}
]
實(shí)際使用babylon6解析這一代碼時(shí)首有,分詞結(jié)果為
生成的抽象語(yǔ)法樹(shù)為
{
"type":"Program",
"body":[
{
"type":"VariableDeclaration",
"kind":"var",
"declarations":{
"type":"VariableDeclarator",
"id":{
"type":"Identifier",
"value":"a"
},
"init":{
"type":"Literal",
"value":42
}
}
}
]
}
社區(qū)中有各種AST parser實(shí)現(xiàn)
- 早期有uglifyjs和esprima
- espree, 基于esprima燕垃,用于eslint,Introducing Espree, an Esprima alternative
- acorn,號(hào)稱是相對(duì)于esprima性能更優(yōu), Acorn: yet another JavaScript parser
- babylon,出自acorn,用于babel
- babel-eslint,babel團(tuán)隊(duì)維護(hù)的井联,用于配合使用ESLint, GitHub - babel/babel-eslint: ESLint using Babel as the parser.
AST in ESLint
ESLint是一個(gè)用來(lái)檢查和報(bào)告JavaScript編寫規(guī)范的插件化工具利术,通過(guò)配置規(guī)則來(lái)規(guī)范代碼,以no-cond-assign規(guī)則為例低矮,啟用這一規(guī)則時(shí),代碼中不允許在條件語(yǔ)句中賦值被冒,這一規(guī)則可以避免在條件語(yǔ)句中军掂,錯(cuò)誤的將判斷寫成賦值
//check ths user's job title
if(user.jobTitle = "manager"){
user.jobTitle is now incorrect
}
ESLint的檢查基于AST轮蜕,除了這些內(nèi)置規(guī)則外,ESLint為我們提供了API蝗锥,使得我們可以利用源代碼生成的AST跃洛,開(kāi)發(fā)自定義插件和自定義規(guī)則。
module.exports = {
rules: {
"var-length": {
create: function (context) {
//規(guī)則實(shí)現(xiàn)
}
}
}
};
自定義規(guī)則插件的結(jié)構(gòu)如上终议,在create方法中汇竭,我們可以定義我們關(guān)注的語(yǔ)法單元類型并且實(shí)現(xiàn)相關(guān)的規(guī)則邏輯,ESLint會(huì)在遍歷語(yǔ)法樹(shù)時(shí)穴张,進(jìn)入對(duì)應(yīng)的單元類型時(shí)细燎,執(zhí)行我們的檢查邏輯。
比如我們要實(shí)現(xiàn)一條規(guī)則皂甘,要求賦值語(yǔ)句中玻驻,變量名長(zhǎng)度大于兩位
module.exports = {
rules: {
"var-length": {
create: function (context) {
return {
VariableDeclarator: node => {
if (node.id.name.length < 2) {
context.report(node, 'Variable names should be longer than 1 character');
}
}
};
}
}
}
};
為這一插件編寫package.json
{
"name": "eslint-plugin-my-eslist-plugin",
"version": "0.0.1",
"main": "index.js",
"devDependencies": {
"eslint": "~2.6.0"
},
"engines": {
"node": ">=0.10.0"
}
}
在項(xiàng)目中使用時(shí),通過(guò)npm安裝依賴后偿枕,在配置中啟用插件和對(duì)應(yīng)規(guī)則
"plugins": [
"my-eslint-plugin"
]
"rules": {
"my-eslint-plugin/var-length": "warn"
}
通過(guò)這些配置璧瞬,便可以使用上述自定義插件。
有時(shí)我們不想要發(fā)布新的插件渐夸,而僅想編寫本地自定義規(guī)則嗤锉,這時(shí)我們可以通過(guò)自定義規(guī)則來(lái)實(shí)現(xiàn)。自定義規(guī)則與插件結(jié)構(gòu)大致相同墓塌,如下是一個(gè)自定義規(guī)則瘟忱,禁止在代碼中使用console的方法調(diào)用。
const disallowedMethods = ["log", "info", "warn", "error", "dir"];
module.exports = {
meta: {
docs: {
description: "Disallow use of console",
category: "Best Practices",
recommended: true
}
},
create(context) {
return {
Identifier(node) {
const isConsoleCall = looksLike(node, {
name: "console",
parent: {
type: "MemberExpression",
property: {
name: val => disallowedMethods.includes(val)
}
}
});
// find the identifier with name 'console'
if (!isConsoleCall) {
return;
}
context.report({
node,
message: "Using console is not allowed"
});
}
};
}
};
AST in Babel
Babel是為使用下一代JavaScript語(yǔ)法特性來(lái)開(kāi)發(fā)而存在的編譯工具桃纯,最初這個(gè)項(xiàng)目名為6to5酷誓,意為將ES6語(yǔ)法轉(zhuǎn)換為ES5。發(fā)展到現(xiàn)在态坦,Babel已經(jīng)形成了一個(gè)強(qiáng)大的生態(tài)盐数。
業(yè)界大佬的評(píng)價(jià):Babel is the new jQuery
Babel的工作過(guò)程經(jīng)過(guò)三個(gè)階段,parse伞梯、transform玫氢、generate,具體來(lái)說(shuō)谜诫,如下圖所示漾峡,在parse階段,使用babylon庫(kù)將源代碼轉(zhuǎn)換為AST喻旷,在transform階段生逸,利用各種插件進(jìn)行代碼轉(zhuǎn)換,如圖中的JSX transform將React JSX轉(zhuǎn)換為plain object,在generator階段槽袄,再利用代碼生成工具烙无,將AST轉(zhuǎn)換成代碼。
Babel為我們提供了API讓我們可以對(duì)代碼進(jìn)行AST轉(zhuǎn)換并且進(jìn)行各種操作
import * as babylon from "babylon";
import traverse from "babel-traverse";
import generate from "babel-generator";
const code = `function square(n) {
return n * n;
}`
const ast = babylon.parse(code);
traverse(ast,{
enter(path){
if(path.node.type === 'Identifier' && path.node.name === 'n'){
path.node.name = 'x'
}
}
})
generate(ast,{},code)
直接使用這些API的場(chǎng)景倒不多遍尺,項(xiàng)目中經(jīng)常用到的截酷,是各種Babel插件,比如 babel-plugin-transform-remove-console插件乾戏,可以去除代碼中所有對(duì)console的方法調(diào)用迂苛,主要代碼如下
module.exports = function({ types: t }) {
return {
name: "transform-remove-console",
visitor: {
CallExpression(path, state) {
const callee = path.get("callee");
if (!callee.isMemberExpression()) return;
if (isIncludedConsole(callee, state.opts.exclude)) {
// console.log()
if (path.parentPath.isExpressionStatement()) {
path.remove();
} else {
//var a = console.log()
path.replaceWith(createVoid0());
}
} else if (isIncludedConsoleBind(callee, state.opts.exclude)) {
// console.log.bind()
path.replaceWith(createNoop());
}
},
MemberExpression: {
exit(path, state) {
if (
isIncludedConsole(path, state.opts.exclude) &&
!path.parentPath.isMemberExpression()
) {
//console.log = func
if (
path.parentPath.isAssignmentExpression() &&
path.parentKey === "left"
) {
path.parentPath.get("right").replaceWith(createNoop());
} else {
//var a = console.log
path.replaceWith(createNoop());
}
}
}
}
}
};
使用這一插件,可以將程序中如下調(diào)用進(jìn)行轉(zhuǎn)換
console.log()
var a = console.log()
console.log.bind()
var b = console.log
console.log = func
//output
var a = void 0
(function(){})
var b = function(){}
console.log = function(){}
上述Babel插件的工作方式與前述的ESLint自定義插件/規(guī)則類似鼓择,工具在遍歷源碼生成的AST時(shí)三幻,根據(jù)我們指定的節(jié)點(diǎn)類型進(jìn)行對(duì)應(yīng)的檢查。
在我們開(kāi)發(fā)插件時(shí)惯退,是如何確定代碼AST樹(shù)形結(jié)構(gòu)呢赌髓?可以利用AST explorer方便的查看源碼生成的對(duì)應(yīng)AST結(jié)構(gòu)。
AST in Codemod
Codemod可以用來(lái)幫助你在一個(gè)大規(guī)模代碼庫(kù)中催跪,自動(dòng)化修改你的代碼锁蠕。
jscodeshift是一個(gè)運(yùn)行codemods的JavaScript工具,主要依賴于recast和ast-types兩個(gè)工具庫(kù)懊蒸。recast作為JavaScript parser提供AST接口荣倾,ast-types提供類型定義。
利用jscodeshift接口骑丸,完成前面類似功能舌仍,將代碼中對(duì)console的方法調(diào)用代碼刪除
export default (fileInfo,api)=>{
const j = api.jscodeshift;
const root = j(fileInfo.source);
const callExpressions = root.find(j.CallExpression,{
callee:{
type:'MemberExpression',
object:{
type:'Identifier',
name:'console'
}
}
});
callExpressions.remove();
return root.toSource();
}
如果想要代碼看起來(lái)更加簡(jiǎn)潔,也可以使用鏈?zhǔn)紸PI調(diào)用
export default (fileInfo,api)=>{
const j = api.jscodeshift;
return j(fileInfo.source)
.find(j.CallExpression,{
callee:{
type:'MemberExpression',
object:{
type:'Identifier',
name:'console'
}
}
})
.remove()
.toSource();
}
在了解了jscodeshift之后通危,頭腦中立即出現(xiàn)了一個(gè)疑問(wèn)铸豁,就是我們?yōu)槭裁葱枰猨scodeshift呢?利用AST進(jìn)行代碼轉(zhuǎn)換菊碟,Babel不是已經(jīng)完全搞定了嗎节芥?
帶著這個(gè)問(wèn)題進(jìn)行一番搜索驱闷,發(fā)現(xiàn)Babel團(tuán)隊(duì)這處提交說(shuō)明babel-core: add options for different parser/generator娄琉。
前文提到,Babel處理流程中包括了parse羔砾、transform和generation三個(gè)步驟魄幕。在生成代碼的階段相艇,Babel不關(guān)心生成代碼的格式,因?yàn)樯傻木幾g過(guò)的代碼目標(biāo)不是讓開(kāi)發(fā)者閱讀的纯陨,而是生成到發(fā)布目錄供運(yùn)行的坛芽,這個(gè)過(guò)程一般還會(huì)對(duì)代碼進(jìn)行壓縮處理留储。
這一次過(guò)程在使用Babel命令時(shí)也有體現(xiàn),我們一般使用的命令形式為
babel src -d dist
而在上述場(chǎng)景中靡馁,我們的目標(biāo)是在代碼庫(kù)中欲鹏,對(duì)源碼進(jìn)行處理,這份經(jīng)過(guò)處理的代碼仍需是可讀的臭墨,我們?nèi)砸谶@份代碼上進(jìn)行開(kāi)發(fā),這一過(guò)程如果用Babel命令來(lái)體現(xiàn)膘盖,實(shí)際是這樣的過(guò)程
babel src -d src
在這樣的過(guò)程中胧弛,我們會(huì)檢查轉(zhuǎn)換腳本對(duì)源代碼到底做了哪些變更,來(lái)確認(rèn)我們的轉(zhuǎn)換正確性侠畔。這就需要這一個(gè)差異結(jié)果是可讀的结缚,而直接使用Babel完成上述轉(zhuǎn)換時(shí),使用git diff輸出差異結(jié)果時(shí)软棺,這份差異結(jié)果是混亂不可讀的红竭。
基于這個(gè)需求,Babel團(tuán)隊(duì)現(xiàn)在允許通過(guò)配置自定義parser和generator
{
"plugins":[
"./plugins.js"
],
"parserOpts":{
"parser":"recast"
},
"generatorOpts":{
"generator":"recast"
}
}
假設(shè)我們有如下代碼喘落,我們通過(guò)腳本茵宪,將代碼中import模式進(jìn)行修改
import fs, {readFile} from 'fs'
import {resolve} from 'path'
import cp from 'child_process'
resolve(__dirname, './thing')
readFile('./thing.js', 'utf8', (err, string) => {
console.log(string)
})
fs.readFile('./other-thing', 'utf8', (err, string) => {
const resolve = string => string
console.log(resolve())
})
cp.execSync('echo "hi"')
//轉(zhuǎn)換為
import fs from 'fs';
import _path from 'path';
import cp from 'child_process'
_path.resolve(__dirname, './thing')
fs.readFile('./thing.js', 'utf8', (err, string) => {
console.log(string)
})
fs.readFile('./other-thing', 'utf8', (err, string) => {
const resolve = string => string
console.log(resolve())
})
cp.execSync('echo "hi"')
完成這一轉(zhuǎn)換的plugin.js為
module.exports = function(babel) {
const { types: t } = babel
// could just use https://www.npmjs.com/package/is-builtin-module
const nodeModules = [
'fs', 'path', 'child_process',
]
return {
name: 'node-esmodule', // not required
visitor: {
ImportDeclaration(path) {
const specifiers = []
let defaultSpecifier
path.get('specifiers').forEach(specifier => {
if (t.isImportSpecifier(specifier)) {
specifiers.push(specifier)
} else {
defaultSpecifier = specifier
}
})
const {node: {value: source}} = path.get('source')
if (!specifiers.length || !nodeModules.includes(source)) {
return
}
let memberObjectNameIdentifier
if (defaultSpecifier) {
memberObjectNameIdentifier = defaultSpecifier.node.local
} else {
memberObjectNameIdentifier = path.scope.generateUidIdentifier(source)
path.node.specifiers.push(t.importDefaultSpecifier(memberObjectNameIdentifier))
}
specifiers.forEach(specifier => {
const {node: {imported: {name}}} = specifier
const {referencePaths} = specifier.scope.getBinding(name)
referencePaths.forEach(refPath => {
refPath.replaceWith(
t.memberExpression(memberObjectNameIdentifier, t.identifier(name))
)
})
specifier.remove()
})
}
}
}
}
刪除和加上parserOpts和generatorOpts設(shè)置允許兩次,使用git diff命令輸出結(jié)果瘦棋,可以看出明顯的差異
AST in Webpack
Webpack是一個(gè)JavaScript生態(tài)的打包工具稀火,其打出bundle結(jié)構(gòu)是一個(gè)IIFE(立即執(zhí)行函數(shù))
(function(module){})([function(){},function(){}]);
Webpack在打包流程中也需要AST的支持,它借助acorn庫(kù)解析源碼赌朋,生成AST凰狞,提取模塊依賴關(guān)系
在各類打包工具中,由Rollup提出沛慢,Webpack目前也提供支持的一個(gè)特性是treeshaking赡若。treeshaking可以使得打包輸出結(jié)果中,去除沒(méi)有引用的模塊团甲,有效減少包的體積逾冬。
//math.js
export {doMath, sayMath}
const add = (a, b) => a + b
const subtract = (a, b) => a - b
const divide = (a, b) => a / b
const multiply = (a, b) => a * b
function doMath(a, b, operation) {
switch (operation) {
case 'add':
return add(a, b)
case 'subtract':
return subtract(a, b)
case 'divide':
return divide(a, b)
case 'multiply':
return multiply(a, b)
default:
throw new Error(`Unsupported operation: ${operation}`)
}
}
function sayMath() {
return 'MATH!'
}
//main.js
import {doMath}
doMath(2, 3, 'multiply') // 6
上述代碼中,math.js輸出doMath,sayMath方法伐庭,main.js中僅引用doMath方法粉渠,采用Webpack treeshaking特性,再加上uglify的支持圾另,在輸出的bundle文件中霸株,可以去掉sayMath相關(guān)代碼,輸出的math.js形如
export {doMath}
const add = (a, b) => a + b
const subtract = (a, b) => a - b
const divide = (a, b) => a / b
const multiply = (a, b) => a * b
function doMath(a, b, operation) {
switch (operation) {
case 'add':
return add(a, b)
case 'subtract':
return subtract(a, b)
case 'divide':
return divide(a, b)
case 'multiply':
return multiply(a, b)
default:
throw new Error(`Unsupported operation: ${operation}`)
}
}
進(jìn)一步分析main.js中的調(diào)用集乔,doMath(2, 3, 'multiply') 調(diào)用僅會(huì)執(zhí)行doMath的一個(gè)分支去件,math.js中定義的一些help方法如add,subtract,divide實(shí)際是不需要的坡椒,理論上,math.js最優(yōu)可以被減少為
export {doMath}
const multiply = (a, b) => a * b
function doMath(a, b) {
return multiply(a, b)
}
基于AST尤溜,進(jìn)行更為完善的代碼覆蓋率分析倔叼,應(yīng)當(dāng)可以實(shí)現(xiàn)上述效果,這里只是一個(gè)想法宫莱,沒(méi)有具體的實(shí)踐丈攒。參考Faster JavaScript with SliceJS