Skip to content

语法

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)中以不同方式声明这些标识符,将极大地简化下游工具,尤其是语义分析。

rust
pub struct BindingIdentifier {
    pub node: Node,
    pub name: Atom,
}

pub struct IdentifierReference {
    pub node: Node,
    pub name: Atom,
}

类和严格模式

ECMAScript 类诞生于严格模式之后,因此他们决定为了简化,类内部的所有内容都必须处于严格模式。 这在 #sec-class-definitions 中明确指出:“节点:类定义始终是严格模式代码。”

通过将严格模式与函数作用域关联起来,很容易声明严格模式,但 class 声明没有作用域, 我们需要额外的状态来专门处理类的解析。

rust
// 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

如果匹配此产生的源码是严格模式代码,则为语法错误。

检测此类问题的最佳位置是在词法分析器内部,它可以向解析器查询严格模式状态并相应地抛出错误。

但是,当与指令混合时,这就变得不可能了:

javascript
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" 是严格模式错误?

javaScript
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,上述规则不适用,它会被转换为:

javaScript
function foo(a, b) {
  "use strict";
  if (b === void 0) b = "\01";
}

括号表达式

括号表达式理论上不应有任何语义含义?
例如 ((x)) 的抽象语法树可以只是一个 IdentifierReference,而不是 ParenthesizedExpressionParenthesizedExpressionIdentifierReference。 JavaScript 语法正是如此。

但……谁能想到它竟然在运行时有实际意义。 在 这个 estree 问题 中发现:

javascript
> 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 语句的语句位置中包含声明:

javascript
if (x) {
  function foo() {}
} else function bar() {}

标签语句是合法的

我们可能从未写过一行标签语句,但在现代 JavaScript 中它是合法的,且不受严格模式限制。

以下语法是正确的,它返回一个标签语句(而非对象字面量)。

javascript
<Foo
  bar={() => {
    baz: "quaz";
  }}
/>
//   ^^^^^^^^^^^ `LabelledStatement`

let 不是关键字

let 不是关键字,因此它可以在任何地方出现,除非语法明确说明不允许出现在此类位置。 解析器需要查看 let 令牌之后的令牌,并决定应将其解析为何种结构,例如:

javascript
let a;
let = foo;
let instanceof x;
let + 1;
while (true) let;
a = let[0];

for-in / for-of 与 [In] 上下文

如果我们查看 #prod-ForInOfStatementfor-infor-of 的语法,

立即会让人困惑如何解析这些结构。

对我们理解构成障碍的有两个主要因素:[lookahead ≠ let] 部分和 [+In] 部分。

