本章探討 JavaScript 中三種主要的集合型別:ArrayMapSet,介紹如何有效地建立、修改與操作這些集合。

9.1 Arrays#

JavaScript 的陣列本質上是物件(objects),這與 C 等強型別語言中連續記憶體的陣列有根本差異。因為是物件,陣列可以存取方法,但在效能上也有一些特殊之處。

建立陣列#

有兩種基本方式建立陣列:

  • 陣列字面值(Array literal):[]
  • Array 建構函式new Array()
const ninjas = ["Kuma", "Hattori", "Yagyu"];
const samurai = new Array("Oda", "Tomoe");

建議優先使用陣列字面值 [],因為語法更簡潔(2 個字元 vs 11 個字元),而且 JavaScript 的動態特性允許覆寫內建的 Array 建構函式,使用字面值可避免此風險。

重要特性:

  • 每個陣列都有 length 屬性,表示陣列大小
  • 索引從 0 開始,最後一個元素索引為 array.length - 1
  • 存取超出範圍的索引會回傳 undefined(不會拋出例外)
  • 對超出範圍的索引寫入值會自動擴展陣列,中間產生的空位為 undefined
  • 手動將 length 設為較小的值會截斷陣列

在陣列兩端增刪元素#

  • push:在陣列末端新增元素
  • pop:從陣列末端移除元素
  • unshift:在陣列開頭新增元素
  • shift:從陣列開頭移除元素

pushpop 只影響陣列的最後一個元素,效能較好。shiftunshift 會改變第一個元素,導致後續所有元素的索引都需要調整,因此效能較差。除非有特殊需求,建議優先使用 pushpop

在任意位置增刪元素#

使用 delete 運算子刪除陣列元素會留下一個 undefined 的「洞」,length 不會改變,這通常不是我們想要的結果。

正確做法是使用 splice 方法:

const ninjas = ["Yagyu", "Kuma", "Hattori", "Fuma"];

// 從索引 1 開始移除 1 個元素
var removedItems = ninjas.splice(1, 1);
// ninjas: ["Yagyu", "Hattori", "Fuma"]
// removedItems: ["Kuma"]

// 從索引 1 開始,移除 2 個元素,並插入 3 個新元素
removedItems = ninjas.splice(1, 2, "Mochizuki", "Yoshi", "Momochi");
// ninjas: ["Yagyu", "Mochizuki", "Yoshi", "Momochi"]

常見陣列操作#

迭代(Iterating):

  • 傳統 for 迴圈需要手動管理計數器變數,容易出錯
  • forEach 方法更簡潔,接受一個 callback,為每個元素立即呼叫
const ninjas = ["Yagyu", "Kuma", "Hattori"];
ninjas.forEach((ninja) => {
  assert(ninja !== null, ninja);
});

Figure 9.4: 使用 for 迴圈遍歷陣列的輸出結果

映射(Mapping):

  • map 方法對每個元素呼叫 callback,用回傳值建立一個全新的陣列
const weapons = ninjas.map((ninja) => ninja.weapon);

測試(Testing):

  • every:所有元素都滿足條件才回傳 true(遇到 false 立即停止)
  • some:只要有一個元素滿足條件就回傳 true(遇到 true 立即停止)

搜尋(Finding):

  • find:回傳第一個滿足條件的元素,找不到則回傳 undefined
  • filter:回傳所有滿足條件的元素組成的新陣列
  • indexOf / lastIndexOf:回傳特定項目的第一個 / 最後一個索引
  • findIndex:類似 find,但回傳的是索引而非元素本身

排序(Sorting):

  • sort 方法接受一個比較用的 callback,回傳值決定排序順序:
    • < 0a 排在 b 前面
    • = 0:維持不變
    • > 0a 排在 b 後面
array.sort((a, b) => a - b);

聚合(Aggregating):

  • reduce 方法將陣列歸納為單一值,接受一個累加器 callback 和初始值
const sum = numbers.reduce((aggregated, number) => aggregated + number, 0);

重用內建陣列函式#

可以在自訂物件上重用 Array.prototype 的方法,透過 callapply 明確設定方法的呼叫上下文:

const elems = {
  length: 0,
  add: function (elem) {
    Array.prototype.push.call(this, elem);
  },
  find: function (callback) {
    return Array.prototype.find.call(this, callback);
  },
};

