toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
22 stars 1 forks source link

JavaScript ASI 机制详解,不用再纠结分号问题 #223

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

前言

关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。

对于此问题,看看几位大佬是怎么说的:

所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。

个人立场是偏向 semicolon-less 风格,可能是强迫症使然,还有加了也避免不了特殊情况。

ASI 是什么?

按照 ECMAScript 标准,一些特定语句(statement)必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解析器会自己判断语句该在哪里终止。这种行为被叫做「自动插入分号」,简称 ASIAutomatic Semicolon Insertion)。

实际上分号并没有真的被插入,只是便于解释的形象说法。

这些特定的语句有:

ASI 会按照一定的规则去判断,应该在哪里插入分号。但在此之前,先了解一下 JavaScript 是如何解析代码的。

Token

解析器在解析代码时,会把代码分成很多 token,一个 token 相当于一小段有特定意义的语法片段。

看例子:

var a = 12;

通过 Esprima Parser 可以看到被拆分为多个 token

[
  {
    "type": "Keyword",
    "value": "var"
  },
  {
    "type": "Identifier",
    "value": "a"
  },
  {
    "type": "Punctuator",
    "value": "="
  },
  {
    "type": "Numeric",
    "value": "12"
  },
  {
    "type": "Punctuator",
    "value": ";"
  }
]

以上变量声明语句可以分成五个 token

解析器在解析语句时,会一个一个地读入 token 尝试构成给一个完整的语句(statement),直到碰到特定情况(例如语法规定的终止)才会认为这个语句结束了。这个例子中的终止符就是分号。用 token 构成语句的过程类似于正则里的贪婪匹配,解释器总是试图用尽可能多的 token 构成语句。

敲重点!!!

任意 token 之间都可以插入一个或多个换行符(Line Terminator),这完全不会影响 JavaScript 的解析。

var
a
=

// 假设这里有 N 个换行符

12
;

上面的代码,跟之前单行 var a = 12; 是等价的。这个特性可以让开发者通过增加代码的可读性,更灵活地组织语言风格。平时写的跨多行数组、字符串拼接、链式调用等都属于这一类。

当然了,在省略分号的风格中,这种特性会导致一些意外的情况。举个例子:

var a
  , b = 12
  , hi = 2
  , g = {exec: function() { return 3 }}

a = b
/hi/g.exec('hi')

console.log(a)

// 打印出 2, 因为代码会被解析成:
// a = b / hi / g.exec('hi');
// a = 12 / 2 / 3

以上例子,以 / 开头的正则表达式被解析器理解成除法运算,所以打印结果是 2

其实,这不是省略分号风格的错误,而是开发者没有理解 JavaScript 解析器的工作原理。如果你偏向于省略分号的,那更要理解 ASI 的原理了。

ASI 规则

ECMAScript 标准定义的 ASI 包括三条规定两条例外

三条规则

  1. 解析器从左往右解析代码(读入 token),当碰到一个不能构成合法语句的 token 时,他会在以下几种情况中,在该 token 之前插入分号,此时不合群的 token 被称为违规 tokenoffending token
    • 1.1 如果这个 token 跟上一个 token 之间有至少一个换行。
    • 1.2 如果这个 token}
    • 1.3 如果前一个 token) 它会试图把签名的 token 理解成 do-while 语句并插入分号。
  2. 当解析到文件末尾发现语法还是无法构成合法的语句,就会在文件末尾插入分号。
  3. 当解析碰到 restricted production 的语法(比如 return),并且在 restricted production规定的 [no LineTerminator here] 的地方发现换行,那么换行的地方就会被插入分号。

两个例外

  1. 分号不能被解析成空语句。
  2. 分号不能被解析成 for 语句的两个分号之一。

到这里,好像规则挺晦涩的。先别慌,看完下面的例子就能明白其中的含义了。

举例说明

就以上规则,举例说明助于理解。

例一:换行

a
b

