941 字
5 分鐘

JavaScript Set 無法去除重複物件?依 ID 去重與保留順序

[...new Set(rows)] 沒有移除內容相同的兩筆物件,原因是 Set 對物件比較身分,不會逐欄比較內容。如果商品清單的重複定義是 id 相同,就應拿 ID 當 key,並先決定重複時保留哪一筆。

同一個物件與內容相同,是不同條件#

MDN 的 Set 說明以 SameValueZero 比較值;對物件而言,重點是同一個參考。本文兩組程式由維護流程於 2026-11-03 在 macOS、Node.js v24.13.0 執行,驗證範圍為獨立 JavaScript 範例。以下可獨立存成 .mjs,用 Node.js 執行:

import assert from 'node:assert/strict';
const first = { id: 'a', name: '舊名稱' };
const copied = { id: 'a', name: '舊名稱' };
assert.equal(new Set([first, first]).size, 1);
assert.equal(new Set([first, copied]).size, 2);
assert.equal(new Set(['a', 'a']).size, 1);
assert.equal(new Set([1, '1']).size, 2);
console.log('物件身分與 key 型別檢查通過');

從 API 解析出來的不同物件,即使欄位相同,也不會因此變成同一個物件。深複製再放進 Set 只會建立更多不同身分;這與 structuredClone 後的 Map key有相同的身分邊界,但這裡要解決的是清單的業務去重。

第一筆與最後一筆,要連順序一起定義#

下面限定輸入為密集陣列,每筆有自己的非空字串 id;getter、Proxy 與效能需另行驗證。'1' 和 '01' 是不同識別碼,不自動轉數字;缺少 ID 直接拒絕,避免多筆無效資料都被合成同一個 undefined key。

重複時提供兩種政策:first 保留第一筆;last 保留最後一筆的內容,位置仍依這個 ID 首次出現的順序。Map 對既有 key 設新值不會將它移到最後,規則可對照 MDN Map 文件。

import assert from 'node:assert/strict';
function uniqueById(rows, keep = 'first') {
if (!Array.isArray(rows) || !['first', 'last'].includes(keep)) {
throw new TypeError('需要陣列及明確的保留政策');
}
const byId = new Map();
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (!Object.hasOwn(rows, i) || row === null || typeof row !== 'object' ||
!Object.hasOwn(row, 'id') || typeof row.id !== 'string' || row.id === '') {
throw new TypeError('每筆資料需要自己的非空字串 id');
}
if (keep === 'last' || !byId.has(row.id)) byId.set(row.id, row);
}
return [...byId.values()];
}
const oldA = { id: 'a', name: '舊名稱' };
const b = { id: 'b', name: '第二筆' };
const newA = { id: 'a', name: '新名稱' };
const rows = [oldA, b, newA];
const before = [...rows];
assert.deepEqual(uniqueById(rows), [oldA, b]);
assert.deepEqual(uniqueById(rows, 'last'), [newA, b]);
assert.deepEqual(rows, before);
assert.notEqual(uniqueById(rows), rows);
assert.equal(uniqueById(rows)[0], oldA);
assert.deepEqual(uniqueById([]), []);
assert.equal(uniqueById([{ id: '1' }, { id: '01' }]).length, 2);
for (const bad of [[{}], [{ id: '' }], [{ id: 1 }], [null], new Array(1)]) {
assert.throws(() => uniqueById(bad), TypeError);
}
assert.throws(() => uniqueById(rows, 'merge'), TypeError);
console.log('去重政策、順序與輸入邊界檢查通過');

若產品要「最後一次出現的位置」順序,則需要另一個契約,例如更新 Map 前先刪除舊 key,並以輸出結果確認順序。本文的 last 依輸入順序選擇最後一筆。要按 updatedAt 判斷,須先定義時間有效性和同時刻的處理方式。

去重不會合併欄位,也沒有深複製#

若最後一筆只有 { id: 'a', name: '新名稱' },舊筆的其他欄位不會自動補回。這個操作選擇保留整個物件。需要合併時,先定義缺欄、undefined、null 的意思,可參考 JSON 欄位更新契約。

輸出雖是新陣列,仍共用原物件,上面的 identity assertion 特別確認這點。若後續編輯要隔離原資料,應在編輯邊界依資料結構另做複製,完成狀態隔離。

用 JSON.stringify() 當 key,會把欄位序列化規則帶進「相同資料」的定義。已有穩定 ID 時,直接使用它並驗證型別,較容易說清楚哪些資料應該合併。

套用到跨頁清單時,須定義全域去重與排序政策,並驗收跨頁結果。

參考資料:

MDN:Set

MDN:Map

JavaScript Set 無法去除重複物件?依 ID 去重與保留順序
https://laplusda.com/posts/javascript-set-deduplicate-objects-by-id/
作者
Zero
發佈於
2026-11-03
許可協議
CC BY-NC-SA 4.0