JavaScript ASI 机制详解,不用再纠结分号问题

简介: 关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。

前言


关于要不要加分号的问题,其实有很多争论!有的坚持加分号,而有的不喜欢加分号...但是无论那种风格,都不能百分百避免某些特殊情况产生的问题,究其根本就是因为对 JavaScript 解析和 ASI 规则的不了解。


对于此问题,看看几位大佬是怎么说的:


  • 尤雨溪的知乎回答
    挺欣赏大佬的一句话:所有直觉性的 “当然应该加分号” 都是保守的、未经深入思考的草率结论。想知道为什么,继续往下看。


我的立场是偏向 semicolon-less 风格,可能是强迫症使然,还有加了也避免不了特殊情况。


正文


一、ASI 是什么?


按照 ECMAScript 标准,一些特定语句(statement)必须以分号结尾。分号代表这段语句的终止。但是有时候为了方便,这些分号是有可以省略的。这种情况下解析器会自己判断语句该在哪里终止。这种行为被叫做“自动插入分号”,简称 ASIAutomatic 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 包括三条规定两条例外


三条规则


  1. 解析器从左往右解析代码(读入 token),当碰到一个不能构成合法语句的 token 时,他会在以下几种情况中,在该 token 之前插入分号,此时不合群的 token 被称为违规 tokenoffending token
  • 1.1 如果这个 token 跟上一个 token 之间有至少一个换行。
  • 1.2 如果这个 token}
  • 1.3 如果前一个 token) 它会试图把签名的 token 理解成 do-while 语句并插入分号。
  1. 当解析到文件末尾发现语法还是无法构成合法的语句,就会在文件末尾插入分号。
  2. 当解析碰到 restricted production 的语法(比如 return),并且在 restricted production规定的 [no LineTerminator here] 的地方发现换行,那么换行的地方就会被插入分号。


两条例外


  1. 分号不能被解析成空语句。
  2. 分号不能被解析成 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,按照 规则 2b 加上分号。故得到:

{ 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


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


5. 例五:后缀表达式

a
++
b


解析器读到 token ++ 时发现语句不合法(注意:++ 不是两个 token,而是一个)。因为后缀表达式是不允许换行的。换句话说,换行的都不是后缀表达式,所以它只能按照 规则 1.1token ++ 前面插入分号来结束语句 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) cdo 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 规则产生的非预期结果或报错。


  1. ( 开头的情况:

a = b
(function() {
})


JavaScript 解析器会解析成这样:

a = b(function() {
});


  1. [ 开头的情况:

a = function() {
}
[1,2,3].forEach(function(item) {
})


JavaScript 解析器会按以下这样去解析,由于 function() {}[1,2,3] 返回值是 undefined,所以就会报错。

a = function() {
}[1,2,3].forEach(function(item) {
});


  1. / 开头的情况:

a = 'abc'
/[a-z]/.test(a)


JavaScript 解析器会按以下这样去解析,所以就会报错。

a = ‘abc’/[a-z]/.test(a);


  1. + 开头的情况:

a = b
+c


JavaScript 解析器会解析成这样:

a = b + c;


  1. - 开头的情况:

a = b
-c


JavaScript 解析器会解析成这样:

a = b - c;


关于后缀表达式 ++--  跟上面的有点区别,上面已经举例说明了,它属于 restricted production 的情况之一,会在换行处自动插入分号,所以它们不能换行写,否则可能会产生非预期结果。


所以理解了以上规则之后,我可以愉快了使用 ESLint + Prettier 一键去掉分号以及统一格式化,而不会有任何的负担了。


参考


目录
相关文章
|
2月前
|
设计模式 JavaScript 前端开发
深入理解 JavaScript 中的绑定机制(下)
深入理解 JavaScript 中的绑定机制(下)
|
2月前
|
JavaScript 前端开发
深入理解 JavaScript 中的绑定机制(上)
深入理解 JavaScript 中的绑定机制(上)
|
4月前
|
JavaScript 前端开发 数据库连接
js的异常程序处理机制
js的异常程序处理机制
18 0
|
4月前
|
存储 前端开发 JavaScript
深入理解前端JavaScript执行机制
深入理解前端JavaScript执行机制
43 0
|
1天前
|
JavaScript 前端开发 开发者
【JavaScript技术专栏】JavaScript事件处理机制详解
【4月更文挑战第30天】本文探讨JavaScript中的事件处理机制,涉及事件类型(如click、mouseover)、事件流(冒泡型、捕获型及目标阶段)、事件处理函数(内联与addEventListener方法)以及事件委托(用于优化内存和处理动态元素)。此外,还介绍了事件取消,通过`preventDefault()`和`stopPropagation()`控制事件行为。理解这些概念对构建交互式Web应用至关重要。
|
1天前
|
JavaScript 前端开发
Node.js中的错误处理机制
【4月更文挑战第30天】本文介绍了Node.js的错误处理机制,包括Error对象、try-catch、错误事件监听及Promise和async/await的错误处理。错误通常封装在Error对象中,可自定义错误类型。try-catch用于捕获异常,但不适用于异步错误。事件监听器处理对象发出的'error'事件,防止应用崩溃。Promise的.catch()和async/await结合try-catch用于处理异步错误。良好的错误处理是保证应用健壮性和可靠性的关键。
|
2天前
|
消息中间件 存储 前端开发
理解JavaScript事件循环机制
理解JavaScript事件循环机制
|
7天前
|
前端开发 JavaScript
在JavaScript中,回调函数、Promise和async/await这三种异步处理机制的优缺点
JavaScript的异步处理包括回调函数、Promise和async/await。回调函数简单易懂,但可能导致回调地狱和错误处理困难。Promise通过链式调用改善了这一情况,但仍有回调函数需求和学习成本。async/await提供同步风格代码,增强可读性和错误处理,但需ES8支持,不适用于并发执行。根据项目需求选择合适机制。
|
19天前
|
JavaScript 前端开发
深入探讨JavaScript中的原型链与继承机制
JavaScript作为一种灵活而强大的编程语言,其独特的原型链与继承机制是其核心特性之一。本文将深入探讨JavaScript中的原型链与继承机制,从基础概念到实际应用,帮助读者更好地理解和利用JavaScript的继承特性。
|
2月前
|
开发框架 JavaScript 前端开发
描述JavaScript事件循环机制,并举例说明在游戏循环更新中的应用。
JavaScript的事件循环机制是单线程处理异步操作的关键,由调用栈、事件队列和Web APIs构成。调用栈执行函数,遇到异步操作时交给Web APIs,完成后回调函数进入事件队列。当调用栈空时,事件循环取队列中的任务执行。在游戏开发中,事件循环驱动游戏循环更新,包括输入处理、逻辑更新和渲染。示例代码展示了如何模拟游戏循环,实际开发中常用框架提供更高级别的抽象。
14 1