Talk
因為最近工作上有需要使用解析 JavaScript 的程式碼,大部分情況使用正規表示式匹配就可以處理,但是一旦依賴於程式碼上下文的內容時,正則或者簡單的字元解析就很力不從心了,這個時候需要一個語言解析器來獲取整一個 AST(abstract syntax tree)。
然後我找到了多個使用 JavaScript 編寫的 JavaScript 解析器:
從提交記錄來看,維護情況都蠻好的,ES 各種發展的特性都跟得上,我分別都簡單瞭解了一下,聊聊他們的一些情況。
Esprima 是很經典的一個解析器,Acorn 在它之後誕生,都是幾年前的事情了。按照 Acorn 作者的說法,當時造這個輪子更多隻是好玩,速度可以和 Esprima 媲美,但是實現程式碼更少。其中比較關鍵的點是這兩個解析器出來的 AST 結果(對,只是 AST,tokens 不一樣)都是符合 The Estree Spec 規範(這是 Mozilla 的工程師給出的 SpiderMonkey 引擎輸出的 JavaScript AST 的規範文件,也可以參考:SpiderMonkey in MDN)的,也就是得到的結果在很大部分上是相容的。
現在很出名的 Webpack 解析程式碼時用的也是 Acorn。
至於 Uglify,很出名的一個 JavaScript 程式碼壓縮器,其實它自帶了一個程式碼解析器,也可以輸出 AST,但是它的功能更多還是用於壓縮程式碼,如果拿來解析程式碼感覺不夠純粹。
Shift 這個沒做多少了解,只知道他定義了自己的一套 AST 規範。
Esprima 官網上有一個效能測試,我在 chrome 上跑的結果如下:
可見,Acorn 的效能很不錯,而且還有一個 Estree 的規範呢(規範很重要,我個人覺得遵循通用的規範是程式碼複用的重要基礎),所以我就直接選用 Acorn 來做程式碼解析了。
圖中做效能對比的還有 Google 的 Traceur,它更多是一個 ES6 to ES5 的 compiler,於我們想要找的解析器定位不符。
下面進入正題,如何使用 Acorn 來解析 JavaScript。
API
解析器的 API 都是很簡單的:
const ast = acorn.parse(code, options)複製程式碼
Acorn 的配置項蠻多的,裡邊還包括了一些事件可以設定回撥函式。我們挑幾個比較重要的講下:
- ecmaVersion
字面意義,很好理解,就是設定你要解析的 JavaScript 的 ECMA 版本。預設是 ES7。 - sourceType
這個配置項有兩個值:module
和script
,預設是script
。
主要是嚴格模式和import/export
的區別。ES6 中的模組是嚴格模式,也就是你無須新增use strict
。我們通常瀏覽器中使用的 script 是沒有import/export
語法的。
所以,選擇了script
則出現import/export
會報錯,可以使用嚴格模式宣告,選擇了module
,則不用嚴格模式宣告,可以使用import/export
語法。 - locations
預設值是false
,設定為true
之後會在 AST 的節點中攜帶多一個loc
物件來表示當前的開始和結束的行數和列數。 - onComment
傳入一個回撥函式,每當解析到程式碼中的註釋時會觸發,可以獲取當年註釋內容,引數列表是:[block, text, start, end]
。block
表示是否是塊註釋,text
是註釋內容,start
和end
是註釋開始和結束的位置。
上邊提及的 Espree 需要 Esprima 的
attachComment
的配置項,設定為 true 後,Esprima 會在程式碼解析結果的節點中攜帶註釋相關資訊(trailingComments
和leadingComments
)。Espree 則是利用 Acorn 的onComment
配置來實現這個 Esprima 特性的相容。
解析器通常還會有一個獲取詞法分析結果的介面:
const tokens = [...acorn.tokenizer(code, options)]複製程式碼
tokenizer
方法的第二個引數也能夠配置 locations
。
詞法結果 token 和 Esprima 的結果資料結構上有一定的區別(Espree 又是做了這一層的相容),有興趣瞭解的可以看下 Esprima 的解析結果:esprima.org/demo/parse.… 。
至於 Acorn 解析的 AST 和 token 的內容我們接下來詳述。
Token
我找了半天,沒找到關於 token 資料結構的詳細介紹,只能自己動手來看一下了。
我用來測試解析的程式碼是:
import "hello.js"
var a = 2;
// test
function name() { console.log(arguments); }複製程式碼
解析出來的 token 陣列是一個個類似這樣的物件:
Token {
type:
TokenType {
label: `import`,
keyword: `import`,
beforeExpr: false,
startsExpr: false,
isLoop: false,
isAssign: false,
prefix: false,
postfix: false,
binop: null,
updateContext: null },
value: `import`,
start: 5,
end: 11 },複製程式碼
看上去其實很好理解對不對,在 type
對應的物件中,label
表示當前標識的一個型別,keyword
就是關鍵詞,像例子中的 import
,或者 function
之類的。
value
則是當前標識的值,start/end
分別是開始和結束的位置。
通常我們需要關注的就是 label/keyword/value
這些了。其他的詳細可以參考原始碼:tokentype.js。
The Estree Spec
這一部分是重頭戲,因為實際上我需要的還是解析出來的 AST。最原滋原味的內容來自於:The Estree Spec,我只是閱讀了之後的搬運工。
提供了標準文件的好處是,很多東西有跡可循,這裡還有一個工具,用於把滿足 Estree 標準的 AST 轉換為 ESMAScript 程式碼:escodegen。
好吧,回到正題,我們先來看一下 ES5 的部分,可以在 Esprima: Parser 這個頁面測試各種程式碼的解析結果。
符合這個規範的解析出來的 AST 節點用 Node
物件來標識,Node
物件應該符合這樣的介面:
interface Node {
type: string;
loc: SourceLocation | null;
}複製程式碼
type
欄位表示不同的節點型別,下邊會再講一下各個型別的情況,分別對應了 JavaScript 中的什麼語法。loc
欄位表示原始碼的位置資訊,如果沒有相關資訊的話為 null
,否則是一個物件,包含了開始和結束的位置。介面如下:
interface SourceLocation {
source: string | null;
start: Position;
end: Position;
}複製程式碼
這裡的 Position
物件包含了行和列的資訊,行從 1 開始,列從 0 開始:
interface Position {
line: number; // >= 1
column: number; // >= 0
}複製程式碼
好了,基礎部分就是這樣,接下來看各種型別的節點,順帶溫習一下 JavaScript 語法的一些東西吧。對於這裡每一部分的內容,會簡單談一下,但不會展開(內容不少),對 JavaScript 瞭解的人很容易就明白的。
我覺得看完就像把 JavaScript 的基礎語法整理了一遍。
Identifier
識別符號,我覺得應該是這麼叫的,就是我們寫 JS 時自定義的名稱,如變數名,函式名,屬性名,都歸為識別符號。相應的介面是這樣的:
interface Identifier <: Expression, Pattern {
type: "Identifier";
name: string;
}複製程式碼
一個識別符號可能是一個表示式,或者是解構的模式(ES6 中的解構語法)。我們等會會看到 Expression
和 Pattern
相關的內容的。
Literal
字面量,這裡不是指 []
或者 {}
這些,而是本身語義就代表了一個值的字面量,如 1
,“hello”
, true
這些,還有正規表示式(有一個擴充套件的 Node
來表示正規表示式),如 /d?/
。我們看一下文件的定義:
interface Literal <: Expression {
type: "Literal";
value: string | boolean | null | number | RegExp;
}複製程式碼
value
這裡即對應了字面量的值,我們可以看出字面量值的型別,字串,布林,數值,null
和正則。
RegExpLiteral
這個針對正則字面量的,為了更好地來解析正規表示式的內容,新增多一個 regex
欄位,裡邊會包括正則本身,以及正則的 flags
。
interface RegExpLiteral <: Literal {
regex: {
pattern: string;
flags: string;
};
}複製程式碼
Programs
一般這個是作為跟節點的,即代表了一棵完整的程式程式碼樹。
interface Program <: Node {
type: "Program";
body: [ Statement ];
}複製程式碼
body
屬性是一個陣列,包含了多個 Statement
(即語句)節點。
Functions
函式宣告或者函式表示式節點。
interface Function <: Node {
id: Identifier | null;
params: [ Pattern ];
body: BlockStatement;
}複製程式碼
id
是函式名,params
屬性是一個陣列,表示函式的引數。body
是一個塊語句。
有一個值得留意的點是,你在測試過程中,是不會找到 type: "Function"
的節點的,但是你可以找到 type: "FunctionDeclaration"
和 type: "FunctionExpression"
,因為函式要麼以宣告語句出現,要麼以函式表示式出現,都是節點型別的組合型別,後邊會再提及 FunctionDeclaration
和 FunctionExpression
的相關內容。
這讓人感覺這個文件規劃得蠻細緻的,函式名,引數和函式塊是屬於函式部分的內容,而宣告或者表示式則有它自己需要的東西。
Statement
語句節點沒什麼特別的,它只是一個節點,一種區分,但是語句有很多種,下邊會詳述。
interface Statement <: Node { }複製程式碼
ExpressionStatement
表示式語句節點,a = a + 1
或者 a++
裡邊會有一個 expression
屬性指向一個表示式節點物件(後邊會提及表示式)。
interface ExpressionStatement <: Statement {
type: "ExpressionStatement";
expression: Expression;
}複製程式碼
BlockStatement
塊語句節點,舉個例子:if (...) { // 這裡是塊語句的內容 }
,塊裡邊可以包含多個其他的語句,所以有一個 body
屬性,是一個陣列,表示了塊裡邊的多個語句。
interface BlockStatement <: Statement {
type: "BlockStatement";
body: [ Statement ];
}複製程式碼
EmptyStatement
一個空的語句節點,沒有執行任何有用的程式碼,例如一個單獨的分號 ;
interface EmptyStatement <: Statement {
type: "EmptyStatement";
}複製程式碼
DebuggerStatement
debugger
,就是表示這個,沒有其他了。
interface DebuggerStatement <: Statement {
type: "DebuggerStatement";
}複製程式碼
WithStatement
with
語句節點,裡邊有兩個特別的屬性,object
表示 with
要使用的那個物件(可以是一個表示式),body
則是對應 with
後邊要執行的語句,一般會是一個塊語句。
interface WithStatement <: Statement {
type: "WithStatement";
object: Expression;
body: Statement;
}複製程式碼
下邊是控制流的語句:
ReturnStatement
返回語句節點,argument
屬性是一個表示式,代表返回的內容。
interface ReturnStatement <: Statement {
type: "ReturnStatement";
argument: Expression | null;
}複製程式碼
LabeledStatement
label
語句,平時可能會比較少接觸到,舉個例子:
loop: for(let i = 0; i < len; i++) {
// ...
for (let j = 0; j < min; j++) {
// ...
break loop;
}
}複製程式碼
這裡的 loop
就是一個 label
了,我們可以在迴圈巢狀中使用 break loop
來指定跳出哪個迴圈。所以這裡的 label
語句指的就是 loop: ...
這個。
一個 label
語句節點會有兩個屬性,一個 label
屬性表示 label
的名稱,另外一個 body
屬性指向對應的語句,通常是一個迴圈語句或者 switch
語句。
interface LabeledStatement <: Statement {
type: "LabeledStatement";
label: Identifier;
body: Statement;
}複製程式碼
BreakStatement
break
語句節點,會有一個 label
屬性表示需要的 label
名稱,當不需要 label
的時候(通常都不需要),便是 null
。
interface BreakStatement <: Statement {
type: "BreakStatement";
label: Identifier | null;
}複製程式碼
ContinueStatement
continue
語句節點,和 break
類似。
interface ContinueStatement <: Statement {
type: "ContinueStatement";
label: Identifier | null;
}複製程式碼
下邊是條件語句:
IfStatement
if
語句節點,很常見,會帶有三個屬性,test
屬性表示 if (...)
括號中的表示式。
consequent
屬性是表示條件為 true
時的執行語句,通常會是一個塊語句。
alternate
屬性則是用來表示 else
後跟隨的語句節點,通常也會是塊語句,但也可以又是一個 if
語句節點,即類似這樣的結構:if (a) { //... } else if (b) { // ... }
。alternate
當然也可以為 null
。
interface IfStatement <: Statement {
type: "IfStatement";
test: Expression;
consequent: Statement;
alternate: Statement | null;
}複製程式碼
SwitchStatement
switch
語句節點,有兩個屬性,discriminant
屬性表示 switch
語句後緊隨的表示式,通常會是一個變數,cases
屬性是一個 case
節點的陣列,用來表示各個 case
語句。
interface SwitchStatement <: Statement {
type: "SwitchStatement";
discriminant: Expression;
cases: [ SwitchCase ];
}複製程式碼
SwitchCase
switch
的 case
節點。test
屬性代表這個 case
的判斷表示式,consequent
則是這個 case
的執行語句。
當 test
屬性是 null
時,則是表示 default
這個 case
節點。
interface SwitchCase <: Node {
type: "SwitchCase";
test: Expression | null;
consequent: [ Statement ];
}複製程式碼
下邊是異常相關的語句:
ThrowStatement
throw
語句節點,argument
屬性用以表示 throw
後邊緊跟的表示式。
interface ThrowStatement <: Statement {
type: "ThrowStatement";
argument: Expression;
}複製程式碼
TryStatement
try
語句節點,block
屬性表示 try
的執行語句,通常是一個塊語句。
hanlder
屬性是指 catch
節點,finalizer
是指 finally
語句節點,當 hanlder
為 null
時,finalizer
必須是一個塊語句節點。
interface TryStatement <: Statement {
type: "TryStatement";
block: BlockStatement;
handler: CatchClause | null;
finalizer: BlockStatement | null;
}複製程式碼
CatchClause
catch
節點,param
用以表示 catch
後的引數,body
則表示 catch
後的執行語句,通常是一個塊語句。
interface CatchClause <: Node {
type: "CatchClause";
param: Pattern;
body: BlockStatement;
}複製程式碼
下邊是迴圈語句:
WhileStatement
while
語句節點,test
表示括號中的表示式,body
是表示要迴圈執行的語句。
interface WhileStatement <: Statement {
type: "WhileStatement";
test: Expression;
body: Statement;
}複製程式碼
DoWhileStatement
do/while
語句節點,和 while
語句類似。
interface DoWhileStatement <: Statement {
type: "DoWhileStatement";
body: Statement;
test: Expression;
}複製程式碼
ForStatement
for
迴圈語句節點,屬性 init/test/update
分別表示了 for
語句括號中的三個表示式,初始化值,迴圈判斷條件,每次迴圈執行的變數更新語句(init
可以是變數宣告或者表示式)。這三個屬性都可以為 null
,即 for(;;){}
。body
屬性用以表示要迴圈執行的語句。
interface ForStatement <: Statement {
type: "ForStatement";
init: VariableDeclaration | Expression | null;
test: Expression | null;
update: Expression | null;
body: Statement;
}複製程式碼
ForInStatement
for/in
語句節點,left
和 right
屬性分別表示在 in
關鍵詞左右的語句(左側可以是一個變數宣告或者表示式)。body
依舊是表示要迴圈執行的語句。
interface ForInStatement <: Statement {
type: "ForInStatement";
left: VariableDeclaration | Pattern;
right: Expression;
body: Statement;
}複製程式碼
Declarations
宣告語句節點,同樣也是語句,只是一個型別的細化。下邊會介紹各種宣告語句型別。
interface Declaration <: Statement { }複製程式碼
FunctionDeclaration
函式宣告,和之前提到的 Function 不同的是,id
不能為 null
。
interface FunctionDeclaration <: Function, Declaration {
type: "FunctionDeclaration";
id: Identifier;
}複製程式碼
VariableDeclaration
變數宣告,kind
屬性表示是什麼型別的宣告,因為 ES6 引入了 const/let
。declarations
表示宣告的多個描述,因為我們可以這樣:let a = 1, b = 2;
。
interface VariableDeclaration <: Declaration {
type: "VariableDeclaration";
declarations: [ VariableDeclarator ];
kind: "var";
}複製程式碼
VariableDeclarator
變數宣告的描述,id
表示變數名稱節點,init
表示初始值的表示式,可以為 null
。
interface VariableDeclarator <: Node {
type: "VariableDeclarator";
id: Pattern;
init: Expression | null;
}複製程式碼
Expressions
表示式節點。
interface Expression <: Node { }複製程式碼
ThisExpression
表示 this
。
interface ThisExpression <: Expression {
type: "ThisExpression";
}複製程式碼
ArrayExpression
陣列表示式節點,elements
屬性是一個陣列,表示陣列的多個元素,每一個元素都是一個表示式節點。
interface ArrayExpression <: Expression {
type: "ArrayExpression";
elements: [ Expression | null ];
}複製程式碼
ObjectExpression
物件表示式節點,property
屬性是一個陣列,表示物件的每一個鍵值對,每一個元素都是一個屬性節點。
interface ObjectExpression <: Expression {
type: "ObjectExpression";
properties: [ Property ];
}複製程式碼
Property
物件表示式中的屬性節點。key
表示鍵,value
表示值,由於 ES5 語法中有 get/set
的存在,所以有一個 kind
屬性,用來表示是普通的初始化,或者是 get/set
。
interface Property <: Node {
type: "Property";
key: Literal | Identifier;
value: Expression;
kind: "init" | "get" | "set";
}複製程式碼
FunctionExpression
函式表示式節點。
interface FunctionExpression <: Function, Expression {
type: "FunctionExpression";
}複製程式碼
下邊是一元運算子相關的表示式部分:
UnaryExpression
一元運算表示式節點(++/--
是 update 運算子,不在這個範疇內),operator
表示運算子,prefix
表示是否為字首運算子。argument
是要執行運算的表示式。
interface UnaryExpression <: Expression {
type: "UnaryExpression";
operator: UnaryOperator;
prefix: boolean;
argument: Expression;
}複製程式碼
UnaryOperator
一元運算子,列舉型別,所有值如下:
enum UnaryOperator {
"-" | "+" | "!" | "~" | "typeof" | "void" | "delete"
}複製程式碼
UpdateExpression
update 運算表示式節點,即 ++/--
,和一元運算子類似,只是 operator
指向的節點物件型別不同,這裡是 update 運算子。
interface UpdateExpression <: Expression {
type: "UpdateExpression";
operator: UpdateOperator;
argument: Expression;
prefix: boolean;
}複製程式碼
UpdateOperator
update 運算子,值為 ++
或 --
,配合 update 表示式節點的 prefix
屬性來表示前後。
enum UpdateOperator {
"++" | "--"
}複製程式碼
下邊是二元運算子相關的表示式部分:
BinaryExpression
二元運算表示式節點,left
和 right
表示運算子左右的兩個表示式,operator
表示一個二元運算子。
interface BinaryExpression <: Expression {
type: "BinaryExpression";
operator: BinaryOperator;
left: Expression;
right: Expression;
}複製程式碼
BinaryOperator
二元運算子,所有值如下:
enum BinaryOperator {
"==" | "!=" | "===" | "!=="
| "<" | "<=" | ">" | ">="
| "<<" | ">>" | ">>>"
| "+" | "-" | "*" | "/" | "%"
| "|" | "^" | "&" | "in"
| "instanceof"
}複製程式碼
AssignmentExpression
賦值表示式節點,operator
屬性表示一個賦值運算子,left
和 right
是賦值運算子左右的表示式。
interface AssignmentExpression <: Expression {
type: "AssignmentExpression";
operator: AssignmentOperator;
left: Pattern | Expression;
right: Expression;
}複製程式碼
AssignmentOperator
賦值運算子,所有值如下:(常用的並不多)
enum AssignmentOperator {
"=" | "+=" | "-=" | "*=" | "/=" | "%="
| "<<=" | ">>=" | ">>>="
| "|=" | "^=" | "&="
}複製程式碼
LogicalExpression
邏輯運算表示式節點,和賦值或者二元運算型別,只不過 operator
是邏輯運算子型別。
interface LogicalExpression <: Expression {
type: "LogicalExpression";
operator: LogicalOperator;
left: Expression;
right: Expression;
}複製程式碼
LogicalOperator
邏輯運算子,兩種值,即與或。
enum LogicalOperator {
"||" | "&&"
}複製程式碼
MemberExpression
成員表示式節點,即表示引用物件成員的語句,object
是引用物件的表示式節點,property
是表示屬性名稱,computed
如果為 false
,是表示 .
來引用成員,property
應該為一個 Identifier
節點,如果 computed
屬性為 true
,則是 []
來進行引用,即 property
是一個 Expression
節點,名稱是表示式的結果值。
interface MemberExpression <: Expression, Pattern {
type: "MemberExpression";
object: Expression;
property: Expression;
computed: boolean;
}複製程式碼
下邊是其他的一些表示式:
ConditionalExpression
條件表示式,通常我們稱之為三元運算表示式,即 boolean ? true : false
。屬性參考條件語句。
interface ConditionalExpression <: Expression {
type: "ConditionalExpression";
test: Expression;
alternate: Expression;
consequent: Expression;
}複製程式碼
CallExpression
函式呼叫表示式,即表示了 func(1, 2)
這一型別的語句。callee
屬性是一個表示式節點,表示函式,arguments
是一個陣列,元素是表示式節點,表示函式引數列表。
interface CallExpression <: Expression {
type: "CallExpression";
callee: Expression;
arguments: [ Expression ];
}複製程式碼
NewExpression
new
表示式。
interface NewExpression <: CallExpression {
type: "NewExpression";
}複製程式碼
SequenceExpression
這個就是逗號運算子構建的表示式(不知道確切的名稱),expressions
屬性為一個陣列,即表示構成整個表示式,被逗號分割的多個表示式。
interface SequenceExpression <: Expression {
type: "SequenceExpression";
expressions: [ Expression ];
}複製程式碼
Patterns
模式,主要在 ES6 的解構賦值中有意義,在 ES5 中,可以理解為和 Identifier
差不多的東西。
interface Pattern <: Node { }複製程式碼
這一部分的內容比較多,但都可以舉一反三,寫這個的時候我就當把 JavaScript 語法再複習一遍。這個文件還有 ES2015,ES2016,ES2017 相關的內容,涉及的東西也蠻多,但是理解了上邊的這一些,然後從語法層面去思考這個文件,其他的內容也就很好理解了,這裡略去,有需要請參閱:The Estree Spec。
Plugins
回到我們的主角,Acorn,提供了一種擴充套件的方式來編寫相關的外掛:Acorn Plugins。
我們可以使用外掛來擴充套件解析器,來解析更多的一些語法,如 .jsx
語法,有興趣的看看這個外掛:acorn-jsx。
官方表示 Acorn 的外掛是用於方便擴充套件解析器,但是需要對 Acorn 內部的執行極致比較瞭解,擴充套件的方式會在原本的基礎上重新定義一些方法。這裡不展開講了,如果我需要外掛的話,會再寫文章聊聊這個東西。
Examples
現在我們來看一下如何應用這個解析器,例如我們需要用來解析出一個符合 CommonJS 規範的模組依賴了哪些模組,我們可以用 Acorn 來解析 require
這個函式的呼叫,然後取出呼叫時的傳入引數,便可以獲取依賴的模組。
下邊是示例程式碼:
// 遍歷所有節點的函式
function walkNode(node, callback) {
callback(node)
// 有 type 欄位的我們認為是一個節點
Object.keys(node).forEach((key) => {
const item = node[key]
if (Array.isArray(item)) {
item.forEach((sub) => {
sub.type && walkNode(sub, callback)
})
}
item && item.type && walkNode(item, callback)
})
}
function parseDependencies(str) {
const ast = acorn.parse(str, { ranges: true })
const resource = [] // 依賴列表
// 從根節點開始
walkNode(ast, (node) => {
const callee = node.callee
const args = node.arguments
// require 我們認為是一個函式呼叫,並且函式名為 require,引數只有一個,且必須是字面量
if (
node.type === `CallExpression` &&
callee.type === `Identifier` &&
callee.name === `require` &&
args.length === 1 &&
args[0].type === `Literal`
) {
const args = node.arguments
// 獲取依賴的相關資訊
resource.push({
string: str.substring(node.range[0], node.range[1]),
path: args[0].value,
start: node.range[0],
end: node.range[1]
})
}
})
return resource
}複製程式碼
這只是簡單的一個情況的處理,但是已經給我們呈現瞭如何使用解析器,Webpack 則在這個的基礎上做了更多的東西,包括 var r = require; r(`a`)
或者 require.async(`a`)
等的處理。
AST 這個東西對於前端來說,我們無時無刻不在享受著它帶來的成果(模組構建,程式碼壓縮,程式碼混淆),所以瞭解一下總歸有好處。
有問題歡迎討論。