语法
JavaScript 拥有最复杂的语法之一,需要解析,本教程详细记录了我在学习过程中经历的所有艰辛与泪水。
LL(1) 语法
根据 Wikipedia,
LL 语法是一种上下文无关语法,可以由一个 LL 解析器进行解析,该解析器从左到右扫描输入
第一个 L 表示从 左 到右扫描源码,第二个 L 表示构建 最左推导 树。
上下文无关以及 (1) 在 LL(1) 中表示:仅通过查看下一个标记即可构造出一棵树,而无需其他信息。
在学术界,LL 语法特别受关注,因为人类是懒惰的,我们希望编写能够自动生成解析器的程序,从而避免手动编写解析器。
不幸的是,大多数工业级编程语言都没有漂亮的 LL(1) 语法,这同样适用于 JavaScript。
INFO
Mozilla 几年前启动了 jsparagus 项目, 并用 Python 编写了 一个 LALR 解析器生成器。 过去两年中他们很少更新,并且在 js-quirks.md 的结尾发送了一个强烈的信号:
我们今天学到了什么?
- 不要编写 JavaScript 解析器。
- 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 中明确指出:“节点:类定义始终是严格模式代码。”
通过将严格模式与函数作用域关联起来,很容易声明严格模式,但 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
如果匹配此产生的源码是严格模式代码,则为语法错误。检测此类问题的最佳位置是在词法分析器内部,它可以向解析器查询严格模式状态并相应地抛出错误。
但是,当与指令混合时,这就变得不可能了:
https://github.com/tc39/test262/blob/747bed2e8aaafe8fdf2c65e8a10dd7ae64f66c47/test/language/literals/string/legacy-octal-escape-sequence-prologue-strict.js#L16-L19use 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 :
如果函数体包含 `use strict` 且形式参数列表不是简单的,则为语法错误。Chrome 抛出此错误时附带一条神秘消息:“未捕获的语法错误:在具有非简单参数列表的函数中非法的 'use strict' 指令”。
更详细的解释见作者 ESLint 的 这篇博客文章。
INFO
有趣的是,如果我们目标是 TypeScript 中的 es5,上述规则不适用,它会被转换为:
function foo(a, b) {
"use strict";
if (b === void 0) b = "\01";
}括号表达式
括号表达式理论上不应有任何语义含义?
例如 ((x)) 的抽象语法树可以只是一个 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] :
... 各种语句
Declaration[Yield, Await] :
... 声明我们为抽象语法树定义的 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 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]
注释 2:[In] 语法参数用于避免将关系表达式中的 `in` 操作符与 `for` 语句中的 `in` 操作符混淆。这是整个规范中唯一使用 [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() {} // 重复声明错误
}
}而下面的情况不会报错,因为它位于函数作用域内,函数 bar 被视为 var 声明:
function foo() {
var bar;
function bar() {}
}语法上下文
语法有五个上下文参数,用于允许或禁止某些结构,分别是 [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 {
/// 是否解析器处于生成器函数中,如 `function* a() {}`
/// 对应于 ECMA 规范中的 `Yield` 参数
const IN_GENERATOR = 1 << 0;
/// 是否解析器位于函数内部
const IN_FUNCTION = 1 << 1;
/// 解析器是否位于构造函数中
const IN_CONSTRUCTOR = 1 << 2;
/// 此上下文中是否允许使用 `async`。要么是因为是异步函数,要么是因为支持顶层 `await`。
/// 等价于 ECMA 规范中的 `Async` 生成器
const IN_ASYNC = 1 << 3;
/// 是否解析器正在解析顶层语句(不在类、函数、参数内)
const TOP_LEVEL = 1 << 4;
/// 是否解析器位于迭代或切换语句中,并且 `break` 是允许的
const BREAK_ALLOWED = 1 << 5;
/// 是否解析器位于迭代语句中,并且 `continue` 是允许的
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, // 这是 `BindingIdentifier` 还是 `IdentifierReference`?
ArrayPattern,
ObjectPattern,
}这将在解析管道后续环节导致各种不必要的代码。 例如,在设置语义分析的作用域时,我们需要检查该 Identifier 的父节点,以确定是否应将其绑定到作用域。
更好的解决方案是彻底理解规范,并决定如何处理。
AssignmentExpression 与 VariableDeclaration 的语法定义如下:
13.15 赋值操作符
AssignmentExpression[In, Yield, Await] :
LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]
13.15.5 解构赋值
在处理 `AssignmentExpression : LeftHandSideExpression = AssignmentExpression` 实例时,
`LeftHandSideExpression` 的解释会通过以下语法进一步细化:
AssignmentPattern[Yield, Await] :
ObjectAssignmentPattern[?Yield, ?Await]
ArrayAssignmentPattern[?Yield, ?Await]14.3.2 变量语句
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]opt
BindingPattern[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]规范通过分别定义 AssignmentPattern 与 BindingPattern 来区分这两种语法。
因此,在这种情况下,不要害怕偏离 estree,为我们的解析器定义额外的抽象语法树节点:
enum BindingPattern {
BindingIdentifier,
ObjectBindingPattern,
ArrayBindingPattern,
}
enum AssignmentPattern {
IdentifierReference,
ObjectAssignmentPattern,
ArrayAssignmentPattern,
}我曾为此困惑整整一周,直到最终顿悟: 我们必须定义 AssignmentPattern 节点与 BindingPattern 节点,而不是单一的 Pattern 节点。
estree必须正确,因为人们多年来一直使用它,不可能出错?- 我们该如何干净地区分模式中的
Identifier而不定义两个独立的节点?我就是找不到语法在哪? - 经过一整天浏览规范……
AssignmentPattern的语法在“13.15 赋值操作符”主节的第五个小节中,标题为“补充语法” 🤯——这真的很奇怪,因为所有语法通常定义在主节中,不像这个定义在“运行时语义”章节之后。
TIP
以下情况非常难以理解。此处有龙。
模糊语法
首先让我们像解析器一样思考,解决这个问题——给定 / 令牌,它是除法运算符还是正则表达式开头?
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 <---------- `/` 和 `/=` 令牌
RightBracePunctuator
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral <-------- `RegExp` 令牌这意味着每当语法到达 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 的语法上下文。”
INFO
作为练习,尝试跟随 /=/ / /=/ 的语法。
覆盖语法
首先阅读 V8 博客文章 关于此主题。
总结来说,规范提到了以下三种覆盖语法:
CoverParenthesizedExpressionAndArrowParameterList
PrimaryExpression[Yield, Await] :
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
当处理实例
PrimaryExpression[Yield, Await] : CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
时,`CoverParenthesizedExpressionAndArrowParameterList` 的解释通过以下语法细化:
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 中发生在括号表达式是否为箭头函数尚不明确,直到遇到
=>令牌为止。如果做三遍而不是两遍,就能解决这个问题,即完成解析后再开始设置作用域和声明符号,但我们试图只用两遍完成。因此我们调用popAndDiscardScope()或popAndFlattenScope()而不是popScope(),以便在假设不成立时稍后修改作用域树。
CoverCallExpressionAndAsyncArrowHead
CallExpression :
CoverCallExpressionAndAsyncArrowHead
当处理实例
CallExpression : CoverCallExpressionAndAsyncArrowHead
时,`CoverCallExpressionAndAsyncArrowHead` 的解释通过以下语法细化:
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]
当处理实例
AsyncArrowFunction : CoverCallExpressionAndAsyncArrowHead => AsyncConciseBody
时,`CoverCallExpressionAndAsyncArrowHead` 的解释通过以下语法细化:
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 对象初始值设定项
ObjectLiteral[Yield, Await] :
...
PropertyDefinition[Yield, Await] :
CoverInitializedName[?Yield, ?Await]
注释 3:在某些上下文中,`ObjectLiteral` 用作更受限的次级语法的覆盖语法。
`CoverInitializedName` 生产是完全覆盖这些次级语法所必需的。然而,在正常上下文中,使用此生产会导致早期语法错误,因为预期的是真正的 `ObjectLiteral`。
13.2.5.1 静态语义:早期错误
除了描述真正的对象初始值设定项外,`ObjectLiteral` 生产还用作 `ObjectAssignmentPattern` 的覆盖语法,也可能被识别为 `CoverParenthesizedExpressionAndArrowParameterList` 的一部分。当 `ObjectLiteral` 出现在要求 `ObjectAssignmentPattern` 的上下文中时,以下早期错误规则不适用。此外,它们也不适用于最初解析 `CoverParenthesizedExpressionAndArrowParameterList` 或 `CoverCallExpressionAndAsyncArrowHead` 时。
PropertyDefinition : CoverInitializedName
* 如果任何源码匹配此生产,则为语法错误。13.15.1 静态语义:早期错误
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
如果 `LeftHandSideExpression` 是 `ObjectLiteral` 或 `ArrayLiteral`,则应用以下早期错误规则:
* `LeftHandSideExpression` 必须覆盖一个 `AssignmentPattern`。这些定义说明:
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // 有语法错误的 `ObjectLiteral`解析器需要使用 CoverInitializedName 解析 ObjectLiteral, 并在 ObjectAssignmentPattern 未达到 = 时抛出语法错误。
作为练习,下列哪一个 = 应抛出语法错误?
let { x = 1 } = ({ x = 1 } = { x: 1 });