iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 22 篇

Day 22|Set 如何找出唯一值?從去重規則到集合運算

  • 分享至 

  • xImage
  •  

摘要

為什麼內容一樣的物件,放進 Set 卻不會被去重?先弄懂 Set 判斷「一樣」的規則,再用 ES2025 的集合運算,一行比出交集資料間少了什麼、篩選條件差在哪。

  • 前置知識:會使用 Set、理解 primitive 與 object reference 的基本差異。

  • 學習路線:資料模型段落第三篇。Day 20 認識 Object、Map、Set 各自的規則 → Day 21 用 Object.groupBy() 把資料分組 → 今天:用 Set 找出唯一值,並比對兩組資料 。

  • 標籤:ES2025 Set SameValueZero Set Methods Set-like

https://ithelp.ithome.com.tw/upload/images/20260925/20145251bMg5XJHUJR.png


今日學習目標

  1. 知道 Set 怎麼判斷兩個值「一樣」:為什麼 NaN 放得進去也找得回來,0 和 -0 又為什麼算同一個
  2. 搞懂內容一樣的兩個物件,Set 為什麼還是當成兩筆,以及想依 id 去重時該怎麼做
  3. 會用 union()、intersection()、difference()、symmetricDifference() 合併資料、找共同項、找缺少項、找出差異
  4. 會用 isSubsetOf() 這類方法,直接回答「權限夠不夠」這種是非題
  5. 知道 Map 為什麼能直接拿來比對以及 intersection() 結果順序的陷阱

很多人第一次認識 Set,是從「陣列去重」開始:

const numbers = new Set([1, 1]);

console.log(numbers.size);
// 1

看起來很合理。但把數字換成物件,結果卻不一樣:

const users = new Set([
  { id: 1, name: "Rafael" },
  { id: 1, name: "Rafael" },
]);

console.log(users.size);
// 2

明明內容完全一樣,為什麼 Set 沒有把第二個物件去掉?

答案不在「Set 能不能去重」,而在一個更根本的問題:

JavaScript 怎麼判斷兩個 value 是不是同一個?

這篇先沿著 TC39 規格找到答案,再看 ES2025 新增的 union()、intersection()、difference()。理解底層規則後,這些集合 API 就不需要死背。


一、 Set 規格到底在說什麼?

ECMAScript 規格不是 engine 的原始碼。它不規定 V8 要用什麼資料結構存 Set,只規定「從程式外面看得到的行為」。

為了描述這些行為,規格用一個內部欄位 [[SetData]] 代表 Set 裡的資料(雙中括號表示 JavaScript 讀不到它)。可以把它想成一份依加入順序排列的資料:

const tags = new Set(["javascript", "css", "html"]);
// [[SetData]] ≈ ["javascript", "css", "html"]

console.log([...tags]);
// ["javascript", "css", "html"]:依加入順序走訪

這份名冊只有三條規則:

操作 規格怎麼做 白話
add(value) 資料裡沒有相同的值,才 append 到尾端 新元素永遠排在最後
delete(value) 把那一格換成 empty,其他元素不搬動 只標記刪除,資料整體不會縮短
走訪(for...of、forEach()) 用 index 從頭往後讀,遇到 empty 就跳過;每一輪都重新確認資料長度 只記得「讀到第幾格」

empty 只是規格的描述手法。實際的 engine 可以真的把元素移除,只要對外行為一樣即可。

為什麼刪除要留下 empty?

因為刪除可能發生在走訪途中:

const tags = new Set(["javascript", "css", "html"]);

for (const tag of tags) {
  console.log(tag);

  if (tag === "css") {
    tags.delete("javascript"); // 刪掉已經走過的元素
  }
}
// javascript
// css
// html

走到 "css" 時,第 0、1 格已經讀完,下一步要讀第 2 格:

留下 empty:[empty, "css", "html"]  → 第 2 格是 "html",照常走到
直接移除:  ["css", "html"]         → 第 2 格已超出長度,"html" 被跳過

保留位置,就是為了讓走訪中的 index 不會因為刪除而錯位。

實務上仍建議避免邊走訪邊修改同一個 Set;但就算遇到了,結果也是規格定義好的,不是 engine 各自決定的巧合。


二、資料不能重複:誰來判斷「同一個」?

Set 準備加入一個新元素時,會先檢查資料是否已有相同的 value,這個「相同」用的是規格中的 SameValueZero。今天往下多挖一層:它和 ===、Object.is() 到底差在哪?名字裡的「Zero」又是什麼意思...上次因為篇幅關係簡略帶過,又遇到了稍微來看一下:

