跳至内容

语法

JavaScript 拥有最具挑战性的语法之一,本教程详细介绍了我在学习过程中付出的努力和心血。

LL(1) 语法

根据 维基百科

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

第一个 L 表示从 Left(左)向 right(右)扫描源,第二个 L 表示构造 Leftmost(最左)派生树。

无上下文和 LL(1) 中的 (1) 表示可以通过仅查看下一个标记和其它任何内容构建一个树。

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

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

信息

Mozilla 几年之前启动了 jsaparagus 项目,并用 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 是一个 BindingIdentifierbar 是语法中的一个 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");

旧八进制数和 Use Strict

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

检测这一点的最佳位置是在词法分析器中,它可以询问解析器严格模式的状态,并相应地抛出错误。

但是,当它与指令混合在一起时,就会变得不可能

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 :

It is a Syntax Error if FunctionBodyContainsUseStrict of FunctionBody is true and IsSimpleParameterList of FormalParameters is false.

Chrome 会抛出此错误以及一条神秘的消息“未捕获 SyntaxError: 非简单参数列表中的函数中的非法“use strict”指令”。

ESLint 作者在这篇博客文章中给出了更深入的解释。

信息

有趣的事实,如果我们在 TypeScript 中针对es5,则以上规则不适用,它会编译为

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

带括号的表达式

带括号的表达式应该没有任何语义含义?例如,((x))的 AST 可以只是一个 IdentifierReference,而不是 ParenthesizedExpression -> ParenthesizedExpression -> IdentifierReference。并且这就是 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] :
    ... lots of statements

Declaration[Yield, Await] :
    ... declarations

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

但在 #sec-functiondeclarations-in-ifstatement-statement-clauses的附件 B 中,它允许在非严格模式中声明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-ForInOfStatement 中查看 for-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]

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 中,整整一页都用于解释 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() {} // redeclaration error
  }
}

同时,以下代码没有错误是因为它在函数作用域内,函数 bar 被视为 var 声明

javascript
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 可以是 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

enum Pattern {
    Identifier, // Is this a `BindingIdentifier` or a `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

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]

规范通过使用 AssignmentPatternBindingPattern 分别定义它们来区分这两个语法。

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

enum BindingPattern {
    BindingIdentifier,
    ObjectBindingPattern,
    ArrayBindingPattern,
}

enum AssignmentPattern {
    IdentifierReference,
    ObjectAssignmentPattern,
    ArrayAssignmentPattern,
}

在整个一周的时间里,我一直处于非常困惑的状态,直到我终于顿悟:我们需要定义 AssignmentPattern 节点和 BindingPattern 节点,而不是单个 Pattern 节点。

  • estree 肯定没错,因为人们已经使用它很多年了,对吧?
  • 我们如何在不定义两个单独的节点的情况下,清晰地区分模式中的 Identifier?我只是找不到语法规则?
  • 在花了一整天时间浏览了规范之后… AssignmentPattern 的语法位于主部分 "13.15 Assignment Operators" 的第 5 个小节中,标题为 "Supplemental Syntax" 🤯 - 这确实不合适,因为所有的语法规则都定义在主部分中,而不是像这样在 "Runtime Semantics" 部分之后定义。

提示

以下案例确实难以理解。此中有龙。

模稜两可的语法

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

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 <---------- 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,因为RegularExpressionLiteral是唯一的带有/标记的匹配语法,因此我们达到PrimaryExpression: 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]

这些定义定义了

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

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]

这些定义定义了

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

这些定义定义了

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

解析器需要使用 CoverInitializedName 解析 ObjectLiteral,并且如果未达到 ObjectAssignmentPattern=,则会引发语法错误。

作为一个练习,以下哪一个 = 应该引发语法错误?

javascript
let { x = 1 } = { x = 1 } = { x = 1 }

根据 MIT 许可证发布。