這個技巧利用了 push 方法會自動遞增 length 屬性並以數字索引新增元素的特性,讓普通物件也能模擬陣列行為。

9.2 Maps#

Map 是一種將(key)對應到(value)的集合,也稱為 dictionary。

不要使用物件作為 Map#

使用普通物件作為 Map 有兩個嚴重問題:

  1. 原型屬性污染:所有物件都有原型,即使是空物件 {} 也能存取到 constructor 等未明確定義的屬性
  2. 鍵只能是字串:非字串的鍵會被隱式轉換為字串。例如用 DOM 元素作鍵,不同的 DOM 元素可能被轉成相同的字串(如 "[object HTMLDivElement]"),導致互相覆蓋

由於原型繼承和字串限制,普通物件不適合作為 Map 使用。應改用 ES6 的內建 Map 集合。

Figure 9.12: 物件不適合作為 map,因為它們會存取到原型鏈上的屬性

Figure 9.13: 物件的鍵會被轉為字串

建立與使用 Map#

使用 new Map() 建構函式建立 Map:

const ninjaIslandMap = new Map();

ninjaIslandMap.set(ninja1, { homeIsland: "Honshu" });
ninjaIslandMap.set(ninja2, { homeIsland: "Hokkaido" });

ninjaIslandMap.get(ninja1).homeIsland; // "Honshu"

Map 的主要方法與屬性:

  • set(key, value):建立鍵值對應
  • get(key):取得鍵對應的值,不存在則回傳 undefined
  • has(key):檢查鍵是否存在
  • delete(key):刪除指定鍵
  • clear():清除所有鍵值對
  • size:回傳目前的對應數量

鍵的相等性(Key Equality): Map 的鍵比較基於物件引用(reference equality),兩個內容相同但引用不同的物件會被視為不同的鍵。JavaScript 無法覆載相等運算子,這點需要特別注意。

Figure 9.15: Map 中的鍵相等性基於物件參照

迭代 Map#

Map 可以使用 for...of 迴圈迭代,保證按照插入順序遍歷:

for (let item of directory) {
  // item[0] 是 key,item[1] 是 value
}

也可以使用 keys()values() 方法分別迭代鍵或值。

9.3 Sets#

Set 是不重複元素的集合,每個元素只能出現一次。ES6 之前需要用物件模擬,但存在與 Map 相同的原型污染和字串鍵限制。

建立與使用 Set#

const ninjas = new Set(["Kuma", "Hattori", "Yagyu", "Hattori"]);
// size 為 3,重複的 "Hattori" 被自動忽略

Set 的主要方法:

  • has(item):檢查元素是否存在
  • add(item):新增元素(已存在則無效果)
  • size:回傳元素數量
  • 可使用 for...of 迴圈迭代,按插入順序遍歷

Figure 9.16: Set 中的元素按插入順序迭代

聯集(Union)#

建立包含兩個集合所有元素的新 Set,利用展開運算子合併陣列:

const ninjas = ["Kuma", "Hattori", "Yagyu"];
const samurai = ["Hattori", "Oda", "Tomoe"];

const warriors = new Set([...ninjas, ...samurai]);
// 包含 5 個元素,Hattori 只出現一次

交集(Intersection)#

建立只包含兩個集合共有元素的新 Set:

const ninjaSamurais = new Set(
  [...ninjas].filter((ninja) => samurai.has(ninja))
);

先用展開運算子將 Set 轉為陣列,再用 filter 方法篩選同時存在於另一個 Set 中的元素。

差集(Difference)#

建立只包含在 A 中但不在 B 中的元素的新 Set:

const pureNinjas = new Set([...ninjas].filter((ninja) => !samurai.has(ninja)));

與交集的唯一差別是在 has 前加上 !,篩選不存在於另一個 Set 的元素。

9.4 本章重點#

  • Array 是特殊的物件,具有 length 屬性和 Array.prototype 作為原型
  • 可用陣列字面值 []new Array() 建立陣列
  • 陣列提供豐富的內建方法:push/popshift/unshiftsplicemapevery/somefind/filtersortreduce
  • 可透過 callapply 在自訂物件上重用陣列方法
  • 物件不適合作為 Map,因為有原型屬性污染和只支援字串鍵的限制,應使用內建的 Map
  • Map 是鍵值對集合,可使用 for...of 迴圈迭代
  • Set 是不重複元素的集合