JavaScript ASI 机制详解

9

TL;DR

最近在清理 Pocket 的未读列表,看到了 An Open Letter to JavaScript Leaders Regarding Semicolons 才知道了 JavaScript 的 ASI,一种自动插入分号的机制。因为我是 “省略分号风格” 的支持者,之前也碰到过一次因为忽略分号产生的问题,所以对此比较重视,也特意多看了几份文档,但越看心里越模糊。并不是我记不住 ( 和 [ 前面记得加 ; 这种结论,而是觉得看过的几篇文章跟 ECMAScript 标准描述的有点区别。直到最近反复琢磨才突然有了 “原来如此” 的想法,于是就有了此文。

这篇文章会用 ECMAScript 标准的 ASI 定义来解释它到底是如何运作的,我会尽量用平易近人的方法描述它,避免官方文档的晦涩。希望你跟我一样有收获。掌握 ASI 并不能够让你马上解决手头的问题,但能让你成为一个更好的 JavaScript 程序员。

什么是 ASI

按照 ECMAScript 标准,一些 特定语句(statement) 必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解释器会自己判断语句该在哪里终止。这种行为被叫做 “自动插入分号”,简称 ASI (Automatic Semicolon Insertion) 。实际上分号并没有真的被插入,这只是个便于解释的形象说法。

这些特定的语句有:

  • 空语句

  • let

  • const

  • import

  • export

  • 变量赋值

  • 表达式

  • debugger

  • continue

  • break

  • return

  • throw

下面这段是我 个人的理解,上的定义同时也表示:

  1. 所有这些语句中的分号都是可以省略的。

  2. 除此之外其他的语句有两种情况,一是不需要分号的(比如 if 和函数定义),二是分号不能省略的(比如 for),稍后会详细介绍。

那么 ASI 如何知道在哪里插入分号呢?它会按照一些规则去判断。但在说规则之前,我们先了解一下 JS 是如何解析代码的。

Token

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

var a = 12;

上面这段代码可以分成四个 token :

  1. var 关键字

  2. a 标识符

  3. = 运算符

  4. 12 数字

除此之外,(. 等都算 token ,这里只是让你有个大概的概念,比如 12 整个是一个 token ,而不是 12。字符串同理。

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

接下来是重点:任意 token 之间都可以插入一个或多个换行符 (Line Terminator) ,这完全不影响 JS 的解析,所以上面的代码可以写成下面这样(功能等价):

var
a
=
// = 和 12 之间有两个换行符
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

事实上这并不是省略分号的风格的错误,而是开发者没有理解 JS 解释器的工作原理。如果你倾向省略分号的风格,那了解 ASI 是必修课。

ASI 规则

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

三条规则是描述何时该自动插入分号:

  1. 解析器从左往右解析代码(读入 token),当碰到一个不能构成合法语句的 token 时,它会在以下几种情况中在该 token 之前插入分号,此时这个不合群的 token 被称为 offending token :

    • 如果这个 token 跟上一个 token 之间有至少一个换行。

    • 如果这个 token 是 }

    • 如果 前一个 token 是 ),它会试图把前面的 token 理解成 do...while 语句并插入分号。

  2. 当解析到文件末尾发现语法还是有问题,就会在文件末尾插入分号。

  3. 当解析时碰到 restricted production 的语法(比如 return),并且在 restricted production 规定的 [no LineTerminator here] 的地方发现换行,那么换行的地方就会被插入分号。

两条例外表示,就算符合上述规则,如果分号会被解析成下面的样子,它也不能被自动插入:

  1. 分号不能被解析成空语句。

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

你会发现这些规则相当晦涩,好像存心考你智商的,还有些坑爹的专有名词。不要紧,我们来看几个非常简单的例子,看完之后你就会明白所有这些东西的含义。

例子解析

第一个例子:换行

a
b

我们模拟一下解析器的思考过程,大概是这样的:解析器一个个读取 token ,但读到第二个 token b 时它就发现没法构成合法的语句,然后它发现 b 和前面是有换行的,于是按照规则一(情况一),它在 b 之前插入分号变成 a\n;b,这样语句就合法了。然后继续处理,这时读到文件末了,b 还是不能构成合法的语句,这时候按照规则二,它在末尾插入分号,结束。最终结果是:

a
;b;

第二个例子:大括号

{ a } b

解析器仍然一个个读取 token ,读到 token } 时发现 { a } 是不合法的,因为 a 是表达式,它必须以分号结尾。但当前 token 是 },所以按照规则一(情况二),它在 } 前面插入分号变成 { a ;},这句就通过了,然后继续处理,按照规则二给 b 加上分号,结束。最终结果是:

{ a ;} b;

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

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

第三个例子:do while

这个是为了解释规则一(情况三),这是最绕的部分,代码如下:

do a; while(b) c

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

do a; while (b) ; c;

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

第四个例子:return

return
a

你一定知道 return 和返回值之间不能换行,因为上面代码会解析成:

return;
a;

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

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

return [no LineTerminator here] Expression ;

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

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

  • 后缀的 ++--

  • return

  • continue

  • break

  • throw

  • ES6 箭头函数(参数和箭头之间不能换行)

  • yield

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

第五个例子:后缀表达式

a
++
b

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

a
;++
b;

第六个例子:空语句

if (a)
else b

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

else b
^^^^

SyntaxError: Unexpected token else

第七个例子:for

for (a; b
)

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

)
^

SyntaxError: Unexpected token )

如何手动测试 ASI

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

拿这段代码举例子:

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;

你会发现生成的 Syntax 是一致的。这说明解释器对这两段代码解析过程是一致的,我们并没有加入任何多余的分号。

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

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

Esprima 结果:

{
    "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"
}

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

结尾

如果看到这里,相信你对 ASI 和 JS 的解析机制已经有所了解。也许你会想 “那我再也不省略分号了”,那我建议你看看参考资料里的链接。而且就我的经验,即使是分号的坚持者,少数地方也会无意识地使用 ASI 。比如有时候忘了写分号,或者写迭代器中的单行函数时。下次我会说下对省略分号的风格的看法,和如何用 ESLint 保证代码风格的一致性。

参考资料

ECMAScript: ASI
ECMAScript 标准定义。本文的概念和很多例子完全遵照它来写的。但也强烈建议你自己看看。

JavaScript Semicolon Insertion Everything you need to know
关于 ASI 的解释,略微学术化,讲得很详细,也很客观。

An Open Letter to JavaScript Leaders Regarding Semicolons
NPM 作者对 ASI 和两种风格的看法,这篇更注重个人观点的表达。他是省略分号风格的倾向者。

Esprima: Parser
一个在线 JS 解析器。你可以输入一些语句来看看 token 都是什么。也可以通过 Tree 的变化来测试加不加分号的影响。

载入中...