Skip to content

语法

JavaScript 拥有最具挑战性的语法之一, 本教程详细介绍了我学习过程中的所有艰辛和泪水。

LL(1) 语法

根据 Wikipedia

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

第一个 L 意味着从到右扫描源代码, 第二个 L 意味着构造最左推导树。

上下文无关和 LL(1) 中的 (1) 意味着只需 peek 下一个词法单元就可以构造树,不需要其他任何东西。

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 中,fooBindingIdentifierbarIdentifierReference,语法如下:

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");

旧式八进制和 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 :

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

Chrome 抛出这个错误,消息很神秘 "Uncaught SyntaxError: Illegal 'use strict' directive in function with non-simple parameter list"。

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 在这样的位置是不允许的。 解析器需要 peek 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,我们需要检查 peek 的词法单元是否:

  • 不是 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 vs 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,为我们的解析器定义额外的 AST 节点:

rust
enum BindingPattern {
    BindingIdentifier,
    ObjectBindingPattern,
    ArrayBindingPattern,
}

enum AssignmentPattern {
    IdentifierReference,
    ObjectAssignmentPattern,
    ArrayAssignmentPattern,
}

我整个星期都处于超级困惑的状态,直到最终开悟: 我们需要定义一个 AssignmentPattern 节点和一个 BindingPattern 节点,而不是单个 Pattern 节点。

  • estree 一定是正确的,因为人们已经使用它多年了,所以它不可能错?
  • 我们如何在不定义两个单独节点的情况下干净地区分 Pattern 中的 Identifier?我就是找不到语法在哪里?
  • 经过整整一天的规范导航...AssignmentPattern 的语法在主要章节"13.15 赋值运算符"的第 5 个子章节中,副标题是"补充语法" 🤯 - 这真的位置不对,因为所有语法都在主要章节中定义,不像这个定义在"运行时语义"章节之后

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 中,当括号表达式是箭头函数还是不确定直到我们到达 => 词法单元时。这可以通过做三次 pass 而不是两次来解决,这样我们在开始设置作用域和声明符号之前完成解析,但我们试图在两次 pass 中做到这一点。所以相反,如果我们的假设不正确,我们调用 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 带有 SyntaxError

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

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

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