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
下面这段是我 个人的理解,上的定义同时也表示:
所有这些语句中的分号都是可以省略的。
除此之外其他的语句有两种情况,一是不需要分号的(比如
if
和函数定义),二是分号不能省略的(比如for
),稍后会详细介绍。
那么 ASI 如何知道在哪里插入分号呢?它会按照一些规则去判断。但在说规则之前,我们先了解一下 JS 是如何解析代码的。
Token
解析器在解析代码时,会把代码分成很多 token 。一个 token 相当于一小段有特定意义的语法片段。看一个例子你就会明白:
var a = 12;
上面这段代码可以分成四个 token :
var
关键字a
标识符=
运算符12
数字
除此之外,(
,.
等都算 token ,这里只是让你有个大概的概念,比如 12
整个是一个 token ,而不是 1
和 2
。字符串同理。
解释器在解析语句时会一个一个读入 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 包括 三条规则 和 两条例外。
三条规则是描述何时该自动插入分号:
-
解析器从左往右解析代码(读入 token),当碰到一个不能构成合法语句的 token 时,它会在以下几种情况中在该 token 之前插入分号,此时这个不合群的 token 被称为 offending token :
如果这个 token 跟上一个 token 之间有至少一个换行。
如果这个 token 是
}
。如果 前一个 token 是
)
,它会试图把前面的 token 理解成do...while
语句并插入分号。
当解析到文件末尾发现语法还是有问题,就会在文件末尾插入分号。
当解析时碰到 restricted production 的语法(比如
return
),并且在 restricted production 规定的[no LineTerminator here]
的地方发现换行,那么换行的地方就会被插入分号。
两条例外表示,就算符合上述规则,如果分号会被解析成下面的样子,它也不能被自动插入:
分号不能被解析成空语句。
分号不能被解析成
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
这些不用死记,因为按照常规书写习惯,几乎没人会这样换行的。顺带一提,continue
和 break
后面是可以接 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 的变化来测试加不加分号的影响。