【轉向JavaScript系列】AST in Modern JavaScript
What is AST
什麼是AST?AST是Abstract Syntax Tree(抽象語法樹)的縮寫。
傳說中的程式設計師三大浪漫是編譯原理、圖形學、作業系統,不把AST玩轉,顯得逼格不夠,而本文目標就是為你揭示AST在現代化JavaScript專案中的應用。
var a = 42
function addA(d){
return a + d;
}
按照語法規則書寫的程式碼,是用來讓開發者可閱讀、可理解的。對編譯器等工具來講,它可以理解的就是抽象語法樹了,在網站javascript-ast裡,可以直觀看到由原始碼生成的圖形化語法樹
生成抽象語法樹需要經過兩個階段:
- 分詞(tokenize)
- 語義分析(parse)
其中,分詞是將原始碼source code分割成語法單元,語義分析是在分詞結果之上分析這些語法單元之間的關係。
以var a = 42這句程式碼為例,簡單理解,可以得到下面分詞結果
[
{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:';'}
]
實際使用babylon6解析這一程式碼時,分詞結果為
生成的抽象語法樹為
{
"type":"Program",
"body":[
{
"type":"VariableDeclaration",
"kind":"var",
"declarations":{
"type":"VariableDeclarator",
"id":{
"type":"Identifier",
"value":"a"
},
"init":{
"type":"Literal",
"value":42
}
}
}
]
}
社群中有各種AST parser實現
- 早期有uglifyjs和esprima
- espree, 基於esprima,用於eslint,Introducing Espree, an Esprima alternative
- acorn,號稱是相對於esprima效能更優, Acorn: yet another JavaScript parser
- babylon,出自acorn,用於babel
- babel-eslint,babel團隊維護的,用於配合使用ESLint, GitHub - babel/babel-eslint: ESLint using Babel as the parser.
AST in ESLint
ESLint是一個用來檢查和報告JavaScript編寫規範的外掛化工具,通過配置規則來規範程式碼,以no-cond-assign規則為例,啟用這一規則時,程式碼中不允許在條件語句中賦值,這一規則可以避免在條件語句中,錯誤的將判斷寫成賦值
//check ths user's job title
if(user.jobTitle = "manager"){
user.jobTitle is now incorrect
}
ESLint的檢查基於AST,除了這些內建規則外,ESLint為我們提供了API,使得我們可以利用原始碼生成的AST,開發自定義外掛和自定義規則。
module.exports = {
rules: {
"var-length": {
create: function (context) {
//規則實現
}
}
}
};
自定義規則外掛的結構如上,在create方法中,我們可以定義我們關注的語法單元型別並且實現相關的規則邏輯,ESLint會在遍歷語法樹時,進入對應的單元型別時,執行我們的檢查邏輯。
比如我們要實現一條規則,要求賦值語句中,變數名長度大於兩位
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"
}
}
在專案中使用時,通過npm安裝依賴後,在配置中啟用外掛和對應規則
"plugins": [
"my-eslint-plugin"
]
"rules": {
"my-eslint-plugin/var-length": "warn"
}
通過這些配置,便可以使用上述自定義外掛。
有時我們不想要釋出新的外掛,而僅想編寫本地自定義規則,這時我們可以通過自定義規則來實現。自定義規則與外掛結構大致相同,如下是一個自定義規則,禁止在程式碼中使用console的方法呼叫。
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語法特性來開發而存在的編譯工具,最初這個專案名為6to5,意為將ES6語法轉換為ES5。發展到現在,Babel已經形成了一個強大的生態。
業界大佬的評價:Babel is the new jQuery
Babel的工作過程經過三個階段,parse、transform、generate,具體來說,如下圖所示,在parse階段,使用babylon庫將原始碼轉換為AST,在transform階段,利用各種外掛進行程式碼轉換,如圖中的JSX transform將React JSX轉換為plain object,在generator階段,再利用程式碼生成工具,將AST轉換成程式碼。
Babel為我們提供了API讓我們可以對程式碼進行AST轉換並且進行各種操作
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的場景倒不多,專案中經常用到的,是各種Babel外掛,比如 babel-plugin-transform-remove-console外掛,可以去除程式碼中所有對console的方法呼叫,主要程式碼如下
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());
}
}
}
}
}
};
使用這一外掛,可以將程式中如下呼叫進行轉換
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自定義外掛/規則類似,工具在遍歷原始碼生成的AST時,根據我們指定的節點型別進行對應的檢查。
在我們開發外掛時,是如何確定程式碼AST樹形結構呢?可以利用AST explorer方便的檢視原始碼生成的對應AST結構。
AST in Codemod
Codemod可以用來幫助你在一個大規模程式碼庫中,自動化修改你的程式碼。
jscodeshift是一個執行codemods的JavaScript工具,主要依賴於recast和ast-types兩個工具庫。recast作為JavaScript parser提供AST介面,ast-types提供型別定義。
利用jscodeshift介面,完成前面類似功能,將程式碼中對console的方法呼叫程式碼刪除
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();
}
如果想要程式碼看起來更加簡潔,也可以使用鏈式API呼叫
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之後,頭腦中立即出現了一個疑問,就是我們為什麼需要jscodeshift呢?利用AST進行程式碼轉換,Babel不是已經完全搞定了嗎?
帶著這個問題進行一番搜尋,發現Babel團隊這處提交說明babel-core: add options for different parser/generator。
前文提到,Babel處理流程中包括了parse、transform和generation三個步驟。在生成程式碼的階段,Babel不關心生成程式碼的格式,因為生成的編譯過的程式碼目標不是讓開發者閱讀的,而是生成到釋出目錄供執行的,這個過程一般還會對程式碼進行壓縮處理。
這一次過程在使用Babel命令時也有體現,我們一般使用的命令形式為
babel src -d dist
而在上述場景中,我們的目標是在程式碼庫中,對原始碼進行處理,這份經過處理的程式碼仍需是可讀的,我們仍要在這份程式碼上進行開發,這一過程如果用Babel命令來體現,實際是這樣的過程
babel src -d src
在這樣的過程中,我們會檢查轉換指令碼對原始碼到底做了哪些變更,來確認我們的轉換正確性。這就需要這一個差異結果是可讀的,而直接使用Babel完成上述轉換時,使用git diff輸出差異結果時,這份差異結果是混亂不可讀的。
基於這個需求,Babel團隊現在允許通過配置自定義parser和generator
{
"plugins":[
"./plugins.js"
],
"parserOpts":{
"parser":"recast"
},
"generatorOpts":{
"generator":"recast"
}
}
假設我們有如下程式碼,我們通過指令碼,將程式碼中import模式進行修改
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"')
//轉換為
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"')
完成這一轉換的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設定允許兩次,使用git diff命令輸出結果,可以看出明顯的差異
AST in Webpack
Webpack是一個JavaScript生態的打包工具,其打出bundle結構是一個IIFE(立即執行函式)
(function(module){})([function(){},function(){}]);
Webpack在打包流程中也需要AST的支援,它藉助acorn庫解析原始碼,生成AST,提取模組依賴關係
在各類打包工具中,由Rollup提出,Webpack目前也提供支援的一個特性是treeshaking。treeshaking可以使得打包輸出結果中,去除沒有引用的模組,有效減少包的體積。
//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相關程式碼,輸出的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}`)
}
}
進一步分析main.js中的呼叫,doMath(2, 3, 'multiply') 呼叫僅會執行doMath的一個分支,math.js中定義的一些help方法如add,subtract,divide實際是不需要的,理論上,math.js最優可以被減少為
export {doMath}
const multiply = (a, b) => a * b
function doMath(a, b) {
return multiply(a, b)
}
基於AST,進行更為完善的程式碼覆蓋率分析,應當可以實現上述效果,這裡只是一個想法,沒有具體的實踐。參考Faster JavaScript with SliceJS
參考文章
相關文章
- 聊一聊 Javascript 中的 ASTJavaScriptAST
- JavaScript 二進位制的 ASTJavaScriptAST
- 《JavaScript物件導向精要》系列文章JavaScript物件
- Pinterest轉向通用JavaScript和ReactRESTJavaScriptReact
- javaScript系列[06]-javaScript和thisJavaScript
- 帶你揭開神秘的javascript AST面紗之AST 基礎與功能JavaScriptAST
- JavaScript高階:JavaScript物件導向,JavaScript內建物件,JavaScript BOM,JavaScript封裝JavaScript物件封裝
- 物件導向的JavaScript程式設計 (轉)物件JavaScript程式設計
- 深入理解javascript系列(十八):掌握物件導向(1)JavaScript物件
- JavaScript 物件導向JavaScript物件
- javaScript系列[05]-javaScript和JSONJavaScriptJSON
- 高階前端基礎-JavaScript抽象語法樹AST前端JavaScript抽象語法樹AST
- Modern Javascript Cheatsheet 簡體中文版(持續更新中)JavaScript
- JavaScript的物件導向JavaScript物件
- 初探 JavaScript 物件導向JavaScript物件
- 微軟向JavaScript邁進微軟JavaScript
- JavaScript系列目錄JavaScript
- 【轉】eval()函式(javascript) - [javaScript]函式JavaScript
- 《JavaScript物件導向程式設計指南》 - JavaScript好書JavaScript物件程式設計
- 逆向進階,利用 AST 技術還原 JavaScript 混淆程式碼ASTJavaScript
- 【讀】JavaScript之物件導向JavaScript物件
- JavaScript 的物件導向(OO)JavaScript物件
- JavaScript7:物件導向JavaScript物件
- 更多物件導向的JavaScript物件JavaScript
- JavaScript物件導向入門JavaScript物件
- JavaScript 物件導向初步理解JavaScript物件
- JavaScript物件導向精要(二)JavaScript物件
- JavaScript 物件導向精要(一)JavaScript物件
- JavaScript模擬物件導向JavaScript物件
- 從 JavaScript 到 TypeScript 系列JavaScriptTypeScript
- 帶你揭開神秘的Javascript AST面紗之Babel AST 四件套的使用方法JavaScriptASTBabel
- [譯]用javascript實現一門程式語言-AST的介紹JavaScriptAST
- JavaScript 極致效能追求:TC39 二進位制 AST 提案JavaScriptAST
- JavaScript:雪景(轉)JavaScript
- JavaScript 物件導向實戰思想JavaScript物件
- JavaScript物件導向詳解(原理)JavaScript物件
- Javascript物件導向與繼承JavaScript物件繼承
- javascript中的雙向繫結JavaScript