解析器一个一个读取 token,但读到第二个 token b 时,它就发现没法构成合法的语句,然后它发现 token b 和上一个 token a 是有换行的,于是按照 规则 1.1 的处理,在 token b 之前插入分号变成 a\n;b,这样语法就合法了。接着继续读取 token,这时读到文件末尾,token b 还是不能构成合法的语句,这是按照 规则 2 处理,在末尾插入分号终止。故得到:

a
;b;

例二:大括号

{ a } b

解析器仍然一个一个读取 token,读到 token } 时,它发现 { a } 是不合法的,因为 a 是表达式,它必须以分号结尾。但是当前 token},所以按照 规则 1.2 处理,他在 } 前面插入分号变成 { a; },接着往后读取 token b,按照 规则 2b 加上分号。故得到:

{ a; } b;

另外,也许有人会认为是 { a; }; ,但不是这样的。因为 {...} 属于块语句,而按照定义块语句是不需要分号结尾的,不管是不是在一行。因为块语句也被用在其他地方(比如函数声明),所以下面代码是完全合法的,不需要任何的分号。

function a() {} function b() {}

例三:do-while 语句

这个是为了解释 规则 1.3,这是最绕的地方。

do a; while(b) c

这例子种解析到 token c 的时候就不对了。这里既没有 换行,也没有 },但 token c 前面是 token ) ,所以解析器把之前的 token 组成一个语句,并判断是否为 do-while 语句,结果正好是的,于是自动插入分号变成 do a; whiile(b);,这种给 token c 加上分号。故得到:

do a; while(b); c;

简单来说,do-while 后面的分号是自动插入的。但是如果其他以 ) 结尾的情况就不行了。规则 1.3 就是为 do-while 量身定做的。

例四:return

return
a

我们都知道 return返回值 之间不能换行,因为上面代码会解析成:

return;
a;

但为什么不能换行呢?是因为 return 语句就是一个 restricted production 语法。restricted production 是一组有严格限定的语法的统称,这些语法都是在某个地方不能换行的,不能换行的地方会被标注 [no LineTermiator here]

比如 ECMAScript 的 return 语法定义如下:

return [no LineTerminator here] Expression;

这表示 return 跟表达式之间是不允许换行的(但后面的表达式内部可以换行)。如果这个地方恰好有换行,ASI 就会自动插入分号,这就是 规则 3 的含义。

刚才我们说了 restricted production 是一组语法的统称,它一共包含下面几个语法:

这些无需死记,因为按照一般的书写习惯,几乎没有人会这样换行的。顺带一提,continuebreak 后面是可以接 label 的。但这不在本文讨论范围内,有兴趣自行探索。

例五:后缀表达式

a
++
b

解析器读到 token ++ 时发现语句不合法(注意:++ 不是两个 token,而是一个)。因为后缀表达式是不允许换行的。换句话说,换行的都不是后缀表达式,所以它只能按照 规则 1.1token ++ 前面插入分号来结束语句 a,然后继续执行,因为前缀表达式并不是 restricted production,所以 ++b 可以组成一条语句,然后按照 规则 2 在末尾加上分号。故得到:

a
;++
b;

例六:空语句

if (a)
else b

解析器解析到 token else 时发现不合法( else 是不能跟在 if 语句头后面的),本来按照 规则 1.1,它应该加上分号变成 if (a)\n;,但是这样 ; 就变成空语句了,所以按照 例外 1,这个分号不能加,程序在 else 处抛异常结束。Node.js 的运行结果:

else b
^^^^

SyntaxError: Unexpected token else

而以下这样语法是正确的。

if (a);
else b

例七:for 语句

for(a; b
)

解析器读到 token ) 时发现语法不合法,本来换行可以自动插入分号,但是按照 例外 2,不能为 for 头部自动插入分号,于是程序在 ) 处抛异常结束。Node.js 运行结果如下:

)
^

SyntaxError: Unexpected token )

如何手动测试 ASI

我们很难有办法去测试 ASI 是不是如预期那样工作的,只能看到代码最终执行结果是对是错。ASI 也没有手动打开或者关掉去对比结果。但我们可以通过对比解析器生成的 tree 是否一致来判断 ASI 插入的分号是不是跟我们预期的一致。这点可以用 Esprima Parser 验证。