NaN 與 -0 從哪來?先認識 IEEE 754

JavaScript 的 Number 不是自己發明的格式,而是採用 IEEE 754 雙精度浮點數。這是 1985 年訂定的國際標準,C、Java、Python 的浮點數也都遵守它。所以下面這些「怪現象」其實不是 JavaScript 獨有的:

0.1 + 0.2;    // 0.30000000000000004
NaN === NaN;  // false
0 === -0;     // true

NaN(Not-a-Number) 是 IEEE 754 用來表示「這次運算沒有有效結果」的值:

0 / 0;            // NaN
Number("hello");  // NaN
Math.sqrt(-1);    // NaN

標準規定 NaN 和任何值比較都不相等,包括它自己。原因很直覺:0 / 0 和 Math.sqrt(-1) 都是「算不出來」,但不能說它們是同一個答案。

早期 JavaScript 還沒有 Number.isNaN(),於是有人利用這個特性,寫出「只有 NaN 不等於自己」的判斷:

const isReallyNaN = (value) => value !== value;

isReallyNaN(NaN); // true

另外要小心全域的 isNaN():它會先把參數轉成數字,所以字串也可能被誤判。現代寫法請用不轉型的 Number.isNaN():

isNaN("hello");        // true:先轉成 Number("hello"),得到 NaN
Number.isNaN("hello"); // false:不轉型,只認真正的 NaN
isNaN("hello");        // true:先轉成 Number("hello"),得到 NaN
Number.isNaN("hello"); // false:不轉型,只認真正的 NaN

-0 則來自 IEEE 754 的儲存方式:數字用一個位元記錄正負號,所以 0 也有正負兩種。它保留了「從哪個方向接近 0」的資訊,平常用 === 看不出差別,但在某些運算裡會現身:

1 / 0;             // Infinity
1 / -0;            // -Infinity

Math.round(-0.4);  // -0
0 * -5;            // -0

String(-0);        // "0":轉成字串後完全看不出來
Object.is(-0, 0);  // false:只有 Object.is() 分得出來

NaN === NaN 為 false 的行為適合數值比較,卻不適合集合。若 Set 完全使用 ===,就會發生「放得進去,卻找不回來」的問題:

// 假想 Set 完全使用 === 的結果:
// set.add(NaN);
// set.has(NaN); 會是 false

三種「相同」:===、SameValueZero、Object.is()

規格裡其實有三套常見的相等判斷,差別只落在 NaN 與 -0 這兩個特例:

判斷方式 NaN 與 NaN 0 與 -0 誰在使用
===(IsStrictlyEqual) 不同 相同 ===、indexOf()
SameValueZero 相同 相同 Set、Map、includes()
SameValue 相同 不同 Object.is()

表格沒有列出 ==(寬鬆相等),因為它比較前會先轉型,例如 0 == "" 是 true;Set、Map 與 includes() 都不會使用它。

同樣是在陣列裡找 NaN,用的規則不同,結果就不同:

[NaN].indexOf(NaN);   // -1:=== 認不出 NaN
[NaN].includes(NaN);  // true:SameValueZero 認得出

Object.is(0, -0);     // false:SameValue 會區分正負零

名字裡的「Zero」就是相對於 SameValue 而言:SameValueZero 幾乎等於 SameValue,只差在把 0 與 -0 視為同一個零。也可以反過來記:它是「修好 NaN 的 ===」。

const values = new Set([NaN, NaN, 0, -0]);

console.log(values.size);
// 2

console.log(values.has(NaN));
// true

還有一個容易忽略的細節:Set 在加入元素時,規格會先把 -0 轉成 +0 再存。所以就算只放入 -0,取出來的也是 0:

const [zero] = new Set([-0]);

Object.is(zero, -0); // false
Object.is(zero, 0);  // true

一般開發不需要手動呼叫這些演算法;重點是知道 Set 不是把資料交給 === 後直接去重,而是依 > SameValueZero 判斷元素是否已存在。


三、兩個物件內容一樣,為什麼仍是兩筆?

上一節的三種規則,差異只出現在 NaN 與 -0 這類 primitive。碰到物件、陣列與 function 時,它們的判斷方式完全相同:

碰到物件、陣列與 function 時,它們的判斷方式完全相同:
只看 reference identity,也就是兩個值是不是指向同一個物件,而不是內容看起來是否相同。可以把 reference 想成物件在記憶體裡的「地址」:變數存的是地址,比較時也只比地址。

const a = { id: 1 };

({ id: 1 }) === ({ id: 1 });         // false
Object.is({ id: 1 }, { id: 1 });     // false
new Set([{ id: 1 }]).has({ id: 1 }); // false

