你懂 JavaScript 嗎?#29 語法(Syntax)

ES6

本文主要會談到 ES6 新增的熱門語法,包含以區塊為範疇的宣告、分散與其餘運算、預設參數值、解構、物件字面值擴充功能、範本字面值、箭號函式。

以區塊為範疇的宣告(Block-Scoped Declaration)

在 ES6 出現以前 JavaScript 是以函式為範疇宣告的單位,而在 ES6 之後,可用大括號 { ... } 定義區塊範疇,讓 let 和 const 宣告以區塊為範疇的變數。

先總結 let 與 const 的特點,都是…

兩者差異在於 let 可重新賦值,而 const 在宣告時就要給值,之後也不能更新其值。

let

let + for

由於 let 可宣告以區塊為範疇的變數,因此在與 for 合用時,能對每次迭代都產生一個新的變數並將上一次迭代的結果作為這一次的初始值。

範例如下,每秒依序印出數字 1, 2, 3, …, 5,let 會在每次迭代時重新宣告變數 i,並將上一次迭代的結果作為這一次的初始值。

for (let i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

得到結果

1;
2;
3;
4;
5;

如果不用 let 而是用 var,運作結果就不是我們預期所想的那樣…那是因為 var 所宣告的 i 只會有一個被外層範疇宣告包圍,而 let 會在每次迭代時建立新的變數 i,而這些 i 都會被各自的範疇所包圍。我們可以想成 var 的每次迭代都共用同一個 i,而 let 會讓每次迭代都擁有自己獨立的 i 以供後續運作。

範例如下,由於 console.log(i) 中的 i 會存取的範疇是 for 所在的範疇,而此例為全域範疇,並且 var 宣告的變數不具區塊範疇的特性,因此當 1 秒、2 秒…5 秒後執行 console.log(i) 時,就會去取 i 的值,而此時 for 迴圈已跑完,i 變成 6,因此就會每隔一秒印出一個「6」。

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

得到結果

6;
6;
6;
6;
6;
6;

暫時死亡區域(Temporal Dead Zone,TDZ)

在宣告前使用 var 所宣告的變數,由於拉升(hoisting) 的緣故,變數宣告會被提到所屬範疇的最上方但不賦值,因此會得到 undefined。

console.log(x); // undefined

var x = 1;

ES6 定義了「暫時死亡區域」(Temporal Dead Zone,TDZ),意即程式碼中某個部份的變數的參考動作還不能執行的地方,這是因為該變數尚未被初始化的緣故。

範例如下,在宣告前使用 let 所宣告的變數會產生 TDZ 的問題,因此會報錯 ReferenceError。

console.log(x); // Uncaught ReferenceError: x is not defined

let x = 1;

這在使用 typeof 檢查變數是否存在時也有同樣的狀況。如下所示,之前提到 typeof 對於尚未宣告的變數可有保護機制,但在這裡是無效的。如下,typeof b 會被報錯,得到 ReferenceError。

{
  typeof a; // undefined
  typeof b; // ReferenceError! (TDZ)
  let b;
}

垃圾回收

另外值得再次提起的是「垃圾回收」的議題-一但變數用不到了,JavaScript 引擎就可能會將它回收,但由於範疇的緣故,仍須保留這些變數存取值的能力,而區塊範疇明確表達資料不再用到,而解決這個不需要被保留的狀況,可釋出更多記憶體空間,點這裡參考先前範例。

const

宣告變數為常數,不可重新賦值。

{
  const a = 2;
  console.log(a); // 2

  a = 3; // Uncaught TypeError: Assignment to constant variable.
}

但注意,不可變的不是值本身,而是「參考」。因此,當將一個物件或陣列設定為常數時,這意味著此常數的語彙範疇在消失前,這個值都無法被垃圾回收,因為這個參考永遠都無法被解除設定。

{
  const a = [1, 2, 3];
  a.push(4);

  console.log(a); // [1, 2, 3, 4]

  a = 123; // Uncaught TypeError: Assignment to constant variable.
}

雖然 const 比起 let 對於 JavaScript 來說來得更容易最佳化,但我們在選擇要使用 const 或 let 來宣告變數時,最重要的是考慮用途-是否真的是常數?在這樣的思路下所撰寫的程式碼才是易於閱讀、好維護的。

以區塊為範疇的函式

從 ES6 之後,在區塊內宣告的函式將會被限制在該區塊內,但函式可被拉升而無 TDZ 的問題。

如下範例所示,在 ES6 之前的環境下,不論 flag 的值是 true 或 false,都會因為函式的拉升且後面的宣告會覆蓋前面的宣告而得到 2。

if (flag) {
  function foo() {
    console.log('1');
  }
} else {
  function foo() {
    console.log('2');
  }
}

foo(); // 2

但若在 ES6 的環境下,則不論 flag 的值為何,由於函式在區塊內宣告,因此只能在區塊內存取,因此會得到 ReferenceError。

if (flag) {
  function foo() {
    console.log('1');
  }
} else {
  function foo() {
    console.log('2');
  }
}

foo(); // ReferenceError

擴展與其餘運算(Spread/Rest)

... 可作為擴展或其餘運算子,端看用途而定。

擴展運算子(Spread Operator)

... 表示,將陣列展開成個別數值,可以想像是展示(展示這個陣列的所有元素)的功能。

以下列出相關應用。

將陣列展開為字串

將 list 這個陣列展開為字串 apple boy cat

let list = ['apple', 'boy', 'cat'];
console.log(...list); // apple boy cat

展開字串成為個別元素

將 list 陣列內的字串 jacket 展開為個別字元。

let list = [...'jacket'];
console.log(list); // ["j", "a", "c", "k", "e", "t"]

陣列的複製

單層結構的物件或陣列是以深拷貝(deep copy)的方式進行複製。

let list = ['apple', 'boy', 'cat'];
let list2 = [...list];
let list3 = ['doll', ...list, 'fat'];

console.log(list2); // ["apple", "boy", "cat"]
console.log(list3); // ["doll", "apple", "boy", "cat", "fat"]

list.push('goat');
console.log(list); // ["apple", "boy", "cat", "goat"]
console.log(list2); // ["apple", "boy", "cat"]
console.log(list3); // ["doll", "apple", "boy", "cat", "fat"]

在多維陣列或有複雜物件結構的情況時,是以淺拷貝(shallow copy)的方式進行複製。如下範例所示,更新 list 中的元素 'fat''happy',結果也更動到 list2 了。

let list = ['apple', 'boy', 'cat', ['doll', 'fat']];
let list2 = [...list];

list[3][1] = 'happy';
console.log(list); // ["apple", "happy", "cat", ["doll", "happy"]]
console.log(list2); // ["apple", "boy", "cat", ["doll", "happy"]]

陣列的合併

範例如下,將陣列 list 與 list2 合併為 list3。

可取代 concat 來做陣列的複製。

let list = ['apple', 'boy', 'cat'];
let list2 = ['dog', 'egg'];
let list3 = list.concat(list2); // ["apple", "boy", "cat", "dog", "egg"]

改用擴展運算子。

let list = ['apple', 'boy', 'cat'];
let list2 = ['dog', 'egg'];
let list3 = [...list, ...list2]; // ["apple", "boy", "cat", "dog", "egg"]

當成參數,代入函式中

let student = ['Nina', 'girl'];

function sayHi(name, gender) {
  console.log(`Hi, I am ${name}. I am a ${gender}.`);
}

sayHi(...student); // Hi, I am Nina. I am a girl.

對照 ES5 的語法,由於 apply 的第二個參數是陣列(fun.apply(thisArg, [argsArray])),因此上面的例子可改為下面的寫法

sayHi.apply(null, student); // Hi, I am Nina. I am a girl.

其餘運算子(Rest Operator)

... 表示,集合剩餘的數值並轉為陣列,可以想像是收納(多個元素至一個陣列)的功能。

其餘參數

範例如下,若對應不到的參數,就會將剩餘的數值蒐集起來轉成陣列(非類陣列)。因此,x 為字串 'happy',其餘的字元轉為陣列 ["f", "i", "v", "e"]

const concatenate = (x, ...letters) => {
  console.log(x);
  console.log(letters);
};

concatenate('happy', 'f', 'i', 'v', 'e');

得到結果。

happy[('f', 'i', 'v', 'e')];

使用這樣作法的優點是-在不確定要傳入多少參數的時候,是很好用的。

備註:(1) 其餘參數只能有一個,並且只能放在最後;(2) 其餘參數在沒有傳入值的時候會是空陣列。

陣列的解構賦值

const [a, ...b] = [1, 2, 3, 4, 5]; // a = 1, b = [2, 3, 4, 5]

對應不到的時候就是空陣列。

const [a, ...b] = [1]; // a = 1, b = []

預設參數值(Default Parameter Value)

在 JavaScript 中,函式的參數預設值都為 undefined,在 ES6 可為參數設定初始值,好處是再也不用在函式中檢查參數是否為 undefined 再設定初始值了。

範例如下,像是這樣的檢查,若沒有傳入參數值或傳入 undefined,就設定為初始值。

function callMe(phone) {
  return phone || '0912345678';
}

callMe(); // "0912345678"
callMe(undefined); // "0912345678"
callMe('0987654321'); // "0987654321"

但若傳入會轉型為 false 的值,就會出現誤判的狀況,反而用了預設值。這是因為邏輯運算子 || 會先將第一個運算元做布林測試或強制轉型為布林以便測試,若為結果 true,則取第一個運算元為結果;若結果為 false,則取第二個運算元為結果。

因此,如下範例所示,0 || '0912345678' 檢測 0 並做轉型得到 false,因此選了第二個運算元作為結果,也就是 '0912345678',而誤設了初始值。

callMe(0); // "0912345678"

改進如下。

function callMe(phone) {
  var telNumber = typeof phone !== 'undefined' ? phone : '0912345678';
  return telNumber;
}

callMe(); // "0912345678"
callMe(undefined); // "0912345678"
callMe('0987654321'); // "0987654321"

function callMe(phone) {
  var telNumber = phone !== undefined ? phone : '0912345678';
  return telNumber;
}

callMe(); // "0912345678"
callMe(undefined); // "0912345678"
callMe('0987654321'); // "0987654321"

以上看起來程式碼挺複雜的…那麼改為在參數傳入時設定預設值吧。

const callMe = (phone = '0911111111') => phone;

callMe(); // "0911111111"
callMe('0922222222'); // "0922222222"

預設參數值的功能是不是簡潔有力呢!

預設值運算式(Default Value Expression)

預設值不但可以是簡單值,還可以是運算式或函式呼叫。

函式宣告中的形式參數(formal parameter)是形成自己的範疇,當無法在形式範疇中找到時,才會往外面的範疇查找。因此 function bar(x = y + 2, z = foo(x)) 中的 y 無法在自己的形式範疇中找到,於是往外的全域範疇找到值為 5,而順利運算得到 x 的值;z = foo(x) 中的 x 可在自己的形式範疇中找到,於是順利計算出 z 的值。

function foo(a) {
  return a + 1;
}

function bar(x = y + 2, z = foo(x)) {
  console.log(x, z);
}

var y = 5;
bar(); // 得到 7 8,其中 x = 7, y = 5, z = 8
bar(10, 20); // 得到 10 20,其中 x = 10, 未宣告 y, z = 20

再看下面的一個例子,在運算 z + 1 的過程中,z 可在自己的形式範疇中找到,但此時 z 尚未被初始化(TDZ)而無法存取,因此得到 ReferrenceError。

var w = 1,
  z = 2;

function foo(x = w + 1, y = x + 1, z = z + 1) {
  console.log(x, y, z);
}

foo(); // ReferrenceError

解構(Destructure)

物件與陣列專用,是一種有結構的指定方式,可以想像成是類似鏡子映射的概念來指定成員值。

範例如下,經由一一對應的方式,將變數與值對應起來,無法對應到的就會得到 undefined。

const [a, b] = [1, 2]; // a = 1, b = 2
const { foo, bar } = { foo: '12345' }; // foo = '12345', bar = undefined

物件特性指定模式

在這裡要先說明一下,一般物件屬性的設定與物件解構的模式可能稍微有點不同。

範例如下,在物件 a 指定屬性 aa 與 bb 的值的過程中,aa: x 的資料來源與目的地是 target <- source,而在解構 a 物件時,{ aa: AA } 卻是 source -> target,這樣的模式是需要特別注意的。

var x = 1,
  y = 2;
var a = { aa: x, bb: y };
var { aa: AA, bb: BB } = a;

console.log(`AA: ${AA}`);
console.log(`BB: ${BB}`);

得到結果

AA: 1;
BB: 2;

我們也可以將宣告的變數提出來,而非隱藏在解構當中。

var x = 1,
  y = 2,
  AA,
  BB;
var a = { aa: x, bb: y };
({ aa: AA, bb: BB } = a);

console.log(`AA: ${AA}`);
console.log(`BB: ${BB}`);

注意 ({ aa: AA, bb: BB } = a) 外必須要包裹小括號,否則 {...} 的部份會被視為區塊述句而非物件。

另,指定的不一定要是簡單值,也可以是運算式。範例如下,經由計算得出的屬性名稱與解構方式來指定屬性值。

var which = 'a',
  o = {};

function foo() {
  return {
    a: 1,
    b: 2,
  };
}

({ [which]: o[which] } = foo());

console.log(o); // {a: 1}

還可做值的交換,範例如下,將 x 與 y 的值做交換。

var x = 1,
  y = 2;
[y, x] = [x, y];
console.log(`x: ${x}, y: ${y}`); // x: 2, y: 1

重複指定

解構允許將來源值重複指定給目的地。

var a = { x: 1 };
var { x: AA, x: BB } = a;
console.log(`AA: ${AA}, BB: ${BB}`); // AA: 1, BB: 1

巢狀解構

解構子物件或子陣列。

解構子物件。

var {
  a: { x: X, x: Y },
  a,
} = {
  a: {
    x: 1,
  },
};

console.log(a); // { x: 1 }
console.log(X); // 1
console.log(Y); // 1

解構子陣列。

({
  a: X,
  a: Y,
  a: [Z],
} = { a: [1] });

X.push(2);
Y[0] = 10;

console.log(X); // [10,2]
console.log(Y); // [10,2]
console.log(Z); // 1

巢狀預設值:解構與重新結構化

我們常遇到一種狀況,在製作設定檔時,一般條件下,使用預設的設定參數即可,而若有特殊情況,再將部份參數修改,以適應新的環境。

這裡有一份預設的參數設定檔案。

var defaults = {
  setupFiles: ['setup.js', 'setUp-another.js'],
  testUrl: 'https://sample.com.tw',
  support: {
    a: true,
    b: false,
  },
};

還有一份應用在特殊情況下的參數設定檔案,若在特殊狀況下沒有設定到的部份,就沿用預設的參數設定即可。

var config = {
  testUrl: 'https://sample-special.com.tw',
  support: {
    a: false,
    c: true,
  },
};

若希望能達到目的-以 defaults 為預設值,並添加或覆蓋上 config,大多時候的寫法都是很複雜的。

來看解構可以幫我們做什麼?

將 defaults 融合到 config 中,如下所示,先將全部的屬性都解構到頂層變數中,再重新建構為預定的巢狀結構。

{
  let {
    setupFiles = defaults.setupFiles,
    testUrl = defaults.testUrl,
    support = defaults.support,
  } = config;

  config = {
    setupFiles,
    testUrl,
    support,
  };
}

解構可以幫我們重新結構化,好讀易懂。

太多、太少、剛剛好

解構不一定需要一一對應。

不必指定所有出現的值,丟棄 a 物件的屬性 y 與 z,只取 x。

var a = {
  x: 1,
  y: 2,
  z: 3,
};
var { x } = a;
console.log(x); // 1

超過的指定會被設為 undefined,範例如下,z 並沒有被對應到,因此值為 undefined。

var a = [1, 2];
var [x, y, z] = a;

console.log(`x: ${x}`);
console.log(`x: ${y}`);
console.log(`x: ${z}`);

與擴展運算子合併使用,如下範例所示,...d 會將剩餘的數值聚集成一個陣列並指定給 d。

var a = [1, 2, 3, 4, 5];
var b = ([, c, ...d] = a);

console.log(c); // 2
console.log(d); // [3, 4, 5]

預設值指定

解構也能當成預設值來做指定,如下範例所示,由於 b 為 undefined,因此給定預設值 2。

var { a = 3, b = 2, c = 1 } = foo();

function foo() {
  return {
    a: 1,
    b: undefined,
    c: 3,
  };
}

console.log(a); // 1
console.log(b); // 2
console.log(c); // 3

參數解構

函式的參數解構,並搭配指定預設值。

var obj = { a: 2, b: 1, c: 'Hello World' };

function foo({ a = 1, b = 2 }) {
  console.log(`a: ${a}, b: ${b}`);
}

foo(obj); // a: 2, b: 1

解構預設值 vs 參數預設值

解構預設值與函式參數預設值的差異是什麼呢?

範例如下,函式 foo 使用了兩種方式來設定參數的預設值,其中

function foo({ x = 10 } = {}, { y } = { y: 10 }) {
  console.log(x, y);
}

foo(); // 10 10
foo(undefined, undefined); // 10 10
foo({}, undefined); // 10 10
foo({}, {}); // 10 undefined
foo(undefined, {}); // 10 undefined
foo({ x: 2 }, { y: 3 }); // 2 3

物件字面值擴充功能(Object Literal Extension)

ES6 為物件字面值新增了幾個重要且方便的擴充功能。

簡潔特性

若定義的屬性與所使用的語彙識別字同名則可作簡寫。

範例如下,x 與 y 的屬性與所使用的語彙識別字同名,分別寫了兩次 x 與兩次 y。

var x = 1, y = 2;

var obj = {
  x: x,
  y: y,
};

簡寫如下,只需要寫一次 x 和 y 就可以了。

var x = 1, y = 2;

var obj = {
  x,
  y,
};

簡潔方法

方法上不需要 function 關鍵字,也就是省略 function()

var obj = {
  a: function () {
    // ...
  },
  b: function () {
    // ...
  },
};

可簡寫為…

var obj = {
  a() {
    // ...
  },
  b() {
    // ...
  },
};

計算得出的屬性名稱

ES6 新增動態產生的字串作為屬性名稱功能,讓 key 的值可經由運算得出,並且必須使用鍵值存取 [ ] 的方式。

const prefix = 'fresh-';

const fruits = {
  [prefix + 'apple']: 100,
  [prefix + 'orange']: 60,
};

fruits['fresh-apple']; // 100
fruits['fresh-orange']; // 60

設定 [[Prototype]]

在 ES6 前若想設定 [[Prototype]] 內部屬性的值,可直接設定 __proto__ 這個內部屬性來將兩個物件連起來(點這裡複習原型,點那裡複習委派),但現在可以使用 ES6 的 setPrototypeOf 來設定 [[Prototype]] 內部屬性的值了。

var o1 = {
  // ...
};

var o2 = {
  __proto__: o1,
  // ...
};

可改成

var o1 = {
  // ...
};

var o2 = {
  // ...
};

Object.setPrototypeOf(o2, o1);

這個意思就是當在 o2 無法找到特定的屬性時,就循著鍊結串鏈往 o1 找就可以了。

物件 super

super 通常與類別有關,但其實也可以只是(也只能)用於普通物件的簡潔方法中(不可用於函式運算式屬性上)。

如下範例所示,o2 的 foo 方法中的 super 等同於 Object.getPrototypeOf(o2) 而得到 o1,因此會呼叫 o1.foo

var o1 = {
  foo() {
    console.log('o1:foo');
  },
};

var o2 = {
  foo() {
    super.foo();
    console.log('o2:foo');
  },
};

Object.setPrototypeOf(o2, o1);

o2.foo();

得到結果

o1: foo;
o2: foo;

範本字面值(Template Literal)

範本字面值又稱「內插的字串字面值」(Interpolated String Literal),在 ES6 可使用 `(backtick)作為分隔符號來內插運算式,並會自動剖析與估算。

也就是說,backtick 的內容會被解讀為字串,而 ${...} 內的運算式會被剖析並在行內被估算。

範例如下,ES6 前必須要用 + 與雙/單引號拼湊字串,但在 ES6 後使用 ${ variable_name } 代入變數即可。

let name = 'Summer';
let greetings_1 = 'Hello ' + name + '!'; // greetings_1 = "Hello Summer!"
let greetings_2 = `Hello ${name}!`; // greetings_2 = "Hello Summer!"

可跨越多行,並且斷行會被保留。

var text = `Hello World
Hello World
Hello World
Hello World`;

console.log(text);

得到結果

Hello World
Hello World
Hello World
Hello World

除了字串,還可以放任何運算式,包含函式呼叫等。

如下所示,建立一個函式 upper 負責將字串轉為大寫,接著內插到 backtick 中。

function upper(s) {
  return s.toUpperCase();
}

var who = 'reader';

var text = `A very ${upper('warm')} welcome
to all of you ${upper(`${who}s`)}!`;

console.log(text);
// A very WARM welcome
// to all of you READERS!

箭號函式(Arrow Function)

箭號函式除了提供較精簡的語法外,更重要的是關於 this 的綁定(點此複習 this),箭號函式的 this 的值是以語彙範疇的方式查找,其值並非源自執行時的綁定,而是定義時包含它的範疇或全域範疇,並不適用於之前在 this 的篇章所提到的四種規則來做判斷。

範例如下,雖然 obj.sayHi() 印出預期的「Hi, I am Jack」,但 setTimeout(obj.sayHi, 1000); 卻因為前面提過的隱含的失去中的函式是另一個函式的參考而失去了原先 this 的綁定。

var name = 'Apple';
var obj = {
  name: 'Jack',
  sayHi: function () {
    console.log(`Hi, I am ${this.name}`);
  },
};

obj.sayHi(); // Hi, I am Jack
setTimeout(obj.sayHi, 1000); // Hi, I am Apple

改進方法有很多,像是…

透過變數儲存目前 this 的值…這其實不算是「改變」,只能說是「保留」。如果希望存取到外部的 this,可用一個變數 _this 儲存起來,稍後再用。

修改上例如下,一秒後的確是印出「Hi, I am Jack」。

var obj = {
  name: 'Jack',
  sayHi: function () {
    var _this = this;

    setTimeout(function () {
      console.log(`Hi, I am ${_this.name}`);
    }, 1000);
  },
};

obj.sayHi(); // Hi, I am Jack

當然我們也可以用 bind、call 或 apply 來明確綁定特定的物件作為 this 的值。

修改上例如下,一秒後的確是印出「Hi, I am Jack」。

var obj = {
  name: 'Jack',
  sayHi: function () {
    setTimeout(
      function () {
        console.log(`Hi, I am ${this.name}`);
      }.bind(this),
      1000,
    );
  },
};

obj.sayHi(); // Hi, I am Jack

既然箭號函式會自動綁定所在範疇,那也就可以這麼修改了…經由箭號函式就會把 this 綁在 obj 上。

var obj = {
  name: 'Jack',
  sayHi: function () {
    setTimeout(() => {
      console.log(`Hi, I am ${this.name}`);
    }, 1000);
  },
};

obj.sayHi(); // Hi, I am Jack

雖然箭號函式語法簡單又能解決 this 綁定不明確的問題,但箭號函式不適用於…

更多關於箭頭函式的 this,可參考這裡

回顧

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

References


同步發表於2019 鐵人賽


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