作者:BoBoooooo
前言
談及 Babel,必然離不開 AST。有關 AST 這個知識點其實是很重要的,但由於涉及到程式碼編譯階段,大多情況都是由各個框架內建相關處理,所以作為開發(使用)者本身,往往會忽視這個過程。希望通過這篇文章,帶各位同學走進 AST,藉助 AST 發揮更多的想象力。
AST 概述
想必大家總是聽到 AST 這個概念,那麼到底什麼是 AST?
AST 全稱是是 Abstract Syntax Tree,中文為抽象語法樹,將我們所寫的程式碼轉換為機器能識別的一種樹形結構。其本身是由一堆節點(Node)組成,每個節點都表示原始碼中的一種結構。不同結構用型別(Type)來區分,常見的型別有:Identifier(識別符號),Expression(表示式),VariableDeclaration(變數定義),FunctionDeclaration(函式定義)等。
AST 結構
隨著 JavaScript 的發展,為了統一ECMAScript
標準的語法表達。社群中衍生出了ESTree Spec,是目前社群所遵循的一種語法表達標準。
ESTree 提供了例如Identifier、Literal
等常見的節點型別。
節點型別
型別 | 說明 |
---|---|
File | 檔案 (頂層節點包含 Program) |
Program | 整個程式節點 (包含 body 屬性代表程式體) |
Directive | 指令 (例如 "use strict") |
Comment | 程式碼註釋 |
Statement | 語句 (可獨立執行的語句) |
Literal | 字面量 (基本資料型別、複雜資料型別等值型別) |
Identifier | 識別符號 (變數名、屬性名、函式名、引數名等) |
Declaration | 宣告 (變數宣告、函式宣告、Import、Export 宣告等) |
Specifier | 關鍵字 (ImportSpecifier、ImportDefaultSpecifier、ImportNamespaceSpecifier、ExportSpecifier) |
Expression | 表示式 |
公共屬性
型別 | 說明 |
---|---|
type | AST 節點的型別 |
start | 記錄該節點程式碼字串起始下標 |
end | 記錄該節點程式碼字串結束下標 |
loc | 內含 line、column 屬性,分別記錄開始結束的行列號 |
leadingComments | 開始的註釋 |
innerComments | 中間的註釋 |
trailingComments | 結尾的註釋 |
extra | 額外資訊 |
AST 示例
有的同學可能會問了,這麼多型別都需要記住麼? 其實並不是,我們可以藉助以下兩個工具來查詢 AST 結構。
結合一個示例,帶大家快速瞭解一下 AST 結構。
function test(args) {
const a = 1;
console.log(args);
}
上述程式碼,宣告瞭一個函式
,名為test
,有一個形參args
。
函式體中:
- 宣告瞭一個
const
型別變數a
,值為1
- 執行了一個 console.log 語句
將上述程式碼貼上至AST Explorer,結果如圖所示:
接下來我們繼續分析內部結構,以const a = 1
為例:
變數宣告在 AST 中對應的就是 type 為VariableDeclaration
的節點。該節點包含kind
和declarations
兩個必須屬性,分別代表宣告的變數型別和變數內容。
細心的同學可能發現了declarations
是一個陣列。這是為什麼呢?因為變數宣告本身支援const a=1,b=2
的寫法,需要支援多個VariableDeclarator
,故此處為陣列。
而 type 為VariableDeclarator
的節點代表的就是a=1
這種宣告語句,其中包含id
和init
屬性。
id
即為Identifier
,其中的name
值對應的就是變數名稱。
init
即為初始值,包含type
,value
屬性。分別表示初始值型別和初始值。此處 type 為NumberLiteral
,表明初始值型別為number型別。
Babel 概述
Babel 是一個 JavaScript 編譯器,在實際開發過程中通常藉助Babel來完成相關 AST 的操作。
Babel 工作流程
Babel AST
Babel 解析程式碼後生成的 AST 是以ESTree作為基礎,並略作修改。
官方原文如下:
The Babel parser generates AST according to Babel AST format. It is based on ESTree spec with the following deviations:
- Literal token is replaced with StringLiteral, NumericLiteral, BigIntLiteral, BooleanLiteral, NullLiteral, RegExpLiteral
- Property token is replaced with ObjectProperty and ObjectMethod
- MethodDefinition is replaced with ClassMethod
- Program and BlockStatement contain additional directives field with Directive and DirectiveLiteral
- ClassMethod, ObjectProperty, and ObjectMethod value property's properties in FunctionExpression is coerced/brought into the main method node.
- ChainExpression is replaced with OptionalMemberExpression and OptionalCallExpression
- ImportExpression is replaced with a CallExpression whose callee is an Import node.
Babel 核心包
工具包 | 說明 |
---|---|
@babel/core | Babel 轉碼的核心包,包括了整個 babel 工作流(已整合@babel/types) |
@babel/parser | 解析器,將程式碼解析為 AST |
@babel/traverse | 遍歷/修改 AST 的工具 |
@babel/generator | 生成器,將 AST 還原成程式碼 |
@babel/types | 包含手動構建 AST 和檢查 AST 節點型別的方法 |
@babel/template | 可將字串程式碼片段轉換為 AST 節點 |
npm i @babel/parser @babel/traverse @babel/types @babel/generator @babel/template -D
Babel 外掛
Babel 外掛大致分為兩種:語法外掛和轉換外掛。語法外掛作用於 @babel/parser,負責將程式碼解析為抽象語法樹(AST)(官方的語法外掛以 babel-plugin-syntax 開頭);轉換外掛作用於 @babel/core,負責轉換 AST 的形態。絕大多數情況下我們都是在編寫轉換外掛。
Babel 工作依賴外掛。外掛相當於是指令,來告知 Babel 需要做什麼事情。如果沒有外掛,Babel 將原封不動的輸出程式碼。
Babel 外掛本質上就是編寫各種 visitor
去訪問 AST 上的節點,並進行 traverse。當遇到對應型別的節點,visitor
就會做出相應的處理,從而將原本的程式碼 transform 成最終的程式碼。
export default function (babel) {
// 即@babel/types,用於生成AST節點
const { types: t } = babel;
return {
name: "ast-transform", // not required
visitor: {
Identifier(path) {
path.node.name = path.node.name.split("").reverse().join("");
},
},
};
}
這是一段AST Explorer上的 transform 模板程式碼。上述程式碼的作用即為將輸入程式碼的所有識別符號(Identifier)型別的節點名稱顛倒
。
其實編寫一個 Babel 外掛很簡單。我們要做的事情就是回傳一個 visitor 物件,定義以Node Type
為名稱的函式。該函式接收path
,state
兩個引數。
其中path(路徑)提供了訪問/操作AST 節點的方法。path 本身表示兩個節點之間連線的物件
。例如path.node
可以訪問當前節點,path.parent
可以訪問父節點等。path.remove()
可以移除當前節點。具體 API 見下圖。其他可見
handlebook。
Babel Types
Babel Types 模組是一個用於 AST 節點的 Lodash 式工具庫,它包含了構造、驗證以及變換 AST 節點的方法。
型別判斷
Babel Types 提供了節點型別判斷的方法,每一種型別的節點都有相應的判斷方法。更多見babel-types API。
import * as types from "@babel/types";
// 是否為識別符號型別節點
if (types.isIdentifier(node)) {
// ...
}
// 是否為數字字面量節點
if (types.isNumberLiteral(node)) {
// ...
}
// 是否為表示式語句節點
if (types.isExpressionStatement(node)) {
// ...
}
建立節點
Babel Types 同樣提供了各種型別節點的建立方法,詳見下屬示例。
注: Babel Types 生成的 AST 節點需使用@babel/generator
轉換後得到相應程式碼。
import * as types from "@babel/types";
import generator from "@babel/generator";
const log = (node: types.Node) => {
console.log(generator(node).code);
};
log(types.stringLiteral("Hello World")); // output: Hello World
基本資料型別
types.stringLiteral("Hello World"); // string
types.numericLiteral(100); // number
types.booleanLiteral(true); // boolean
types.nullLiteral(); // null
types.identifier(); // undefined
types.regExpLiteral("\\.js?$", "g"); // 正則
"Hello World"
100
true
null
undefined
/\.js?$/g
複雜資料型別
- 陣列
types.arrayExpression([
types.stringLiteral("Hello World"),
types.numericLiteral(100),
types.booleanLiteral(true),
types.regExpLiteral("\\.js?$", "g"),
]);
["Hello World", 100, true, /\.js?$/g];
- 物件
types.objectExpression([
types.objectProperty(
types.identifier("key"),
types.stringLiteral("HelloWorld")
),
types.objectProperty(
// 字串型別 key
types.stringLiteral("str"),
types.arrayExpression([])
),
types.objectProperty(
types.memberExpression(
types.identifier("obj"),
types.identifier("propName")
),
types.booleanLiteral(false),
// 計算值 key
true
),
]);
{
key: "HelloWorld",
"str": [],
[obj.propName]: false
}
JSX 節點
建立 JSX AST 節點
與建立資料型別節點
略有不同,此處整理了一份關係圖。
JSXElement
types.jsxElement( types.jsxOpeningElement(types.jsxIdentifier("Button"), []), types.jsxClosingElement(types.jsxIdentifier("Button")), [types.jsxExpressionContainer(types.identifier("props.name"))] );
<Button>{props.name}</Button>
JSXFragment
types.jsxFragment(types.jsxOpeningFragment(), types.jsxClosingFragment(), [ types.jsxElement( types.jsxOpeningElement(types.jsxIdentifier("Button"), []), types.jsxClosingElement(types.jsxIdentifier("Button")), [types.jsxExpressionContainer(types.identifier("props.name"))] ), types.jsxElement( types.jsxOpeningElement(types.jsxIdentifier("Button"), []), types.jsxClosingElement(types.jsxIdentifier("Button")), [types.jsxExpressionContainer(types.identifier("props.age"))] ), ]);
<> <Button>{props.name}</Button> <Button>{props.age}</Button> </>
宣告
變數宣告 (variableDeclaration)
types.variableDeclaration("const", [ types.variableDeclarator(types.identifier("a"), types.numericLiteral(1)), ]);
const a = 1;
函式宣告 (functionDeclaration)
types.functionDeclaration( types.identifier("test"), [types.identifier("params")], types.blockStatement([ types.variableDeclaration("const", [ types.variableDeclarator( types.identifier("a"), types.numericLiteral(1) ), ]), types.expressionStatement( types.callExpression(types.identifier("console.log"), [ types.identifier("params"), ]) ), ]) );
function test(params) { const a = 1; console.log(params); }
React 函式式元件
綜合上述內容,小小實戰一下~
我們需要通過 Babel Types 生成button.js
程式碼。乍一看不知從何下手?
// button.js
import React from "react";
import { Button } from "antd";
export default (props) => {
const handleClick = (ev) => {
console.log(ev);
};
return <Button onClick={handleClick}>{props.name}</Button>;
};
小技巧: 先借助AST Explorer網站,觀察 AST 樹結構。然後通過 Babel Types 逐層編寫程式碼。事半功倍!
types.program([
types.importDeclaration(
[types.importDefaultSpecifier(types.identifier("React"))],
types.stringLiteral("react")
),
types.importDeclaration(
[
types.importSpecifier(
types.identifier("Button"),
types.identifier("Button")
),
],
types.stringLiteral("antd")
),
types.exportDefaultDeclaration(
types.arrowFunctionExpression(
[types.identifier("props")],
types.blockStatement([
types.variableDeclaration("const", [
types.variableDeclarator(
types.identifier("handleClick"),
types.arrowFunctionExpression(
[types.identifier("ev")],
types.blockStatement([
types.expressionStatement(
types.callExpression(types.identifier("console.log"), [
types.identifier("ev"),
])
),
])
)
),
]),
types.returnStatement(
types.jsxElement(
types.jsxOpeningElement(types.jsxIdentifier("Button"), [
types.jsxAttribute(
types.jsxIdentifier("onClick"),
types.jSXExpressionContainer(types.identifier("handleClick"))
),
]),
types.jsxClosingElement(types.jsxIdentifier("Button")),
[types.jsxExpressionContainer(types.identifier("props.name"))],
false
)
),
])
)
),
]);
應用場景
AST 本身應用非常廣泛,例如:Babel 外掛(ES6 轉化 ES5)、構建時壓縮程式碼 、css 前處理器編譯、 webpack 外掛等等,可以說是無處不在。
如圖所示,不難發現,一旦涉及到編譯,或者說程式碼本身的處理,都和 AST 息息相關。下面列舉了一些常見應用,讓我們看看是如何處理的。
程式碼轉換
// ES6 => ES5 let 轉 var
export default function (babel) {
const { types: t } = babel;
return {
name: "let-to-var",
visitor: {
VariableDeclaration(path) {
if (path.node.kind === "let") {
path.node.kind = "var";
}
},
},
};
}
babel-plugin-import
在 CommonJS 規範下,當我們需要按需引入antd
的時候,通常會藉助該外掛。
該外掛的作用如下:
// 通過es規範,具名引入Button元件
import { Button } from "antd";
ReactDOM.render(<Button>xxxx</Button>);
// babel編譯階段轉化為require實現按需引入
var _button = require("antd/lib/button");
ReactDOM.render(<_button>xxxx</_button>);
簡單分析一下,核心處理: 將 import 語句替換為對應的 require 語句。
export default function (babel) {
const { types: t } = babel;
return {
name: "import-to-require",
visitor: {
ImportDeclaration(path) {
if (path.node.source.value === "antd") {
// var _button = require("antd/lib/button");
const _botton = t.variableDeclaration("var", [
t.variableDeclarator(
t.identifier("_button"),
t.callExpression(t.identifier("require"), [
t.stringLiteral("antd/lib/button"),
])
),
]);
// 替換當前import語句
path.replaceWith(_botton);
}
},
},
};
}
TIPS: 目前 antd 包中已包含esm
規範檔案,可以依賴 webpack 原生 TreeShaking 實現按需引入。
LowCode 視覺化編碼
當下LowCode
,依舊是前端一大熱門領域。目前主流的做法大致下述兩種。
Schema 驅動
目前主流做法,將表單或者表格的配置,描述為一份 Schema,視覺化設計器基於 Schema 驅動,結合拖拽能力,快速搭建。
AST 驅動
通過
CloudIDE
,CodeSandbox
等瀏覽器端線上編譯,編碼。外加視覺化設計器,最終實現視覺化編碼。
大致流程如上圖所示,既然涉及到程式碼修改,離不開AST
的操作,那麼又可以發揮 babel 的能力了。
假設設計器的初始程式碼如下:
import React from "react";
export default () => {
return <Container></Container>;
};
此時我們拖拽了一個Button
至設計器中,根據上圖的流程,核心的 AST 修改過程如下:
- 新增 import 宣告語句
import { Button } from "antd";
- 將
<Button></Button>
插入至<Container></Container>
話不多說,直接上程式碼:
import traverse from "@babel/traverse";
import generator from "@babel/generator";
import * as parser from "@babel/parser";
import * as t from "@babel/types";
// 原始碼
const code = `
import React from "react";
export default () => {
return <Container></Container>;
};
`;
const ast = parser.parse(code, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
// 1. 程式頂層 新增import語句
Program(path) {
path.node.body.unshift(
t.importDeclaration(
// importSpecifier表示具名匯入,相應的匿名匯入為ImportDefaultSpecifier
// 具名匯入對應程式碼為 import { Button as Button } from 'antd'
// 如果相同會自動合併為 import { Button } from 'antd'
[t.importSpecifier(t.identifier("Button"), t.identifier("Button"))],
t.stringLiteral("antd")
)
);
},
// 訪問JSX節點,插入Button
JSXElement(path) {
if (path.node.openingElement.name.name === "Container") {
path.node.children.push(
t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier("Button"), []),
t.jsxClosingElement(t.jsxIdentifier("Button")),
[t.jsxText("按鈕")],
false
)
);
}
},
});
const newCode = generator(ast).code;
console.log(newCode);
結果如下:
import { Button } from "antd";
import React from "react";
export default () => {
return (
<Container>
<Button>按鈕</Button>
</Container>
);
};
ESLint
自定義 eslint-rule,本質上也是訪問 AST 節點,是不是跟 Babel 外掛的寫法很相似呢?
module.exports.rules = {
"var-length": (context) => ({
VariableDeclarator: (node) => {
if (node.id.name.length <= 2) {
context.report(node, "變數名長度需要大於2");
}
},
}),
};
Code2Code
以 Vue To React 為例,大致過程跟ES6 => ES5
類似,通過vue-template-compiler
編譯得到 Vue AST => 轉換為 React AST => 輸出 React 程式碼
。
有興趣的同學可以參考vue-to-react
其他多端框架:一份程式碼 => 多端,大體思路一致。
總結
在實際開發中,遇到的情況往往更加複雜,建議大家多番文件,多觀察,用心去感受 ~
參考文章
- babel-handlebook
- @babel/types
- [透過製作 Babel-plugin 初訪 AST
](https://blog.techbridge.cc/20...) - [@babel/types 深度應用
](https://juejin.cn/post/698494...)
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!