如果已经解析到 for (let,我们需要检查接下来的标记是否:

  • 不是 in 以禁止 for (let in)
  • {[ 或标识符以允许 for (let {} = foo)for (let [] = foo)for (let bar = foo)

一旦到达 ofin 关键字,右侧表达式必须传递正确的 [+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, 整页内容都在解释 FunctionDeclarationBlock 语句中应该如何表现。 简而言之:

javascript
https://github.com/acornjs/acorn/blob/11735729c4ebe590e406f952059813f250a4cbd1/acorn/src/scope.js#L30-L35

如果 FunctionDeclaration 位于函数声明内,则其名称需像 var 声明一样处理。 这段代码会因重复声明而报错,因为 bar 处于块级作用域中:

javascript
function foo() {
  if (true) {
    var bar;
    function bar() {} // 重复声明错误
  }
}

而下面的情况不会报错,因为它位于函数作用域内,函数 bar 被视为 var 声明:

javascript
function foo() {
  var bar;
  function bar() {}
}

语法上下文

语法有五个上下文参数,用于允许或禁止某些结构,分别是 [In][Return][Yield][Await][Default]

最好在解析期间保持一个上下文,例如在 Biome:

rust
// 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 可以是 IdentifierObjectPatternArrayPattern

interface Identifier <: Expression, Pattern {
    type: "Identifier";
    name: string;
}

interface ObjectPattern <: Pattern {
    type: "ObjectPattern";
    properties: [ AssignmentProperty ];
}

interface ArrayPattern <: Pattern {
    type: "ArrayPattern";
    elements: [ Pattern | null ];
}

但从规范角度看,我们有以下的 JavaScript:

javascript
// AssignmentExpression:
{ foo } = bar;
  ^^^ IdentifierReference
[ foo ] = bar;
  ^^^ IdentifierReference

// VariableDeclarator
var { foo } = bar;
      ^^^ BindingIdentifier
var [ foo ] = bar;
      ^^^ BindingIdentifier

这开始变得令人困惑,因为我们现在遇到了一种情况:无法直接区分 Pattern 内的 IdentifierBindingIdentifier 还是 IdentifierReference

rust
enum Pattern {
    Identifier, // 这是 `BindingIdentifier` 还是 `IdentifierReference`?
    ArrayPattern,
    ObjectPattern,
}

这将在解析管道后续环节导致各种不必要的代码。 例如,在设置语义分析的作用域时,我们需要检查该 Identifier 的父节点,以确定是否应将其绑定到作用域。

更好的解决方案是彻底理解规范,并决定如何处理。

AssignmentExpressionVariableDeclaration 的语法定义如下:

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]

规范通过分别定义 AssignmentPatternBindingPattern 来区分这两种语法。

因此,在这种情况下,不要害怕偏离 estree,为我们的解析器定义额外的抽象语法树节点:

rust
enum BindingPattern {
    BindingIdentifier,
    ObjectBindingPattern,
    ArrayBindingPattern,
}

enum AssignmentPattern {
    IdentifierReference,
    ObjectAssignmentPattern,
    ArrayAssignmentPattern,
}

我曾为此困惑整整一周,直到最终顿悟: 我们必须定义 AssignmentPattern 节点与 BindingPattern 节点,而不是单一的 Pattern 节点。

  • estree 必须正确,因为人们多年来一直使用它,不可能出错?
  • 我们该如何干净地区分模式中的 Identifier 而不定义两个独立的节点?我就是找不到语法在哪?
  • 经过一整天浏览规范……AssignmentPattern 的语法在“13.15 赋值操作符”主节的第五个小节中,标题为“补充语法” 🤯——这真的很奇怪,因为所有语法通常定义在主节中,不像这个定义在“运行时语义”章节之后。

TIP

以下情况非常难以理解。此处有龙。

模糊语法

首先让我们像解析器一样思考,解决这个问题——给定 / 令牌,它是除法运算符还是正则表达式开头?

javascript
a / b;
a / / regex /;
a /= / regex /;
/ regex / / b;
/=/ / /=/;

几乎不可能,不是吗?让我们分解并遵循语法。

首先需要理解的是,如 #sec-ecmascript-language-lexical-grammar 所述,语法驱动词法:

存在几种情况,词法输入元素的识别取决于正在消费这些元素的语法上下文。

这意味着解析器负责告诉词法分析器应返回下一个哪个令牌。 上述例子表明,词法分析器需要返回 / 令牌或 RegExp 令牌。 为获取正确的 /RegExp 令牌,规范说明:

InputElementRegExp 目标符号用于所有允许 RegularExpressionLiteral 的语法上下文…… 在所有其他上下文中,使用 InputElementDiv 作为词法目标符号。

InputElementDivInputElementRegExp 的语法如下:

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]

这些定义说明:

javascript
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]

这些定义说明:

javascript
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`。

这些定义说明:

javascript
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // 有语法错误的 `ObjectLiteral`

解析器需要使用 CoverInitializedName 解析 ObjectLiteral, 并在 ObjectAssignmentPattern 未达到 = 时抛出语法错误。

作为练习,下列哪一个 = 应抛出语法错误?

javascript
let { x = 1 } = ({ x = 1 } = { x: 1 });