前言
關于要不要加分號的問題,其實有很多爭論!有的堅持加分號,而有的不喜歡加分號...但是無論那種風格,都不能百分百避免某些特殊情況產生的問題,究其根本就是因為對 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 一鍵去掉分號以及統一格式化,而不會有任何的負擔了。