語法
JavaScript 擁有最難以解析的語法之一,本教學詳細說明我在學習時所付出的所有汗水和淚水。
LL(1) 語法
根據 維基百科,
LL 語法是一種可以由 LL 解析器解析的上下文無關文法,該解析器從左到右解析輸入
第一個 L 表示從左到右掃描來源,第二個 L 表示建構一個左推導樹。
上下文無關,且 LL(1) 中的 (1) 表示僅需查看下一個符號(token)即可建構樹,而無需其他資訊。
LL 語法在學術界特別受關注,因為我們是懶惰的人類,我們希望編寫自動產生解析器的程式,這樣我們就不需要手動編寫解析器。
不幸的是,大多數工業程式語言並沒有良好的 LL(1) 語法,這也適用於 JavaScript。
資訊
Mozilla 在幾年前啟動了 jsparagus 專案,並使用 Python 編寫了一個 LALR 解析器產生器。他們在過去兩年中沒有對其進行太多更新,並且在 js-quirks.md 的末尾發出了一個強烈的訊息
今天我們學到了什麼?
- 不要編寫 JS 解析器。
- JavaScript 中有一些語法上的恐怖之處。但是,嘿,你不會因為避免所有錯誤而成為世界上使用最廣泛的程式語言。你要在適當的情況下,為適當的用戶提供可用的工具。
解析 JavaScript 的唯一可行方法是手動編寫遞迴下降解析器,因為其語法的本質如此,因此在自討苦吃之前,我們先來了解一下語法中的所有怪異之處。
下面的列表從簡單開始,會變得難以理解,所以請喝杯咖啡,慢慢來。
識別符號
在 #sec-identifiers
中定義了三種類型的識別符號:
IdentifierReference[Yield, Await] :
BindingIdentifier[Yield, Await] :
LabelIdentifier[Yield, Await] :
estree
和某些 AST 並不區分以上識別符號,並且規範中沒有以純文本解釋它們。
BindingIdentifier
是宣告,而 IdentifierReference
是對綁定識別符號的引用。例如,在 var foo = bar
中,foo
在語法上是 BindingIdentifier
,而 bar
是 IdentifierReference
。
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt
Initializer[In, Yield, Await] :
= AssignmentExpression[?In, ?Yield, ?Await]
將 AssignmentExpression
追溯到 PrimaryExpression
,我們得到
PrimaryExpression[Yield, Await] :
IdentifierReference[?Yield, ?Await]
在 AST 中以不同方式宣告這些識別符號將大大簡化下游工具,尤其是語意分析。
pub struct BindingIdentifier {
pub node: Node,
pub name: Atom,
}
pub struct IdentifierReference {
pub node: Node,
pub name: Atom,
}
類別和嚴格模式
ECMAScript 類別是在嚴格模式之後誕生的,因此他們決定為了簡單起見,類別內的所有內容都必須處於嚴格模式。在 #sec-class-definitions
中,它被這樣陳述: Node: A class definition is always strict mode code.
透過將嚴格模式與函數作用域相關聯來宣告嚴格模式很容易,但是 class
宣告沒有作用域,我們需要保留一個額外的狀態來專門解析類別。
// https://github.com/swc-project/swc/blob/f9c4eff94a133fa497778328fa0734aa22d5697c/crates/swc_ecma_parser/src/parser/class_and_fn.rs#L85
fn parse_class_inner(
&mut self,
_start: BytePos,
class_start: BytePos,
decorators: Vec<Decorator>,
is_ident_required: bool,
) -> PResult<(Option<Ident>, Class)> {
self.strict_mode().parse_with(|p| {
expect!(p, "class");
傳統八進制和使用嚴格模式
#sec-string-literals-early-errors
不允許字串中的跳脫傳統八進制 "\01"
EscapeSequence ::
LegacyOctalEscapeSequence
NonOctalDecimalEscapeSequence
It is a Syntax Error if the source text matched by this production is strict mode code.
偵測這個問題的最佳位置是在詞法分析器中,它可以向解析器詢問嚴格模式狀態並相應地拋出錯誤。
但是,當與指令混合時,這變得不可能
https://github.com/tc39/test262/blob/747bed2e8aaafe8fdf2c65e8a10dd7ae64f66c47/test/language/literals/string/legacy-octal-escape-sequence-prologue-strict.js#L16-L19
use strict
是在跳脫的傳統八進制之後宣告的,但是仍然需要拋出語法錯誤。幸運的是,沒有真正的程式碼會將指令與傳統八進制一起使用... 除非你想要通過上面的 test262 測試案例。
非簡單參數和嚴格模式
在非嚴格模式下,允許使用相同的函數參數 function foo(a, a) { }
,我們可以透過新增 use strict
來禁止這樣做: function foo(a, a) { "use strict" }
。在 es6 的後期,函數參數中新增了其他語法,例如 function foo({ a }, b = c) {}
。
現在,如果我們寫入以下內容,其中 "01" 是嚴格模式錯誤,會發生什麼?
function foo(value=(function() { return "\01" }())) {
"use strict";
return value;
}
更具體來說,如果從解析器的角度考慮,參數內部存在嚴格模式語法錯誤,我們應該怎麼做?因此,在 #sec-function-definitions-static-semantics-early-errors
中,它只是透過陳述來禁止此行為
FunctionDeclaration :
FunctionExpression :
It is a Syntax Error if FunctionBodyContainsUseStrict of FunctionBody is true and IsSimpleParameterList of FormalParameters is false.
Chrome 拋出此錯誤,並顯示一條神秘的訊息「Uncaught SyntaxError: Illegal 'use strict' directive in function with non-simple parameter list」。
ESLint 作者在 這篇部落格文章中對此進行了更深入的解釋。
資訊
有趣的是,如果我們的 TypeScript 目標是 es5
,則上述規則不適用,它會轉譯為
function foo(a, b) {
"use strict";
if (b === void 0) { b = "\01"; }
}
括號表示式
括號表示式應該沒有任何語意含義?例如,((x))
的 AST 可以只是一個單一的 IdentifierReference
,而不是 ParenthesizedExpression
-> ParenthesizedExpression
-> IdentifierReference
。這也是 JavaScript 語法的情況。
但是... 誰會想到它會有執行時的含義。在 這個 estree 問題中發現,它表明
> fn = function () {};
> fn.name
< "fn"
> (fn) = function () {};
> fn.name
< ''
因此,最終 acorn 和 babel 添加了 preserveParens
選項以實現相容性。
if 語句中的函數宣告
如果我們精確遵循 #sec-ecmascript-language-statements-and-declarations
中的語法
Statement[Yield, Await, Return] :
... lots of statements
Declaration[Yield, Await] :
... declarations
我們為 AST 定義的 Statement
節點顯然不會包含 Declaration
,
但是在附錄 B #sec-functiondeclarations-in-ifstatement-statement-clauses
中,它允許在非嚴格模式下於 if
語句的陳述位置中宣告
if (x) function foo() {}
else function bar() {}
標籤陳述是合法的
我們可能從未編寫過單行標籤陳述,但它在現代 JavaScript 中是合法的,並且不受嚴格模式的禁止。
以下語法是正確的,它會傳回一個標籤陳述(而不是物件字面值)。
<Foo
bar={() => {
baz: "quaz";
}}
/>
// ^^^^^^^^^^^ `LabelledStatement`
let
不是關鍵字
let
不是關鍵字,因此允許它出現在任何位置,除非語法明確說明不允許 let
出現在這些位置。解析器需要查看 let
符號之後的符號,並決定它需要解析成什麼,例如
let a;
let = foo;
let instanceof x;
let + 1;
while (true) let;
a = let[0];
For-in / For-of 和 [In] 上下文
如果我們查看 #prod-ForInOfStatement
中 for-in
和 for-of
的語法,會立即感到困惑,不理解如何解析這些。
我們理解有兩個主要的障礙: [lookahead ≠ let]
部分和 [+In]
部分。
如果我們已解析為 for (let
,則需要檢查查看符號是否
- 不是
in
以禁止for (let in)
- 是
{
、[
或識別符號以允許for (let {} = foo)
、for (let [] = foo)
和for (let bar = foo)
一旦達到 of
或 in
關鍵字,則需要使用正確的 [+In] 上下文傳遞右側表示式,以禁止 #prod-RelationalExpression
中的兩個 in
表示式
RelationalExpression[In, Yield, Await] :
[+In] RelationalExpression[+In, ?Yield, ?Await] in ShiftExpression[?Yield, ?Await]
[+In] PrivateIdentifier in ShiftExpression[?Yield, ?Await]
Note 2: The [In] grammar parameter is needed to avoid confusing the in operator in a relational expression with the in operator in a for statement.
這也是整個規範中 [In]
上下文的唯一應用。
還要說明的是,語法 [lookahead ∉ { let, async of }]
禁止 for (async of ...)
,並且需要明確防止這種情況。
區塊層級函數宣告
在附錄 B.3.2 #sec-block-level-function-declarations-web-legacy-compatibility-semantics
中,整整一頁的內容專門解釋了 FunctionDeclaration
在 Block
陳述中的行為方式。歸根結底
https://github.com/acornjs/acorn/blob/11735729c4ebe590e406f952059813f250a4cbd1/acorn/src/scope.js#L30-L35
如果 FunctionDeclaration
的名稱在函數宣告內部,則需要將其視為與 var
宣告相同。由於 bar
在區塊作用域內,此程式碼片段會因重新宣告錯誤而失敗
function foo() {
if (true) {
var bar;
function bar() {} // redeclaration error
}
}
同時,以下程式碼不會出錯,因為它在函數作用域內,函數 bar
被視為 var 宣告
function foo() {
var bar;
function bar() {}
}
語法上下文
語法文法有 5 個上下文參數,用於允許和禁止某些結構,即 [In]
、[Return]
、[Yield]
、[Await]
和 [Default]
。
最好在解析期間保留上下文,例如在 Biome 中
// https://github.com/rome/tools/blob/5a059c0413baf1d54436ac0c149a829f0dfd1f4d/crates/rome_js_parser/src/state.rs#L404-L425
pub(crate) struct ParsingContextFlags: u8 {
/// Whether the parser is in a generator function like `function* a() {}`
/// Matches the `Yield` parameter in the ECMA spec
const IN_GENERATOR = 1 << 0;
/// Whether the parser is inside a function
const IN_FUNCTION = 1 << 1;
/// Whatever the parser is inside a constructor
const IN_CONSTRUCTOR = 1 << 2;
/// Is async allowed in this context. Either because it's an async function or top level await is supported.
/// Equivalent to the `Async` generator in the ECMA spec
const IN_ASYNC = 1 << 3;
/// Whether the parser is parsing a top-level statement (not inside a class, function, parameter) or not
const TOP_LEVEL = 1 << 4;
/// Whether the parser is in an iteration or switch statement and
/// `break` is allowed.
const BREAK_ALLOWED = 1 << 5;
/// Whether the parser is in an iteration statement and `continue` is allowed.
const CONTINUE_ALLOWED = 1 << 6;
並透過遵循文法相應地切換和檢查這些標誌。
AssignmentPattern 與 BindingPattern
在 estree
中,AssignmentExpression
的左側是一個 Pattern
extend interface AssignmentExpression {
left: Pattern;
}
而 VariableDeclarator
的左側是一個 Pattern
interface VariableDeclarator <: Node {
type: "VariableDeclarator";
id: Pattern;
init: Expression | null;
}
Pattern
可以是 Identifier
、ObjectPattern
、ArrayPattern
interface Identifier <: Expression, Pattern {
type: "Identifier";
name: string;
}
interface ObjectPattern <: Pattern {
type: "ObjectPattern";
properties: [ AssignmentProperty ];
}
interface ArrayPattern <: Pattern {
type: "ArrayPattern";
elements: [ Pattern | null ];
}
但是從規範的角度來看,我們有以下 JavaScript
// AssignmentExpression:
{ foo } = bar;
^^^ IdentifierReference
[ foo ] = bar;
^^^ IdentifierReference
// VariableDeclarator
var { foo } = bar;
^^^ BindingIdentifier
var [ foo ] = bar;
^^^ BindingIdentifier
這開始變得令人困惑,因為我們現在處於一種情況,即無法直接區分 Pattern
內的 Identifier
是 BindingIdentifier
還是 IdentifierReference
enum Pattern {
Identifier, // Is this a `BindingIdentifier` or a `IdentifierReference`?
ArrayPattern,
ObjectPattern,
}
這將導致在解析器管道的更下游產生各種不必要的程式碼。例如,在設定語意分析的範圍時,我們需要檢查此 Identifier
的父項,以確定我們是否應該將其綁定到範圍。
更好的解決方案是充分理解規範並決定如何處理。
AssignmentExpression
和 VariableDeclaration
的語法定義如下
13.15 Assignment Operators
AssignmentExpression[In, Yield, Await] :
LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]
13.15.5 Destructuring Assignment
In certain circumstances when processing an instance of the production
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
the interpretation of LeftHandSideExpression is refined using the following grammar:
AssignmentPattern[Yield, Await] :
ObjectAssignmentPattern[?Yield, ?Await]
ArrayAssignmentPattern[?Yield, ?Await]
14.3.2 Variable Statement
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]opt
BindingPattern[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]
該規範透過使用 AssignmentPattern
和 BindingPattern
單獨定義它們來區分這兩種語法。
所以在這種情況下,不要害怕偏離 estree
,為我們的解析器定義額外的 AST 節點
enum BindingPattern {
BindingIdentifier,
ObjectBindingPattern,
ArrayBindingPattern,
}
enum AssignmentPattern {
IdentifierReference,
ObjectAssignmentPattern,
ArrayAssignmentPattern,
}
我整整一個星期都處於非常困惑的狀態,直到我終於開竅:我們需要定義一個 AssignmentPattern
節點和一個 BindingPattern
節點,而不是單一的 Pattern
節點。
estree
一定是正確的,因為人們已經使用它多年了,所以它不可能是錯的嗎?- 如果不定義兩個獨立的節點,我們如何才能清楚地區分模式內的
Identifier
?我就是找不到語法在哪裡? - 花了一整天瀏覽規範...
AssignmentPattern
的語法在主要章節「13.15 賦值運算符」的第五個小節中,標題為「補充語法」🤯 - 這真的有點不尋常,因為所有語法都定義在主要章節中,而不是像這樣定義在「執行期語義」章節之後
提示
以下情況真的很难掌握。这里有恶龙出没。
模糊語法
讓我們先像解析器一樣思考並解決問題 - 給定 /
符號,它是除法運算符還是正規表達式的開始?
a / b;
a / / regex /;
a /= / regex /;
/ regex / / b;
/=/ / /=/;
這幾乎是不可能的,不是嗎?讓我們分解這些並遵循語法。
我們首先需要了解的是,如 #sec-ecmascript-language-lexical-grammar
中所述,語法語法驅動詞彙語法
在某些情況下,詞彙輸入元素的識別對使用輸入元素的語法語法上下文很敏感。
這表示解析器負責告訴詞法分析器接下來要返回哪個符號。上面的例子表示詞法分析器需要返回 /
符號或 RegExp
符號。為了獲得正確的 /
或 RegExp
符號,規範說
InputElementRegExp 目標符號用於所有允許使用 RegularExpressionLiteral 的語法語法上下文中...在所有其他上下文中,InputElementDiv 用作詞彙目標符號。
InputElementDiv
和 InputElementRegExp
的語法是
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator <---------- the `/` and `/=` token
RightBracePunctuator
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral <-------- the `RegExp` token
這表示每當語法達到 RegularExpressionLiteral
時,/
需要被標記為 RegExp
符號(如果沒有匹配的 /
則會拋出錯誤)。在所有其他情況下,我們將把 /
標記為斜線符號。
讓我們看一個例子
a / / regex /
^ ------------ PrimaryExpression:: IdentifierReference
^ ---------- MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression
^^^^^^^^ - PrimaryExpression: RegularExpressionLiteral
這個語句不符合任何其他 Statement
的開頭,所以它會進入 ExpressionStatement
的路徑
ExpressionStatement
--> Expression
--> AssignmentExpression
--> ... --> MultiplicativeExpression
--> ... --> MemberExpression
--> PrimaryExpression
--> IdentifierReference
。
我們停在了 IdentifierReference
而不是 RegularExpressionLiteral
,所以「在所有其他上下文中,InputElementDiv 用作詞彙目標符號」這個語句適用。第一個斜線是 DivPunctuator
符號。
由於這是一個 DivPunctuator
符號,語法 MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression
被匹配,右邊預期為 ExponentiationExpression
。
現在我們來到 a / /
中的第二個斜線。透過追蹤 ExponentiationExpression
,我們到達 PrimaryExpression: RegularExpressionLiteral
,因為 RegularExpressionLiteral
是唯一符合 /
的語法
RegularExpressionLiteral ::
/ RegularExpressionBody / RegularExpressionFlags
第二個 /
將被標記為 RegExp
,因為規範規定「InputElementRegExp 目標符號用於所有允許使用 RegularExpressionLiteral 的語法語法上下文中」。
資訊
作為一個練習,嘗試追蹤 /=/ / /=/
的語法。
覆蓋語法
請先閱讀關於此主題的 V8 部落格文章。
總結來說,規範定義了以下三個覆蓋語法
CoverParenthesizedExpressionAndArrowParameterList
PrimaryExpression[Yield, Await] :
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
When processing an instance of the production
PrimaryExpression[Yield, Await] : CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
the interpretation of CoverParenthesizedExpressionAndArrowParameterList is refined using the following grammar:
ParenthesizedExpression[Yield, Await] :
( Expression[+In, ?Yield, ?Await] )
ArrowFunction[In, Yield, Await] :
ArrowParameters[?Yield, ?Await] [no LineTerminator here] => ConciseBody[?In]
ArrowParameters[Yield, Await] :
BindingIdentifier[?Yield, ?Await]
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
這些定義定義了
let foo = (a, b, c); // SequenceExpression
let bar = (a, b, c) => {}; // ArrowExpression
^^^^^^^^^ CoverParenthesizedExpressionAndArrowParameterList
解決這個問題的一個簡單但麻煩的方法是先將其解析為 Vec<Expression>
,然後編寫一個轉換函數將其轉換為 ArrowParameters
節點,即每個個別的 Expression
都需要轉換為 BindingPattern
。
應該注意的是,如果我們在解析器中建立作用域樹,即在解析期間為箭頭表達式建立作用域,但不為序列表達式建立作用域,則如何做到這一點並不顯而易見。esbuild 通過先建立一個臨時作用域來解決這個問題,然後如果它不是 ArrowExpression
則將其刪除。
這在其架構文件中有說明
這大部分都很直接,除了一些解析器已推送一個作用域並正在解析聲明的過程中,卻發現它根本不是聲明的情況。當 TypeScript 中函數在沒有主體的情況下前向聲明時,以及在 JavaScript 中,當一個帶括號的表達式是否為箭頭函數不明確,直到我們之後達到 => 符號時,就會發生這種情況。這可以通過執行三個 pass 而不是兩個 pass 來解決,這樣我們就可以在開始設置作用域和聲明符號之前完成解析,但是我們正嘗試僅用兩個 pass 來完成此操作。因此,我們調用 popAndDiscardScope() 或 popAndFlattenScope() 而不是 popScope(),以便在我們的假設被證明不正確時稍後修改作用域樹。
CoverCallExpressionAndAsyncArrowHead
CallExpression :
CoverCallExpressionAndAsyncArrowHead
When processing an instance of the production
CallExpression : CoverCallExpressionAndAsyncArrowHead
the interpretation of CoverCallExpressionAndAsyncArrowHead is refined using the following grammar:
CallMemberExpression[Yield, Await] :
MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await]
AsyncArrowFunction[In, Yield, Await] :
CoverCallExpressionAndAsyncArrowHead[?Yield, ?Await] [no LineTerminator here] => AsyncConciseBody[?In]
CoverCallExpressionAndAsyncArrowHead[Yield, Await] :
MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await]
When processing an instance of the production
AsyncArrowFunction : CoverCallExpressionAndAsyncArrowHead => AsyncConciseBody
the interpretation of CoverCallExpressionAndAsyncArrowHead is refined using the following grammar:
AsyncArrowHead :
async [no LineTerminator here] ArrowFormalParameters[~Yield, +Await]
這些定義定義了
async (a, b, c); // CallExpression
async (a, b, c) => {} // AsyncArrowFunction
^^^^^^^^^^^^^^^ CoverCallExpressionAndAsyncArrowHead
這看起來很奇怪,因為 async
不是關鍵字。第一個 async
是函數名稱。
CoverInitializedName
13.2.5 Object Initializer
ObjectLiteral[Yield, Await] :
...
PropertyDefinition[Yield, Await] :
CoverInitializedName[?Yield, ?Await]
Note 3: In certain contexts, ObjectLiteral is used as a cover grammar for a more restricted secondary grammar.
The CoverInitializedName production is necessary to fully cover these secondary grammars. However, use of this production results in an early Syntax Error in normal contexts where an actual ObjectLiteral is expected.
13.2.5.1 Static Semantics: Early Errors
In addition to describing an actual object initializer the ObjectLiteral productions are also used as a cover grammar for ObjectAssignmentPattern and may be recognized as part of a CoverParenthesizedExpressionAndArrowParameterList. When ObjectLiteral appears in a context where ObjectAssignmentPattern is required the following Early Error rules are not applied. In addition, they are not applied when initially parsing a CoverParenthesizedExpressionAndArrowParameterList or CoverCallExpressionAndAsyncArrowHead.
PropertyDefinition : CoverInitializedName
I* t is a Syntax Error if any source text is matched by this production.
13.15.1 Static Semantics: Early Errors
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
If LeftHandSideExpression is an ObjectLiteral or an ArrayLiteral, the following Early Error rules are applied:
* LeftHandSideExpression must cover an AssignmentPattern.
這些定義定義了
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop = value }); // ObjectLiteral with SyntaxError
解析器需要使用 CoverInitializedName
解析 ObjectLiteral
,如果它沒有達到 ObjectAssignmentPattern
的 =
,則會拋出語法錯誤。
作為一個練習,以下哪個 =
應該拋出語法錯誤?
let { x = 1 } = { x = 1 } = { x = 1 }