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 語句并插入分號。
  2. 當解析到文件末尾發現語法還是無法構成合法的語句,就會在文件末尾插入分號。
  3. 當解析碰到 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 一鍵去掉分號以及統一格式化,而不會有任何的負擔了。

參考

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,048評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,414評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,169評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,722評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,465評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,823評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,813評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,000評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,554評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,295評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,513評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,722評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,125評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,430評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,237評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,482評論 2 379

推薦閱讀更多精彩內容