iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 9
2
Modern Web

你懂 JavaScript 嗎?系列 第 9

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

你所不知道的 JS

JavaScript 的文法是描述其語法(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?

why?

這是因為在規格中的種種複雜規則運作下,變數的述句(例如: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 整理了一份「運算子優先序」清單,截圖如下。

運算子優先順序由高(20)至低(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)

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

分組

結合性分為

  • 左結合,意即由左至右處理,例如:&&||
  • 右結合,意即由右至左處理,例如:三元運算子 條件 ? 值1 : 值2、指定運算子 var a = b = c = 123

...

...

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

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)會在以下情況下,自動幫程式碼補上分號,以避免剖析失敗。

  • 換行,即述句結尾處與下一行之間,除了空白和註解外,沒有其他的程式碼。
  • break、continue、return、yield 之後。

不需要 ASI 的情況是...

  • 區塊({...})不需要分號做終結。

範例如下。

const a = 10;

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

說明

  • a-- 後需要一個分號 ;
  • while (a > 1) 後需要一個分號 ;

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


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

ESLint

並且,邀請大家加入 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


同步發表於部落格


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

YA

...

...

導讀,型別與文法

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


上一篇
你懂 JavaScript 嗎?#8 強制轉型(Coercion)
下一篇
你懂 JavaScript 嗎?#10 範疇(Scope)
系列文
你懂 JavaScript 嗎?30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
huli
iT邦新手 3 級 ‧ 2018-11-14 11:52:09

寫得很好,js 沒有 else if 那邊真是長知識了!

Summer iT邦新手 3 級 ‧ 2019-12-27 23:05:23 檢舉

/images/emoticon/emoticon58.gif

我要留言

立即登入留言