JavaScript JavaScript ASI 机制详解

darkbaby123 · 2016年03月06日 · 最后由 darkbaby123 回复于 2016年05月05日 · 3833 次阅读

TL;DR

原文放在 SegmentFault

最近在清理 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 的变化来测试加不加分号的影响。

最后的结论十分赞同,我经常在两种风格里换来换去,要么就是能不写分号的完全不写(这是绝对可行的),要么就是能写的全都写(但是可写可不写的都不写)。总的来说还是前者更符合我的口味,但是往往团队协作的时候都是后者为主,关键是 ESLint 药不能停……等你分享这个。

@nightire ESLint 只有非常少数的配置是跟分号风格相关的,semi, no-unexpected-multiline, no-unreachable 这三项配置就可以避免很多错误了。

其实最开始我打算把风格问题放进来写,不过写着写着发现 ASI 的部分太多了,而且风格问题属于个人偏好。所以才打算分开写,这篇就是不带个人意见的 ECMAScript 标准分析。

我们是直接粗暴地照搬 airbnb 的 eslint 库,除了那个 object 最后一个 key 必须加逗号的规则,其余的都被大家勉为其难地接受了 😄

@billy 其实我也是,只是我改的是 semi 规则 😄

#3 楼 @billy 其实最后加逗号是很好的规则,习惯了之后就知道它的好处了,删除、添加、插入、上下移动都非常省心。这是很多开发者的强烈需求,ES2015 特意为此做了语法上的允许。

@nightire 你说得很对,没有想到这个,主要是以前都这么写 Javascript 和 Ruby 都习惯了 😄

@nightire 赞同。我现在比较喜欢多行 var 时把逗号放前面,但 object 和 array 把逗号放后面。都是为了你说的原因。不过这种风格不太一致,ESLint 中貌似只能允许 comma first 或者 last。你有类似经验么?

#8 楼 @darkbaby123 呃,我已经很久没有写过多行 var 这样的代码了,感觉是 jQuery 时代才会这么写。实际上最近半年以来连 let 都很少用基本上需要声明一个命名都是 const,极少需要连续声明多个,如果确有必要都会试着利用函数的方式处理或者换换数据结构之类的。所以你说的逗号既要头又要尾的风格我还真没试过,刚好今天要搭个 universal redux 的架子,等会儿我试试。

#8 楼 @darkbaby123 嗯,知道了,就这样:

comma-dangle: [2, "always-multiline"] comma-style: [1, "last", {exceptions: {VariableDeclaration: true} }]

以上是我正在使用的规则,管用。

平时不用分号,只有在特定情况下,基本上是[,(,+,-,/开头的地方(这是在犀牛书里面看到的,实际工作中也就是那么几种情况下). 比如:

const FOO = "FOO"

;[1].forEach(item => console.log(item))

;(function() {
   return console.log("bar")
}())

for (let i = 0; i < 2; i+=1) console.log(i)

@1272729223 对,而且正常情况下也就 ([ 两个。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号