Object.is(a, a);                     // true:同一個 reference

換句話說,Object.is() 並不是「比較物件內容」的工具;JavaScript 沒有內建的 deep equality。Set 用的 SameValueZero 也一樣。

可以把它想成兩張內容完全一樣的表單。文字相同不代表它們是同一張紙;在記憶體裡建立兩個物件,也會得到兩個不同 reference。

const first = { id: 1, name: "Rafael" };
const second = { id: 1, name: "Rafael" };

const users = new Set([first, second]);

console.log(users.size);
// 2

console.log(users.has({ id: 1, name: "Rafael" }));
// false

console.log(users.has(first));
// true

first 與 second 內容相同,但不是同一個物件;而臨時建立的 { id: 1, name: "Rafael" } 又是第三個新物件。Set 不會逐欄位做 deep equality。

到這裡,Set 的基本規則已經齊了:

primitive value
→ 依 SameValueZero 比較

object / array / function
→ 比較是不是同一個 reference


四、從一個 Set 走到兩個 Set:兩者如何比較?

知道 Set 怎麼認出相同元素後,下一個問題是:兩組 Set 要怎麼比較?

假設留言功能需要 read、comment 權限;使用者目前只有 read、export:

const requiredPermissions = new Set(["read", "comment"]);
const userPermissions = new Set(["read", "export"]);

要找出「功能需要,但使用者沒有」的權限,以前常用展開、filter() 與 has() 組裝:

const missingPermissions = new Set(
  [...requiredPermissions].filter(
    (permission) => !userPermissions.has(permission),
  ),
);

console.log([...missingPermissions]);
// ["comment"]

這段程式沒有錯,但真正想說的其實是:

從必要權限中,扣掉使用者已有的權限

專業名詞稱作「差集」,過去 JavaScript Set 沒有原生方法,所以我們只能自己把這個概念翻譯成迴圈。


五、ES2025:不用再把集合概念用迴圈寫

ES2025 將常見集合運算正式加入 Set.prototype。先準備兩組標籤:

const selectedTags = new Set(["javascript", "css"]);
const recommendedTags = new Set(["css", "accessibility"]);

接著不要急著背「聯集、交集」這些名詞,先看每個方法回答什麼問題。

union():兩邊出現過的全部都要

const allTags = selectedTags.union(recommendedTags);

console.log([...allTags]);
// ["javascript", "css", "accessibility"]

"css" 兩邊都有,但 Set 不會重複保存,所以結果只出現一次。這裡使用的「相同」仍是前面介紹的 SameValueZero。

intersection():只留下兩邊都有的

const commonTags = selectedTags.intersection(recommendedTags);

console.log([...commonTags]);
// ["css"]

例如兩套篩選器只想顯示共同支援的選項,就很適合用交集。

difference():我有,但你沒有的

const onlySelected = selectedTags.difference(recommendedTags);

console.log([...onlySelected]);
// ["javascript"]

順序很重要:

selectedTags.difference(recommendedTags);
// selectedTags 有、recommendedTags 沒有的

recommendedTags.difference(selectedTags);
// recommendedTags 有、selectedTags 沒有的

這也是前面權限案例真正需要的方法。

symmetricDifference():只留下兩邊不一致的

const differentTags = selectedTags.symmetricDifference(recommendedTags);

console.log([...differentTags]);
// ["javascript", "accessibility"]

兩邊都有的 "css" 被排除,只留下只有其中一邊擁有的項目。這很適合用來比較「儲存前」與「儲存後」有哪些設定改變。

可以用四句話記住:

方法 白話意思
union() 兩邊全部都要
intersection() 只要兩邊共同的
difference() 我有、你沒有的
symmetricDifference() 只要兩邊不一樣的

這四個方法會建立新的 Set,不會修改原本資料:

console.log([...selectedTags]);
// ["javascript", "css"]

使用前先確認執行環境

Set methods 是 ES2025 才進入標準的 API,需要 Chrome/Edge 122、Firefox 127、Safari 17、Node.js 22 以上的版本。


六、如果只想知道「夠不夠」,不用先算出差集

回到權限案例:

const requiredPermissions = new Set(["read", "comment"]);
const userPermissions = new Set(["read", "export"]);

const missingPermissions = requiredPermissions.difference(userPermissions);

console.log([...missingPermissions]);
// ["comment"]

difference() 適合列出缺少哪些權限。但有時畫面只需要一個 boolean,決定按鈕能不能顯示。

以前要回答「使用者的權限夠不夠」,通常會這樣寫:

const canUseCommentFeature = [...requiredPermissions].every((permission) =>
  userPermissions.has(permission),
);
// false

