Skip to content

语法

JavaScript 拥有最难解析的语法之一, 本教程详细介绍了我在学习它时所有的辛酸泪。

LL(1) 语法

根据 维基百科

LL 语法是一种上下文无关语法,可以被 LL 解析器解析,该解析器从左到右解析输入

第一个 L 表示从 Left(左)到右扫描源代码, 第二个 L 表示构建一个 Leftmost(最左)推导树。

上下文无关以及 LL(1) 中的 (1) 意味着可以通过仅查看下一个标记来构建树,而无需其他信息。

LL 语法在学术界特别受关注,因为我们是懒惰的人类,我们希望编写自动生成解析器的程序,这样我们就不需要手动编写解析器了。

不幸的是,大多数工业编程语言都没有一个好的 LL(1) 语法, 这同样适用于 JavaScript。

INFO

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,而 barIdentifierReference

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,
}

Class 和严格模式

ECMAScript Class 出生于严格模式之后,因此他们决定为了简单起见,类中的所有内容都必须是严格模式。 这在 #sec-class-definitions 中被明确说明为 Node: A class definition is always strict mode code.(节点:类定义始终是严格模式代码)。

通过将严格模式与函数作用域关联起来很容易声明严格模式,但 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");

旧式八进制和 Use Strict

#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 :

如果 FunctionBodyContainsUseStrict of FunctionBody 为 true 且 IsSimpleParameterList of FormalParameters 为 false,则为语法错误。

Chrome 以神秘的消息“Uncaught SyntaxError: Illegal 'use strict' directive in function with non-simple parameter list”(未捕获的语法错误:具有非简单参数列表的函数中存在非法的 'use strict' 指令)抛出此错误。

ESLint 作者的这篇博文对此进行了更深入的解释。

INFO

有趣的是,上述规则不适用于你在 TypeScript 中以 es5 为目标的情况,它会转译为

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

括号表达式

括号表达式应该没有任何语义含义? 例如,((x)) 的 AST 可以只是一个单独的 IdentifierReference,而不是 ParenthesizedExpression -> ParenthesizedExpression -> IdentifierReference。 JavaScript 语法也是如此。

但是……谁能想到它会有运行时含义。 在 这个 estree issue 中发现,它显示

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] :
    ... 声明

我们为 AST 定义的 Statement 节点显然不包含 Declaration

但在 Annex 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 在这些位置不允许出现。 解析器需要查看 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 ...), 需要明确防范。


块级函数声明

在 Annex 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() {}
}

语法上下文

语法有 5 个上下文参数,用于允许和禁止某些结构, 即 [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。要么是因为它是 async 函数,要么是支持顶层 await。
    /// 对应 ECMA 规范中的 `Async` 生成器
    const IN_ASYNC = 1 << 3;

    /// 解析器是否正在解析顶层语句(不在类、函数、参数内部)
    const TOP_LEVEL = 1 << 4;

    /// 解析器是否在迭代或 switch 语句中,并且允许 `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

这开始变得令人困惑,因为我们现在面临一个情况,即无法直接区分 IdentifierBindingIdentifier 还是 Pattern 中的 IdentifierReference

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

这将导致解析器管道下游出现各种不必要的代码。 例如,在为语义分析设置作用域时,我们需要检查此 Identifier 的父级 以确定是否应将其绑定到作用域。

更好的解决方案是完全理解规范并决定如何处理。

AssignmentExpressionVariableDeclaration 的语法定义如下:

13.15 Assignment Operators

AssignmentExpression[In, Yield, Await] :
    LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]

13.15.5 Destructuring Assignment

在处理 AssignmentExpression : LeftHandSideExpression = AssignmentExpression 实例的某些情况下,
LeftHandSideExpression 的解释会使用以下语法进行细化:

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]

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

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

rust
enum BindingPattern {
    BindingIdentifier,
    ObjectBindingPattern,
    ArrayBindingPattern,
}

enum AssignmentPattern {
    IdentifierReference,
    ObjectAssignmentPattern,
    ArrayAssignmentPattern,
}

我曾陷入一个非常混乱的状态整整一周,直到我最终开悟: 我们需要定义一个 AssignmentPattern 节点和一个 BindingPattern 节点,而不是一个单一的 Pattern 节点。

  • estree 必须是正确的,因为人们已经使用了它很多年,所以它不可能是错的?
  • 在不定义两个单独的节点的情况下,我们将如何清晰地区分模式中的 Identifier?我找不到语法在哪里?
  • 在浏览了整整一天的规范后…… AssignmentPattern 的语法位于主部分“13.15 Assignment Operators”的第五个子部分,副标题为“Supplemental Syntax” 🤯 - 这真的不合适,因为所有语法都定义在主部分,而不是像这个定义在“Runtime Semantics”部分之后。

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 Object Initializer

ObjectLiteral[Yield, Await] :
    ...

PropertyDefinition[Yield, Await] :
    CoverInitializedName[?Yield, ?Await]

Note 3: 在某些情况下,ObjectLiteral 用作更受限制的次要语法的覆盖语法。
CoverInitializedName 产生式对于完全覆盖这些次要语法是必需的。然而,使用此产生式会在实际期望 ObjectLiteral 的正常上下文中导致早期语法错误。

13.2.5.1 Static Semantics: Early Errors

除了描述实际的对象初始化器外,ObjectLiteral 产生式还用作 ObjectAssignmentPattern 的覆盖语法,并且可能被识别为 CoverParenthesizedExpressionAndArrowParameterList 的一部分。当 ObjectLiteral 出现在需要 ObjectAssignmentPattern 的上下文中时,以下早期错误规则不适用。此外,当最初解析 CoverParenthesizedExpressionAndArrowParameterList 或 CoverCallExpressionAndAsyncArrowHead 时,它们也不适用。

PropertyDefinition : CoverInitializedName
    I* 如果任何源文本被此产生式匹配,则为语法错误。
13.15.1 Static Semantics: Early Errors

AssignmentExpression : LeftHandSideExpression = AssignmentExpression
如果 LeftHandSideExpression 是 ObjectLiteral 或 ArrayLiteral,则应用以下早期错误规则:
    * LeftHandSideExpression 必须覆盖一个 AssignmentPattern。

这些定义了:

javascript
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // ObjectLiteral with SyntaxError

解析器需要解析带有 CoverInitializedNameObjectLiteral, 如果它没有达到 = 来进行 ObjectAssignmentPattern,则抛出语法错误。

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

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