JS该写分号结尾吗

semi1

首先,如上图所示,我是一个JS无分号派,此前也没因此遇到问题;

然后,写这道题的时候就遇到问题了;没错,正是因为那一行没加分号。

为什么这里缺少分号会报错呢,什么情况下JS代码不加分号会报错?

为什么有的语言需要分号、有的不需要?

总的来说:分号是语句终止信号的一种形式,影响着语句解析;用不用分号是“多写一点换严谨” 还是 “少写一点换效率”的tradeoff。

C及后来被它影响的C++、Java都使用分号来分隔语句,通过显式的符号来简化语法解析。

另一些语言如早期的Lisp家族、后来的python、JS、GO都不需要分号,其中又可以分为两类:(1) 用其他语法来判断语句边界,如Lisp家族用括号来分隔、python用缩进+换行来分隔 (2)用ASI机制来补全分号:如JS在语法分析时补全分号,GO在词法分析阶段就强制插入分号。

ASI是什么

ASI,Automatic Semicolon Insertion,自动分号插入。 JS的编译执行流程是:原始 JS 代码 → 词法分析 → Token → 语法分析 → AST → 解释器/编译器 → 代码执行。Token是对代码进行的第一次处理结果,本质是根据语法将代码拆分为最小且有独立含义的单元(如运算符,变量名/函数名等标识符,let/const等关键字),类似阅读自然语言时拆分成词语。而ASI发生在将token构建成AST的语法分析的过程中,根据一套固定规则判断是否需要在特定位置补充分号,这套规则就是ASI规则。

先来看下这套规则的发展历史(AI梳理的,没挨个查证):

  1. ES1(1997 年):作为 ECMAScript 的首个规范版本,首次引入了自动分号插入的概念,试图定义基本规则来处理缺少分号的语法场景,但规则较为简略,主要针对解析器遇到语法错误时的 "纠错" 行为。

  2. ES3(1999 年):对 ASI 规则进行了重要补充,明确了分号可能被自动插入的三种基本场景:

    • 语句末尾的换行处

    • 程序结束位置

    • 闭合括号 } 之前

      同时首次定义了 "禁止自动插入分号" 的情况(如 for 循环中的表达式分隔)。

  3. ES5(2009 年):进一步细化了 ASI 的判断逻辑,补充了对正则表达式、模板字符串等场景的处理规则,明确了 "no LineTerminator here" 约束(某些位置不允许换行,否则会触发 ASI),使规则更加严谨。

  4. ES6/ES2015(2015 年):随着箭头函数、模块语法等新特性的引入,ASI 规则适配了新语法结构,例如明确了箭头函数=>前的换行处理、export语句后的分号插入逻辑等。

  5. 后续版本(ES2016 至今):在保持核心规则稳定的前提下,ASI 规范主要根据新增语法(如动态 import、可选链等)进行边缘场景的适配,未对基础机制做重大调整。

可以看出,ASI 的出发点不是替代分号,而是纠错;它的存在是为了兼容省略分号的写法。

再看下ECMAScript 规范 中关于 ASI 的三条规则:

There are three basic rules of semicolon insertion:

  1. When, as the source text is parsed from left to right, a token (called the offending token) is encountered that is not allowed by any production of the grammar, then a semicolon is automatically inserted before the offending token if one or more of the following conditions is true:
    • The offending token is separated from the previous token by at least one LineTerminator.
    • The offending token is }.
    • The previous token is ) and the inserted semicolon would then be parsed as the terminating semicolon of a do-while statement (14.7.2).
  2. When, as the source text is parsed from left to right, the end of the input stream of tokens is encountered and the parser is unable to parse the input token stream as a single instance of the goal nonterminal, then a semicolon is automatically inserted at the end of the input stream.
  3. When, as the source text is parsed from left to right, a token is encountered that is allowed by some production of the grammar, but the production is a restricted production and the token would be the first token for a terminal or nonterminal immediately following the annotation “[no LineTerminator here]” within the restricted production (and therefore such a token is called a restricted token), and the restricted token is separated from the previous token by at least one LineTerminator, then a semicolon is automatically inserted before the restricted token.

