前言
关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。
对于此问题,看看几位大佬是怎么说的:
-
尤雨溪的知乎回答
挺欣赏大佬的一句话:所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。想知道为什么,继续往下看。
我的立场是偏向 semicolon-less 风格,可能是强迫症使然,还有加了也避免不了特殊情况。
一、ASI 是什么?
按照 ECMAScript 标准,一些特定语句(statement)必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解析器会自己判断语句该在哪里终止。这种行为被叫做“自动插入分号”,简称 ASI(Automatic Semicolon Insertion)。实际上分号并没有真的被插入,只是便于解释的形象说法。
这些特定的语句有:
空语句
let
const
import
export
变量赋值
表达式
dubugger
continue
break
return
throw
ASI 会按照一定的规则去判断,应该在哪里插入分号。但在此之前,先了解一下 JavaScript 是如何解析代码的。
二、Token
解析器在解析代码时,会把代码分成很多 token
,一个 token
相当于一小段有特定意义的语法片段。
看例子:
var a = 12;
通过 Esprima Parser (经典的 JavaScript 抽象语法树解析器)可以看到被拆分为多个 token
。
[
{
"type": "Keyword",
"value": "var"
},
{
"type": "Identifier",
"value": "a"
},
{
"type": "Punctuator",
"value": "="
},
{
"type": "Numeric",
"value": "12"
},
{
"type": "Punctuator",
"value": ";"
}
]
以上变量声明语句可以分成五个 token
:
-
var
关键字 -
a
标识符 -
=
标点符号 -
12
数字 -
;
标点符号
解析器在解析语句时,会一个一个地读入 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 包括三条规定和两条例外。
三条规则
- 解析器从左往右解析代码(读入
token
),当碰到一个不能构成合法语句的token
时,他会在以下几种情况中,在该token
之前插入分号,此时不合群的token
被称为违规 token
(offending token
)
- 1.1 如果这个
token
跟上一个token
之间有至少一个换行。- 1.2 如果这个
token
是}
。- 1.3 如果前一个
token
是)
它会试图把签名的token
理解成do-while
语句并插入分号。- 当解析到文件末尾发现语法还是无法构成合法的语句,就会在文件末尾插入分号。
- 当解析碰到
restricted production
的语法(比如return
),并且在restricted production
规定的[no LineTerminator here]
的地方发现换行,那么换行的地方就会被插入分号。
两条例外
- 分号不能被解析成空语句。
- 分号不能被解析成
for
语句的两个分号之一。
到这里,好像规则挺晦涩的。先别慌,看完下面的例子就能明白其中的含义了。
四、ASI 规则举例说明
就以上规则,举例说明助于理解。
1. 例一:换行
a
b
解析器一个一个读取 token
,但读到第二个 token b
时,它就发现没法构成合法的语句,然后它发现 token b
和上一个 token a
是有换行的,于是按照 规则 1.1
的处理,在 token b
之前插入分号变成 a\n;b
,这样语法就合法了。接着继续读取 token
,这时读到文件末尾,token b
还是不能构成合法的语句,这是按照 规则 2
处理,在末尾插入分号终止。故得到:
a
;b;
2. 例二:大括号
{ a } b
解析器仍然一个一个读取 token
,读到 token }
时,它发现 { a }
是不合法的,因为 a
是表达式,它必须以分号结尾。但是当前 token
是 }
,所以按照 规则 1.2
处理,他在 }
前面插入分号变成 { a; }
,接着往后读取 token b
,按照 规则 2
给 b
加上分号。故得到:
{ a; } b;
Otherwise,也许有人会认为是
{ a; };
,但不是这样的。因为{...}
属于块语句,而按照定义块语句是不需要分号结尾的,不管是不是在一行。因为块语句也被用在其他地方(比如函数声明),所以下面代码是完全合法的,不需要任何的分号。
function a() {} function b() {}
3. 例三: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
量身定做的。
4. 例四: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
是一组语法的统称,它一共包含下面几个语法:
- 后缀表达式
++
和--
return
continue
break
throw
- ES6 的箭头函数(参数和箭头之间不能换行)
yield
这些无需死记,因为按照一般的书写习惯,几乎没有人会这样换行的。顺带一提,continue
和 break
后面是可以接 label 的。但这不在本文讨论范围内,有兴趣自行探索。
5. 例五:后缀表达式
a
++
b
解析器读到 token ++
时发现语句不合法(注意:++
不是两个 token
,而是一个)。因为后缀表达式是不允许换行的。换句话说,换行的都不是后缀表达式,所以它只能按照 规则 1.1
在 token ++
前面插入分号来结束语句 a
,然后继续执行,因为前缀表达式并不是 restricted production
,所以 ++
和 b
可以组成一条语句,然后按照 规则 2
在末尾加上分号。故得到:
a
;++
b;
6. 例六:空语句
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
7. 例七: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) c
和 do 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 一键去掉分号以及统一格式化,而不会有任何的负担了。