你懂 JavaScript 嗎?#9 文法(Grammar)
16 Oct 2018JavaScript 的文法是描述其語法(syntax),例如:運算子、關鍵字等,如何結合在一起,形成格式正確的有效程式的一種結構化方式。
本文主要會談到
- 述句與運算式、述句完成值和其產生的副作用、解法和好處。
- 運用運算子優先序與結合性的規則,並顧及程式碼的可讀性。
- 依賴 ASI 還是手動加入分號?
- 錯誤-編譯時期的錯誤、執行時期的錯誤、暫時死亡區域(TDZ)。
- try…finally 與 switch 的特殊狀況。
述句與運算式(Statements & Expressions)
運算式類似片語,經由運算子(類似標點符號或連接詞)將多個運算式組成一個完成的述句。每個運算式都可各自估算其值。
const a = 1 + 2; // (1)
const b = a + 3; // (2)
b; // (3)
說明
- 運算式有:
1 + 2
(經估算得到 3)、a + 3
(經估算得到 6)、b
(經估算得到 6)。 - (1) 和 (2) 稱為「宣告述句」(declaration statement)。
- (1) 當中的
a = 1 + 2
和 (2) 當中的b = a + 3
稱為「指定運算式」(assignment expression)。 - (3) 稱為「運算式述句」(expression statement)。
述句完成值(Statement Completion Values)
只要是述句都有完成值,就算是 undefined。我們常在 console 頁籤看到最近一次執行結果的述句完成值。
由上圖中你可能會觀察到一個有趣的問題,為什麼「const a = 1 + 2;
」是得到 undefined 而非 3?
這是因為在規格中的種種複雜規則運作下,變數的述句(例如:const a
)會強制回傳 undefined 作為完成值。
依此類推,我們也會得到區塊完成值(目前是指每個區塊的最後一個述句的完成值),而為了能真正實現區塊也能得到其回傳值,有興趣的可以看這個提案-do
expressions,這樣就可以將區塊視為運算式而得到回傳值了。
理解這個「述句完成值」有什麼好處呢?它可以幫助我們…
- 解決運算式副作用(side effects)的問題。
- 精簡程式碼。
運算式副作用(Side Effects)
「述句完成值」的第一個好處是解決運算式副作用的問題,所謂「運算式副作用」其實就是經由運算式而得到的一些非預期結果,來看 --a++
這個例子。
…
…
--a++
???
這是同時遞增與遞減嗎?
別緊張,當然不是。
…
…
由於運算子的優先順序的關係,我們可以想成是這樣的 --(a++)
,先做遞增,再做遞減。
然而,執行這個運算式是會出錯的,得到 ReferenceError,貼到 Google 翻譯上是說「未捕獲的 ReferenceError:前綴操作中的左側表達式無效」。
let a = 1;
let b = --a++;
b // Uncaught ReferenceError: Invalid left-hand side expression in prefix operation
…
…
蛤,什麼意思???
…
…
先來看 ++
作為前綴(prefix)與後綴(postfix)的差異,a++
和 ++a
的差異是在於這個運算式的結果(意即述句完成值)的回傳動作是在運算前還是後發生的,a++
表示是先回傳再運算,而 ++a
是表示先運算再回傳。
let a = 1;
let b = 10;
a++ // 1,先回傳再運算
--b // 9,先運算再回傳
因此, --a++
可看成 --(a++)
,會先得到 a++
的結果 1,接著再做 --1
,但 --
只能在變數上運作,而無法用在一個值上,因此就丟出了 ReferenceError。
…
…
救星來了!
…
…
幸好,述句序列逗號運算子(,
)救了我們,,
可串起多個述句並回傳最後一個述句的結果作為述句完成值。
let a = 1;
let b = (a++, --a);
b // 1
精簡程式碼
「述句完成值」的第二個好處是能精簡程式碼。
…
…
題外話,大學的時候,我有個同學一直覺得 code review 得高分的秘訣是程式碼行數「愈多愈好」,可以用 10 行寫完的,絕對要弄到 100 行才罷休(有事嗎 @@)
但大多數的人都應該是正常的,喜歡看精簡易懂的程式碼吧!如果你也覺得 100 行很 OK,以下這一段就可以跳過惹
…
…
範例如下,以下是一個確認輸入字串到底有哪些字母是母音的函式,並回傳是母音的字母所構成的陣列。
function checkVowels(str) {
let matches;
if (str) {
matches = str.match(/[aeiou]/g);
if (matches) {
return matches;
}
}
}
checkVowels('Hello World'); // ["e", "o", "o"]
從述句完成值中得知,述句 matches = str.match(/[aeiou]/g)
會得到一個回傳值,因此可直接將此值拿來做條件判斷,精簡程式碼如下。
function checkVowels(str) {
let matches;
if (str && (matches = str.match(/[aeiou]/g))) {
return matches;
}
}
checkVowels('Hello World'); // ["e", "o", "o"]
或
function checkVowels(str) {
let matches;
return str && (matches = str.match(/[aeiou]/g)) ? matches : undefined;
}
checkVowels('Hello World'); // ["e", "o", "o"]
取決於上下文的規則(Contextual Rules)
這部份我們來看一些「語法相同,但在不同環境中有不同意義」的狀況。
大括號({ .. }
Curly Braces)
大括號({ .. }
Curly Braces)在不同環境中有不同意義的狀況有-物件字面值(object literal)、區塊(block)、物件解構(object destructuring),以下分別述之。
- 物件字面值(object literal):將值
{ .. }
指定給某個變數。
const obj = {
foo: 'Jack',
};
- 區塊(block):利用
{ .. }
標示程式碼的區塊範圍。
if (flag) {
// do something...
}
回憶之前一個難搞的範例。
[] + {}
與 {} + []
。
先猜猜看結果是什麼?
皆為 [object Object]
?
…
…
…
公佈答案摟!
[] + {} // "[object Object]"
{} + [] // 0
[] + {}
中,[]
會轉為空字串,而 {}
會轉為字串 "[object Object]"
。{} + []
中,{}
被當成空區塊而無作用, +[]
被當成強制轉型為數字 Number([])
(由於陣列是物件,中間會先使用 toString
轉成字空串,導致變成 Number('')
)而得到 0。
…
…
有沒有種頓悟的感動!
如果完全看不懂,歡迎回到強制轉型的篇章,讓小妹我好好幫你複習一下 d(d'∀')
…
…
- 物件解構(object destructuring):這裡的
{ .. }
表示解構指定式(destructuring assignment)的物件的解構。
const a = { name: 'Jack', foo: function () {} };
const foo = ({ name }) => {
console.log(`Hi, I am ${name}`);
};
foo(a); // Hi, I am Jack
else if 與選擇性區塊
else if
這樣的語法並不存在!
那這是什麼???
if (a) {
// ...
} else if (b) {
// ...
} else {
// ...
}
else if
其實只是因為 if 或 else 後若只接單一述句,就可以省略大括號 {..}
的緣故。
因此,上例程式碼其實是這樣的…
if (a) {
// ...
} else {
if (b) {
// ...
} else {
// ...
}
}
運算子優先序(Operator Precedence)
了解運算子優先序有助於我們理解程式碼什麼時候會執行(短路)、怎麼分批執行(結合性)。
Operator Precedence Table
MDN 整理了一份「運算子優先序」清單,截圖如下。
運算子優先順序由高(21)至低(1)排列。
圖片來源:Operator precedence table
短路(Short Circuited)
先前提過「選擇器運算子」(operand selector operator)的 &&
(and)和 ||
(or)的功用,其中,若運算子左手邊的運算元可估算出結果,右手邊的運算元便不會被估算,此情況稱為「短路」(short circuited)。
應用這種短路的行為的範例如下,若 flag 條件成立(true),就執行函式 foo;反之,就不執行。
const flag = true;
function foo() {
console.log('try me');
}
flag && foo(); // try me
短路其實某方面和 if 述句滿像的,如果判斷的條件不複雜或要執行的工作不多,短路可說是更為精簡易懂的寫法。
結合性(Associativity)
說到運算子優先序就一定會談到結合性,這牽涉到在執行複雜運算時要怎麼幫運算式分組、有多個相同優先序的運算子時該怎麼處理的議題。
結合性分為
- 左結合,意即由左至右處理,例如:
&&
、||
。 - 右結合,意即由右至左處理,例如:三元運算子
條件 ? 值1 : 值2
、指定運算子var a = b = c = 123
。
…
…
猜猜看以下這段程式碼要怎麼分組。
a ? b : c ? d : e
是 (a ? b : c) ? d : e
或 a ? b : (c ? d : e)
?
…
…
答案是後者 a ? b : (c ? d : e)
,因為三元運算子是右結合,從右到左來分組。
…
…
了解運算子優先序與結合性的規則,開發者在撰寫程式碼時才能「消除歧義」,建議在運用運算子優先序與結合性的同時,也手動使用小括號 (..)
歸組以顧及程式碼的可讀性。
自動分號插入(Automatic Semicolon Insertion,ASI)
JavaScript 引擎中的剖析器(parser)會在以下情況下,自動幫程式碼補上分號,以避免剖析失敗。
- 換行,即述句結尾處與下一行之間,除了空白和註解外,沒有其他的程式碼。
- break、continue、return、yield 之後。
不需要 ASI 的情況是…
- 區塊(
{...}
)不需要分號做終結。
範例如下。
const a = 10;
do {
a--
} while (a > 1)
說明
a--
後需要一個分號;
while (a > 1)
後需要一個分號;
關於到底要不要加分號這個議題,真的有非常非常多的討論…像是
就我個人而言,都是會好好加上分號的 XD 因為不加分號的意思不就是「我弄壞了但要別人幫我擦屁股」的意思嗎?…
並且,邀請大家加入 ESLint 的行列,使用工具自動檢視程式碼中微小但重要的問題!
錯誤(Errors)
編譯時期的錯誤
編譯或剖析時期丟出來的錯誤,由於程式尚未執行,因此無法以 try...catch
捕捉。
- SyntaxError,例如:無效的正規表達式
var a = /+foo/;
- ReferenceError,例如:不合法的指定運算式
var a; 42 = a;
執行時期的錯誤
- TypeError,例如:重新設定已宣告為 const 變數
const a = 2; a = 4;
const a = 2;
try {
a = 4;
} catch (e) {
console.log(e); // TypeError: Assignment to constant variable.
}
暫時死亡區域(Temporal Dead Zone,TDZ)
ES6 定義了「暫時死亡區域」(Temporal Dead Zone,TDZ),意思是程式碼中某個部份的變數的參考動作還不能執行的地方,這是因為該變數尚未被初始化的緣故。
{
a = 2; // ReferenceError!
let a;
}
之前提到 typeof 對於尚未宣告的變數可有保護機制,但在這裡是無效的。
{
typeof a; // undefined
typeof b; // ReferenceError! (TDZ)
let b;
}
try..finally
try 區塊的內容 vs finally 區塊的內容,到底是誰會先執行?誰會後執行?
先來看第一個例子。
function foo() {
try {
return 12345;
} finally {
console.log('Hello World');
}
}
console.log(foo());
顯示結果為
Hello World
12345
從結果看起來,似乎難以判斷???
再來看第二個例子。
function foo() {
try {
console.log('Hello World');
return 54321;
} finally {
return 12345;
}
}
console.log(foo());
顯示結果為
Hello World
12345
從執行順序來看,的確是先執行 try 區塊,再來才是 finally 區塊,但「述句完成值」會決定結果的「顯示」順序。首先,會先執行區塊的內容,像是 console.log(..)
,再來才是執行函式 foo()
回傳完成值。因此,在第一個例子中,會先顯示「Hello World」,再顯示「12345」。而在第二個例子中,的確也是先執行執行區塊的內容 console.log(..)
,但由於 finally 若有 return 值,則會覆寫 try 內的回傳值,而成為這個函式最後的完成值,因此得到 12345。
switch
switch 述句等同於 if-else 的縮寫,依靠 break 來決定是否要持續進行下一個 case 述句,若沒有 break 就會「落穿而過」。
範例如下,這裡有一個檢測庫存的簡易範例,假設目前庫存數量為 50,當庫存為 0 ~ 2 時提示要趕快進貨補庫存,庫存到達 50 時顯示庫存充裕,庫存到達 100 時提示貨品是不是賣不掉,其他狀況都顯示為運作正常。
const count = 50;
switch (count) {
case 0:
case 1:
case 2:
console.log('快賣完了!趕快進貨!');
case 50:
console.log('庫存充裕');
case 100:
console.log('是不是賣不掉了!?');
default:
console.log('運作正常');
}
但出乎意料的是,結果印出「庫存充裕、是不是賣不掉了!?、運作正常」。
庫存充裕
是不是賣不掉了!?
運作正常
這是因為如果沒有加入 break,一旦某個符合條件了,接下來的 case 無論符合與否都會被執行,也就是剛才所提到的「落穿而過」。
加入 break 修正一下。
const count = 50;
switch (count) {
case 0:
case 1:
case 2:
console.log('快賣完了!趕快進貨!');
break;
case 50:
console.log('庫存充裕');
break;
case 100:
console.log('是不是賣不掉了!?');
break;
default:
console.log('運作正常');
}
結果印出
庫存充裕
另外,switch 所做的比對是嚴格相等(===
),若希望能使用寬鬆相等(==
)而能有強制轉型的功能,就需要改變一下寫法,像是…
var a = '12345';
switch (true) {
case a == 10:
console.log("10 or '10'");
break;
case a == 12345:
console.log("12345 or '12345'");
break;
default:
// 不會到達這裡的!
}
結果得到
12345 or '12345'
最後,default 不一定要放在最後,順序是什麼並不重要喔!
回顧
看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…
- 述句與運算式、述句完成值和其產生的副作用、解法和好處。
- 運用運算子優先序與結合性的規則,並顧及程式碼的可讀性。
- 依賴 ASI 還是手動加入分號?我個人是偏好要加分號!也推薦大家使用 ESLint!
- 錯誤-編譯時期的錯誤、執行時期的錯誤、暫時死亡區域(TDZ)。
- try…finally 與 switch 的特殊狀況。
References
同步發表於2019 鐵人賽。
「你所不知道的 JS」系列書的第一集「導讀,型別與文法」終於讀完了!跟我一起歡呼吧!明天開始要進入第二集「範疇與閉包 / this 與物件原型」,敬請拭目以待 (๑•̀ㅂ•́)و✧
…
…
p.s. 感謝 hunterliu 友情出借愛書 d(`・∀・)b 也推薦閱讀他的鐵人賽作品「用 Nuxt.js 2.0, Vuetify, Storybook, Firebase 建一個 Blog」,讚讚!