感觉非常拗口...尝试理解一下:

规则1是试错行为:遇到语法错误且满足相关条件后会插入分号。这里的核心概念是offending token,可以理解为“语法不允许出现的token”。遇到offending token且满足前面是换行符等条件时,则触发ASI,将自动在该token前加上分号。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// eg1.
let a = 1
let b = 2 
// 解析到 let a = 1 后,行尾没有分号,解析器会继续看下行开头的 let,尝试判断 “let a = 1 let b = 2” 是否为合法语法
// 由于 JavaScript 中 “赋值表达式(let a = 1)” 后不能直接跟 “变量声明语句(let b = 2)”,整个连续语句语法无效
// 此时 ASI 被触发,在 1 后插入分号,将代码拆分为 let a = 1; let b = 2(合法语法)。

// eg2.到这里就能看出我之前的代码为什么产生错误了
  for (let row = 0; row < n; row++) {
        for (let col = 0; col < n / 2; col++) {
            const symmetryCol = n - col - 1
            [matrix[row][col], matrix[row][symmetryCol]] =[matrix[row][symmetryCol], matrix[row][col]] 
        }
    }
// 解析器会尝试将两行连起来解析:const symmetryCol = n - col - 1[matrix[row][col], matrix[row][symmetryCol]] = ...
// 这里的 1[xxx] 语法本身是合法的,虽然 1[matrix[...]] 最终会导致运行时错误,但语法解析阶段会认为 “1[xxx] 是符合语法规则的结构”
// 最终代码会被解析为赋值表达式链,导致 symmetryCol 被赋值为 (n - col - (1[...])),同时触发解构赋值,代码报错

规则2是处理EOF的,解析到文件末尾仍不完整时,补分号收尾。

规则3是强制行为:遇到restricted token但前面有换行符时,将在该token前强制插入分号。restricted token 是 “语法正确但不允许换行的 token” ,规范中称为 No LineTerminator Here,简称 NLTH。如果实际代码中该 token 与前一个 token 之间有换行,则触发 ASI。

restricted token 示例:

  • return/break/continue 后的任何 token;
  • 后置 ++/-- 前的 token;
  • 箭头函数 =>
1
2
3
4
5
6
7
// eg1.一个很常见的情景:return后的语句全部作废
return
1  // "1" 是 restricted token,将在return后插入分号

// eg2.
let fn = (x)
=> x + 1  // "=>" 是 restricted token,触发 ASI:在 (x) 后插入分号,解析为 let fn = (x); => x + 1,导致语法错误

总结一下大概的判断逻辑:以 “补充分号” 为手段,尽可能让省略分号的代码通过语法解析,但在违反 “禁止换行” 的场景下,会强制插入分号(即使这可能导致最终报错)。

V8里ASI的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// src\parsing\parser-base.h
void ExpectSemicolon() {
    // 这是一个“预读”函数,它查看下一个将要被解析的token是什么,但不消耗它(不移动扫描指针)
    Token::Value tok = peek();
    // 情况1: 下一个token是分号,则调用 Next() 消耗掉它
    if (V8_LIKELY(tok == Token::kSemicolon)) {
      Next();
      return;
    }
    // 情况2: 触发ASI机制【核心】
    // 通过“换行判断 + 自动分号token判断”的组合逻辑,实现上述的三条规则
    if (V8_LIKELY(scanner()->HasLineTerminatorBeforeNext() ||
                  Token::IsAutoSemicolon(tok))) {
      return;
    }
    // 情况3: 特殊错误处理 (await)
    if (scanner()->current_token() == Token::kAwait && !is_async_function()) {
      if (flags().parsing_while_debugging() == ParsingWhileDebugging::kYes) {
        ReportMessageAt(scanner()->location(),
                        MessageTemplate::kAwaitNotInDebugEvaluate);
      } else {
        ReportMessageAt(scanner()->location(),
                        MessageTemplate::kAwaitNotInAsyncContext);
      }
      return;
    }
    // 以上情况都不是,报告语法错误,同时消耗掉导致错误的token,防止解析器无限循环或卡死
    ReportUnexpectedToken(Next());
  }

