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 時,直接使用它並驗證型別,較容易說清楚哪些資料應該合併。
套用到跨頁清單時,須定義全域去重與排序政策,並驗收跨頁結果。
參考資料: