你懂 JavaScript 嗎?#9 文法(Grammar)

你所不知道的 JS

JavaScript 的文法是描述其語法(syntax),例如:運算子、關鍵字等,如何結合在一起,形成格式正確的有效程式的一種結構化方式。

本文主要會談到

述句與運算式(Statements & Expressions)

運算式類似片語,經由運算子(類似標點符號或連接詞)將多個運算式組成一個完成的述句。每個運算式都可各自估算其值。

const a = 1 + 2; // (1)
const b = a + 3; // (2)
b; // (3)

說明

述句完成值(Statement Completion Values)

只要是述句都有完成值,就算是 undefined。我們常在 console 頁籤看到最近一次執行結果的述句完成值。

述句完成值

由上圖中你可能會觀察到一個有趣的問題,為什麼「const a = 1 + 2;」是得到 undefined 而非 3?

why?

這是因為在規格中的種種複雜規則運作下,變數的述句(例如:const a)會強制回傳 undefined 作為完成值。

依此類推,我們也會得到區塊完成值(目前是指每個區塊的最後一個述句的完成值),而為了能真正實現區塊也能得到其回傳值,有興趣的可以看這個提案-do expressions,這樣就可以將區塊視為運算式而得到回傳值了。

理解這個「述句完成值」有什麼好處呢?它可以幫助我們…

運算式副作用(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),以下分別述之。

const obj = {
  foo: 'Jack',
};
if (flag) {
  // do something...
}

回憶之前一個難搞的範例。

[] + {}{} + []

先猜猜看結果是什麼?

皆為 [object Object]

公佈答案摟!

[] + {} // "[object Object]"
{} + [] // 0

[] + {} 中,[] 會轉為空字串,而 {} 會轉為字串 "[object Object]"{} + [] 中,{} 被當成空區塊而無作用, +[] 被當成強制轉型為數字 Number([]) (由於陣列是物件,中間會先使用 toString 轉成字空串,導致變成 Number(''))而得到 0。

有沒有種頓悟的感動!

領悟的瞬間

如果完全看不懂,歡迎回到強制轉型的篇章,讓小妹我好好幫你複習一下 d(d'∀')

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

圖片來源: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)

說到運算子優先序就一定會談到結合性,這牽涉到在執行複雜運算時要怎麼幫運算式分組、有多個相同優先序的運算子時該怎麼處理的議題。

分組

結合性分為

猜猜看以下這段程式碼要怎麼分組。

a ? b : c ? d : e

(a ? b : c) ? d : ea ? b : (c ? d : e)

暈

答案是後者 a ? b : (c ? d : e),因為三元運算子是右結合,從右到左來分組。

了解運算子優先序與結合性的規則,開發者在撰寫程式碼時才能「消除歧義」,建議在運用運算子優先序與結合性的同時,也手動使用小括號 (..) 歸組以顧及程式碼的可讀性。

自動分號插入(Automatic Semicolon Insertion,ASI)

JavaScript 引擎中的剖析器(parser)會在以下情況下,自動幫程式碼補上分號,以避免剖析失敗。

不需要 ASI 的情況是…

範例如下。

const a = 10;

do {
  a--
} while (a > 1)

說明

關於到底要不要加分號這個議題,真的有非常非常多的討論…像是


就我個人而言,都是會好好加上分號的 XD 因為不加分號的意思不就是「我弄壞了但要別人幫我擦屁股」的意思嗎?…

ESLint

並且,邀請大家加入 ESLint 的行列,使用工具自動檢視程式碼中微小但重要的問題!

錯誤(Errors)

編譯時期的錯誤

編譯或剖析時期丟出來的錯誤,由於程式尚未執行,因此無法以 try...catch 捕捉。

執行時期的錯誤

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 不一定要放在最後,順序是什麼並不重要喔!

回顧

看完這篇文章,我們到底有什麼收穫呢?藉由本文可以理解到…

References


同步發表於2019 鐵人賽


「你所不知道的 JS」系列書的第一集「導讀,型別與文法」終於讀完了!跟我一起歡呼吧!明天開始要進入第二集「範疇與閉包 / this 與物件原型」,敬請拭目以待 (๑•̀ㅂ•́)و✧

YA

導讀,型別與文法

p.s. 感謝 hunterliu 友情出借愛書 d(`・∀・)b 也推薦閱讀他的鐵人賽作品「用 Nuxt.js 2.0, Vuetify, Storybook, Firebase 建一個 Blog」,讚讚!


You-Dont-Know-JS javascript 你所不知道的JS 2019鐵人賽 你懂JavaScript嗎? 鐵人賽 You-Dont-Know-JS-Types-and-Grammar ReferenceError operator 運算子 你懂 JavaScript 嗎?2019 iT 邦幫忙鐵人賽 系列文