先来看换行判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// src\parsing\scanner.h
// 这个函数只是一个简单的读取缓存的函数,读取下一个 token 的 after_line_terminator 字段
bool HasLineTerminatorBeforeNext() const { 
    return next().after_line_terminator;
}

// Next() -> Scan() -> ScanSingleToken() -> SkipWhiteSpace()
Token::Value Scanner::Next() {
  // 轮换 Token 缓冲区:current_ 切换到 next_,next_ 切换到 next_next_ 或新扫描的 Token
  TokenDesc* previous = current_;
  current_ = next_;

  if (V8_LIKELY(next_next().token == Token::kUninitialized)) {
    next_ = previous;
    // 每次切换 next_(下一个 Token)时,会先重置其 after_line_terminator 为 false,
    // 再通过 Scan() 扫描 Token 并更新该字段。
    previous->after_line_terminator = false;  
    Scan(previous);  
  } else {
    // (其他缓冲区轮换逻辑,略)
  }
  return current().token;
}

// SkipWhiteSpace()是词法分析器跳过空格、换行等空白字符的函数
// 只要在跳过的字符中存在至少一个换行符,就会将 next().after_line_terminator 设为 true(即使有多个换行,也只会标记一次为 true)
V8_INLINE Token::Value Scanner::SkipWhiteSpace() {
  if (!IsWhiteSpaceOrLineTerminator(c0_)) return Token::kIllegal;
  if (!next().after_line_terminator && unibrow::IsLineTerminator(c0_)) {
    next().after_line_terminator = true;  // 遇到换行符,标记为true
  }

  base::uc32 hint = ' ';
  AdvanceUntil([this, &hint](base::uc32 c0) {
    if (V8_LIKELY(c0 == hint)) return false;
    if (IsWhiteSpaceOrLineTerminator(c0)) {
      if (!next().after_line_terminator && unibrow::IsLineTerminator(c0)) {
        next().after_line_terminator = true;  // 连续换行时,保持标记为true
      }
      hint = c0;
      return false;
    }
    return true;
  });

  return Token::kWhitespace;
}

看到这里似乎只要有换行符就会把after_line_terminator设置为true、添加分号,但根据规则1我们知道、下一行token能否与当前内容构成合法语法时时,是不会加分号的,这个判断在哪里?

实际上,相关逻辑四散在 ExpectSemicolon() 的调用里。scan词法分析阶段是不需要这个判断的,因为在parser语法分析阶段,只有语句解析结束后、可能需要插入分号的情况(包括returnbreak 等 NLTH token后)时才调用ExpectSemicolon()。而“行末和下一行行首构成合法语法”这种情况下(比如我犯的那个错误),解析器会将两行合并为同一表达式,根本不会调用这个函数触发ASI。

再来看下 IsAutoSemicolon()

1
2
3
4
5
// src\parsing\token.h
// 判断当前 token 是否为 “可触发自动分号插入的 token”,如遇到文件结束
static bool IsAutoSemicolon(Value token) { 
    return base::IsInRange(token, kSemicolon, kEos);
}

这里的实现还是很有意思的,IsInRange() 是个工具函数,入参是变量、区间上限、区间下限,变量落在区间里时返回true。[kSemicolon, kEos] 是一个连续的整数区间,映射能够触发 ASI 的token值(如规则里提到的} 等);这些整数值和其对应关系是由一个自动化脚本根据文件里定义好的 TOKEN_LIST 来生成的。

换言之,这里用表驱动代替了复杂的多条件判断。

总结

那么JS该不该写分号呢?当行首和上一行行末连接后符合语法规则时,最好手动加分号,即以 ([/+- 开头的前一行最好都写上分号,一个孤单的分号、突兀的分号;

要么还是每一行都加上吧;

semi2

semi3