前言
关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。
对于此问题,看看几位大佬是怎么说的:
-
尤雨溪的知乎回答
挺欣赏大佬的一句话:所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。想知道为什么,继续往下看。
一、ASI 是什么?
按照 ECMAScript 标准,一些特定语句(statement)必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解析器会自己判断语句该在哪里终止。这种行为被叫做“自动插入分号”,简称 ASI(Automatic Semicolon Insertion)。实际上分号并没有真的被插入,只是便于解释的形象说法。
这些特定的语句有:
空语句letconstimportexport变量赋值表达式dubuggercontinuebreakreturnthrow
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 构成语句。
敲重点!!!
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 包括三条规定和两条例外。
三条规则
两条例外
到这里,好像规则挺晦涩的。先别慌,看完下面的例子就能明白其中的含义了。
四、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;
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 是一组语法的统称,它一共包含下面几个语法:
这些无需死记,因为按照一般的书写习惯,几乎没有人会这样换行的。顺带一提,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 机制有一个大致的了解了。
// 坚持添加分号的小明
return
123;
// 坚持不添加分号的小红
return
123
// 以上小明、小红都不能返回预期的结果 123,而是 undefined。
// 因为 JavaScript 解析器解析它们,都会变成这样:
return;
123;
// 如果我们懂得了 JavaScript 解析规则,那么无论我们写分号或者不写分号,都能得到预期结果。
return 123
return 123;
正如文章开头所说,我更偏向于不加分号的。这时候,我们要注意一些特殊情况,以避免 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;
所以理解了以上规则之后,我可以愉快了使用 ESLint + Prettier 一键去掉分号以及统一格式化,而不会有任何的负担了。