ES2025 也提供三個關係判斷方法,讓這個問題可以直接問出口:

const canUseCommentFeature = requiredPermissions.isSubsetOf(userPermissions);
// false:使用者尚未具備所有必要權限

userPermissions.isSupersetOf(requiredPermissions);
// false:同一件事,從相反方向問

userPermissions.isDisjointFrom(new Set(["admin"]));
// true:使用者沒有任何 admin 權限

先不要背 subset、superset 的中文名稱,直接讀它問的問題:

方法 白話問題
A.isSubsetOf(B) A 的每個元素,B 都有嗎?
A.isSupersetOf(B) B 的每個元素,A 都有嗎?
A.isDisjointFrom(B) A 與 B 完全沒有共同元素嗎?

於是權限判斷不再需要藏在 every() 與 has() 裡,而能直接表達意圖:

if (canUseCommentFeature) {
  showCommentButton();
}

七、一些整理 Set 發現的小知識和疑問

什麼是 set-like?為什麼不能直接傳 Array?

前面的例子都在比較兩個真正的 Set:

selectedTags.union(recommendedTags);

但 ES2025 規格沒有要求右邊的 other 一定是 new Set()。它可以是 set-like object:不是原生 Set,卻具備一個集合進行比較時需要的能力。

TC39 規格會透過 GetSetRecord 讀取三個成員:

size        → 這組資料有幾個元素
has(value)  → 某個元素是否存在
keys()      → 逐一提供集合裡的元素

只要右邊的物件提供這三種能力,就能參與 union()、intersection()、difference() 與其他 Set 比較。

有趣的是,Map 本身就是 set-like:

const selectedPermissions = new Set(["read"]);

const permissionConfig = new Map([
  ["read", { label: "讀取" }],
  ["comment", { label: "留言" }],
]);

const allPermissions = selectedPermissions.union(permissionConfig);

console.log([...allPermissions]);
// ["read", "comment"]

因為 Map 也有 size、has(key) 與 keys()。在這個例子裡,Map 的 key 被視為集合元素,value 不參與比較。

以 union() 來說,概念流程是:先複製左側 Set,再逐一讀取 permissionConfig.keys()。

左側先放入 "read"

讀到 Map key "read"
→ 已存在,不重複加入

讀到 Map key "comment"
→ 尚未存在,加入結果

因此結果只有權限名稱:

console.log([...allPermissions]);
// ["read", "comment"]

Map 裡的 { label: "讀取" }、{ label: "留言" } 是 value,不會進入 union 結果。


左右對調,會比較快或比較慢嗎?

先說結論:不需要為了效能刻意調整左右順序。

想像你要找出兩個班級裡,同時參加籃球社的同學。聰明的做法是拿人數少的那份名單,一個一個去問人數多的那班「有沒有這個人」;要問幾次,只跟人數少的那份有關。

intersection() 就是這樣做的:它會先比較兩邊的 size,自動挑比較小的那邊來走訪,再用另一邊的 has() 檢查。

const commonPermissions = selectedPermissions.intersection(permissionConfig);
// 左邊 1 個、右邊 2 個 → 走訪左邊,只要問一次 permissionConfig.has("read")

console.log([...commonPermissions]);
// ["read"]

所以 A.intersection(B) 和 B.intersection(A) 的工作量差不多,規格已經幫你挑好了。difference() 等方法也有類似的設計。這也說明了 set-like 為什麼要同時提供三個成員:size 用來比大小、keys() 用來走訪、has() 用來檢查。

左右順序真正會影響的,是下面這三件事:

左右對調會影響什麼 說明
答案的意思 A.difference(B) 是「A 有、B 沒有」,對調後問的是另一個問題
誰必須是真正的 Set 左邊一定要是 Set;右邊只要是 set-like 就好,例如 Map
intersection() 結果的順序 見下面的陷阱

陷阱:intersection() 的結果順序不一定跟著左邊

自動挑方向雖然省事,卻有一個副作用:結果的順序會跟著「被走訪的那一邊」。

這裡的「走訪」,就是把一邊的元素照加入順序一個一個拿出來檢查。intersection() 的完整流程是:

  1. 比較兩邊的 size,挑比較小的那一邊來走訪(一樣大就挑左邊)
  2. 照那一邊的加入順序,一個一個拿出元素
  3. 問另一邊 has() 有沒有這個元素;有,就放進結果

結果 Set 依「放進去的先後」排列,所以最後的順序,就是被走訪那一邊的順序。來看一個例子:

const allSteps = new Set([1, 2, 3, 4]);
const pickedSteps = new Set([4, 3]);