举个例子:

do a; while(b) c

Esprima 解析的 Syntax 如下所示(不需要看懂,记住大概样子就好):

{
  "type": "Program",
  "body": [
    {
      "type": "DoWhileStatement",
      "body": {
        "type": "ExpressionStatement",
        "expression": {
          "type": "Identifier",
          "name": "a"
        }
      },
      "test": {
        "type": "Identifier",
        "name": "b"
      }
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "Identifier",
        "name": "c"
      }
    }
  ],
  "sourceType": "script"
}

然后我们主动加上分号,输入进去:

do a; while(b); c;

你就会发现 do a; while(b) cdo a; while(b); c; 生成的 Syntax 是一致的。这说明解析器对这两段代码解析过程是一致的,我们并没有加入任何多余的分号。

然后试试这个多余分号的版本:

do a; while(b); c;; // 结尾多一个分号

它的结果是:

{
  "type": "Program",
  "body": [
    {
      "type": "DoWhileStatement",
      "body": {
        "type": "ExpressionStatement",
        "expression": {
          "type": "Identifier",
          "name": "a"
        }
      },
      "test": {
        "type": "Identifier",
        "name": "b"
      }
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "Identifier",
        "name": "c"
      }
    },
    {
      "type": "EmptyStatement"
    }
  ],
  "sourceType": "script"
}

你会发现多出来一条空语句(EmptyStatement),那么这个分号就是多余的。

最后

看到这里,相信你对 JavaScript 解析机制和 ASI 机制有一个大致的了解了。

所以即使坚持使用分号,仍然会因为 ASI 机制导致产生与预期不一致的结果。

比如下面例子,这不是加不加分号的问题,而是懂不懂 JavaScript 解析的问题。

// 坚持添加分号的小明
return 
123;

// 坚持不添加分号的小红
return
123

// 以上小明、小红都不能返回预期的结果 123,而是 undefined。
// 因为 JavaScript 解析器解析它们,都会变成这样:
return;
123;

// 如果我们懂得了 JavaScript 解析规则,那么无论我们写分号或者不写分号,都能得到预期结果。
return 123
return 123;

正如文章开头所说,我更偏向于不加分号的。这时候,我们要注意一些特殊情况,以避免 ASI 机制产生与我们编写程序的预期结果不一致的问题。

敲重点!!!

如果一条语句是以 ([/+- 开头,那么就要注意了。根据 JavaScript 解析器的规则,尽可能读取更多 token 来构成一个完整的语句,而以上这些 token 极有可能与前一个 token 可组成一个合法的语句,所以它不会自动插入分号。

实际项目中,以 /+- 作为行首的代码其实是很少的,([ 也是较少的。当遇到这些情况时,通过在行首手动键入分号 ; 来避免 ASI 规则产生的非预期结果或报错。

( 开头的情况

a = b
(function() {

})

JavaScript 解析器会解析成这样:

a = b(function() {

});

[ 开头的情况

a = function() {

}
[1,2,3].forEach(function(item) {

})

JavaScript 解析器会按以下这样去解析,由于 function() {}[1,2,3] 返回值是 undefined,所以就会报错。

a = function() {
}[1,2,3].forEach(function(item) {

});

/ 开头的情况

a = 'abc'
/[a-z]/.test(a)

JavaScript 解析器会按以下这样去解析,所以就会报错。

a = ‘abc’/[a-z]/.test(a);

+ 开头的情况

a = b
+c

JavaScript 解析器会解析成这样:

a = b + c;

- 开头的情况

a = b
-c

JavaScript 解析器会解析成这样:

a = b - c;

关于后缀表达式 ++-- 跟上面的有点区别,上面已经举例说明了,它属于 restricted production 的情况之一,会在换行处自动插入分号,所以它们不能换行写,否则可能会产生非预期结果。

所以理解了以上规则之后,我可以愉快了使用 ESLint + Prettier 一键去掉分号以及统一格式化,而不会有任何的负担了。

References