console.log([...allSteps.intersection(pickedSteps)]);
// [4, 3]

直覺上會以為結果照左邊 allSteps 的順序排成 [3, 4]。但左邊有 4 個、右邊只有 2 個,所以走訪的是右邊的 pickedSteps:

步驟 從 pickedSteps 拿出 問 allSteps.has() 結果
1 4 true [4]
2 3 true [4, 3]

換幾種寫法比較,就能看出規則:

寫法 兩邊大小 走訪哪一邊 結果
allSteps.intersection(pickedSteps) 4 比 2 右邊 pickedSteps [4, 3]
pickedSteps.intersection(allSteps) 2 比 4 左邊 pickedSteps [4, 3]
new Set([1, 2]).intersection(new Set([2, 1])) 一樣大 左邊 [1, 2]

一句話記住:intersection() 的結果順序跟著比較小的那一邊;一樣大時才跟著左邊。

如果畫面依賴特定順序,例如步驟條或排序過的選項,就不要依賴 intersection() 的迭代順序,改從來源資料重新篩選:

const orderedSteps = [...allSteps].filter((step) => pickedSteps.has(step));
// [3, 4]

Array 明明可迭代,為什麼不算 set-like?

selectedPermissions.union(["comment", "export"]);
// TypeError

Array 可以被 for...of 迭代,卻沒有 Set methods 要求的介面:

Set                    Array

size                   length
has(value)             沒有;includes() 名稱與介面不同
keys() 產生元素         keys() 產生索引 0、1、2……
元素不重複              可以包含重複元素

這也說明 iterable 和 set-like 是兩種不同能力:

iterable
→ 我可以一筆一筆把資料交給你

set-like
→ 我還能告訴你總數,以及某個值是否存在

若只接受任意 iterable,就沒有 size 與 has() 這些集合能力;Array 也可能包含重複資料,和 Set 的唯一值語意不同。

因此要讓 Array 參與比較,先明確轉成 Set:

const extraPermissions = ["comment", "export", "comment"];

const allPermissions = selectedPermissions.union(
  new Set(extraPermissions),
);

console.log([...allPermissions]);
// ["read", "comment", "export"]

可以把 new Set(array) 看成一次明確的資料正規化:去除重複值,並補上 size、has()、keys() 這組集合介面。


八、Set 不知道哪個物件欄位代表唯一

回到文章開頭,Set 沒有把兩個 { id: 1 } 合併,不是它失效,而是我們沒有告訴它「唯一性來自 id」。

Set 很適合直接保存:

id、權限名稱、標籤、功能開關、已選選項

但若唯一性實際上由物件中的欄位決定,先抽出欄位:

const uniqueProductIds = new Set(
  products.map((product) => product.id),
);

若你還需要依 id 找回完整物件,則 Map 通常更貼近需求:

const productById = new Map(
  products.map((product) => [product.id, product]),
);

Set 負責判斷 value 是否相同,不負責替應用程式定義「哪個欄位代表同一筆資料」。先釐清資料模型,才不會因為「Set 可以去重」就把所有物件都丟進去。


今日總結

我們從「兩個內容一樣的物件為什麼沒有被去重」出發,沿著規格得到答案:Set 的集合運算不是另一套神奇 API,而是建立在既有的相等規則上。

觀念 一句話記住
[[SetData]] 規格描述 Set 的模型:依加入順序保存,刪除只標記 empty,走訪途中修改也有明確結果
SameValueZero 判斷 primitive 是否相同:NaN 等於 NaN,0 等於 -0
reference identity 物件只看是不是同一個 reference;內容一樣也算兩筆,想依 id 去重要先抽出 id
set-like 集合運算的另一邊要有 size、has()、keys():Map 可以直接用,Array 要先轉成 Set

理解這些規則後,ES2025 的 API 就很好選,直接看你想回答什麼問題:

想回答的問題 方法 回傳
兩組合起來有哪些? A.union(B) 新的 Set
兩組共同有哪些? A.intersection(B) 新的 Set(順序可能跟著較小的一邊)
A 有、B 沒有的是哪些? A.difference(B) 新的 Set
兩組不一樣的是哪些? A.symmetricDifference(B) 新的 Set
A 的每一項,B 都有嗎? A.isSubsetOf(B) boolean
B 的每一項,A 都有嗎? A.isSupersetOf(B) boolean
A 和 B 完全沒有共同項嗎? A.isDisjointFrom(B) boolean

參考資料


上一篇
Day 21|Object.groupBy:把扁平資料分組成前端可用的結構
系列文
30 天新世代 JavaScript 自我學習指南 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言