Object.groupBy 分組排錯:hasOwnProperty、key 合併與 Map 選擇
本文目錄
Object.groupBy() 已經把資料分好,接著呼叫 groups.hasOwnProperty('open') 卻出錯。這不是分組失敗:它回傳沒有 prototype 的物件,要用 Object.hasOwn(groups, 'open') 檢查群組是否存在。 若分類還涉及數字/字串區別或物件本身,則要先決定是否改用 Map.groupBy()。
下面用任務清單檢查三件事:分類 key 是否相同、每組拿到什麼資料,以及後續修改是否會影響原陣列。範例於 Node.js v24.13.0 執行;瀏覽器支援和框架的更新機制仍須在目標環境另驗。
Object.groupBy 為什麼沒有 hasOwnProperty?
ECMAScript 規範指定回傳物件以 null 為 prototype,因此不繼承 Object.prototype。這也讓 toString、constructor 等名稱能成為自己的群組 key。不要為了叫回方法,就把 prototype 接回去。
以下整段可存為 .mjs,用 node 檔名.mjs 執行;後面的區塊也可各自執行。
import assert from 'node:assert/strict';
const tasks = [ { id: 1, state: 'open' }, { id: 2, state: 'toString' }, { id: 3, state: '__proto__' },];const groups = Object.groupBy(tasks, task => task.state);
assert.equal(Object.getPrototypeOf(groups), null);assert.equal(typeof groups.hasOwnProperty, 'undefined');assert.equal(Object.hasOwn(groups, 'open'), true);assert.equal(Object.hasOwn(groups, 'closed'), false);assert.deepEqual(groups.toString.map(task => task.id), [2]);assert.deepEqual(groups.__proto__.map(task => task.id), [3]);assert.deepEqual(Object.groupBy([], task => task.state), Object.create(null));console.log('null prototype: PASS');MDN 的 Object.hasOwn 說明也涵蓋這種物件,以及 key 恰好叫 hasOwnProperty 的情況。讀取清單時可用 groups.open ?? [];檢查 key 是否存在則用 Object.hasOwn(),兩個問題不要混為一談。
數字 1 與字串 ‘1’ 是否應該同組?
Object.groupBy() 會把 callback 結果轉成 property key;字串與 Symbol 以外的值會經轉型。因此數字 1 和字串 '1' 在這裡落入同一組。Map.groupBy() 保留 key 型別,可把它們分開;物件 key 則依身分判定,不會比較欄位內容。
import assert from 'node:assert/strict';
const rows = [{ key: 1 }, { key: '1' }];const objectGroups = Object.groupBy(rows, row => row.key);const mapGroups = Map.groupBy(rows, row => row.key);assert.equal(objectGroups['1'].length, 2);assert.equal(mapGroups.size, 2);assert.equal(mapGroups.get(1).length, 1);assert.equal(mapGroups.get('1').length, 1);
const teamA = { id: 'team-a' };const teamACopy = { id: 'team-a' };const jobs = [{ team: teamA }, { team: teamACopy }, { team: teamA }];const byIdentity = Map.groupBy(jobs, job => job.team);assert.equal(byIdentity.size, 2);assert.equal(byIdentity.get(teamA).length, 2);assert.equal(byIdentity.get({ id: 'team-a' }), undefined);
// 產品若以 id 定義同一團隊,就明確用 id 分組。const byId = Object.groupBy(jobs, job => job.team.id);assert.equal(byId['team-a'].length, 3);console.log('key contract: PASS');選擇方式取決於產品資料:後端已定義穩定字串 ID,就按 ID 分組;必須辨認同一個物件實例,才保留物件作為 Map key。物件經 JSON 傳輸或重新建立後,即使欄位相同也不是原來的 key。這個限制可對照 MDN Map.groupBy。
不要用 JSON 字串化任意物件來假裝深度相等;若需要跨請求保留身分,應由資料契約提供穩定 ID。
改群組內的任務,原陣列也會變
分組會建立新的群組容器與陣列,但其中的物件仍是原參照。這使分類畫面能讀取同一份資料,也意味著「在群組裡改狀態」會改到原始物件;群組本身不會自動重新計算。
import assert from 'node:assert/strict';
const tasks = [{ id: 1, state: 'open', detail: { count: 1 } }];const groups = Object.groupBy(tasks, task => task.state);assert.equal(groups.open[0], tasks[0]);
groups.open[0].state = 'closed';assert.equal(tasks[0].state, 'closed');assert.equal(groups.open.length, 1); // 舊分組仍在assert.equal(Object.hasOwn(groups, 'closed'), false);
const regrouped = Object.groupBy(tasks, task => task.state);assert.equal(regrouped.closed[0], tasks[0]);
// 只改畫面上的狀態,可先建立新物件;這是淺拷貝。const viewRows = tasks.map(task => ({ ...task }));viewRows[0].state = 'open';assert.equal(tasks[0].state, 'closed');assert.equal(viewRows[0].detail, tasks[0].detail);console.log('shared references: PASS');如果編輯需求包含巢狀資料,淺拷貝並不夠;要依資料型別選擇複製策略,可接著看 structuredClone 的 DataCloneError 與資料邊界。在 React、Vue 或 Svelte 中,還要把重新分組接到專案實際使用的狀態更新流程,不能期待這份普通物件自行維持分類。
Object.groupBy 分組後,數字 key 為什麼換了順序?
分組內容正確,畫面卻把先出現的「10」排在「2」後面,先查渲染是否使用 Object.keys()/Object.entries()。依 OrdinaryOwnPropertyKeys 規範,一般物件的 array-index key 先按數值遞增列出,接著才是其他字串 key 的建立順序。這不會因為物件沒有 prototype 就消失。
不是所有看起來像數字的字串都屬於 array index:'01'、'-1' 與 '4294967295' 都不在此類。不要用「數字 key 全都排序」概括它。若產品要求分類按首次出現順序顯示,可讓字串 ID 留在 Map.groupBy(),並直接迭代 Map;每組內的資料仍依輸入次序。
以下區塊可獨立存成 .mjs 執行,對照物件列舉、Map 順序與 JSON 傳輸:
import assert from 'node:assert/strict';
const rows = [ { id: 'a', bucket: '10' }, { id: 'b', bucket: '2' }, { id: 'c', bucket: '10' }, { id: 'd', bucket: '01' },];const objectGroups = Object.groupBy(rows, row => row.bucket);assert.deepEqual(Object.keys(objectGroups), ['2', '10', '01']);assert.deepEqual(objectGroups['10'].map(row => row.id), ['a', 'c']);
const mapGroups = Map.groupBy(rows, row => row.bucket);assert.deepEqual([...mapGroups.keys()], ['10', '2', '01']);// 再轉回 Object,列舉順序仍會改變。assert.deepEqual(Object.keys(Object.fromEntries(mapGroups)), ['2', '10', '01']);// Map 的 entries 不會由 JSON.stringify 自動輸出。assert.equal(JSON.stringify(mapGroups), '{}');const wire = [...mapGroups].map(([key, items]) => ({ key, ids: items.map(row => row.id),}));const restored = JSON.parse(JSON.stringify(wire));assert.deepEqual(restored.map(group => group.key), ['10', '2', '01']);assert.deepEqual(restored[0].ids, ['a', 'c']);
const boundaries = Object.groupBy( ['4294967295', '-1', '01', '10', '2', '0'], value => value,);assert.deepEqual(Object.keys(boundaries), ['0', '2', '10', '4294967295', '-1', '01']);console.log('group enumeration and wire order: PASS');有順序的 JSON 請明確使用陣列契約;上例只傳字串 key 與 ID,不是任意 Map key 的無損序列化方案。若分類有固定業務順序,例如待處理、進行中、完成,則建立明確的分類順序清單,再依 key 取群組,不能讓 API 回傳的首次出現順序決定產品排序。需再排序組內資料時,可接著看 sort comparator 與穩定排序。
這份新增斷言於 2026-10-30 由維護流程在 macOS、Node.js v24.13.0 執行;沒有測試框架渲染、真實 API 或其他瀏覽器。
上線前確認執行環境,不只看編譯成功
先檢查實際執行端的 typeof Object.groupBy === 'function',若也使用 Map 版本則分別檢查 Map.groupBy。TypeScript 型別存在、建置成功,都不會自動替舊瀏覽器補上 API;依專案支援範圍選擇 polyfill 或既有分組實作,並重跑相同斷言。不要在 prototype 上自行補同名方法。
如果原需求只是累計每個分類的數量,不需要保存每筆資料,可維持既有累計寫法;改用 groupBy 並不是必要升級。reduce 空陣列的初始值處理則適合接續檢查累計流程。這篇沒有做效能基準,不用語法較新來推論速度較快。
Symbol 分組看得到,轉 JSON 卻不見?
Object.groupBy() 可以保留 Symbol key,但 Object.keys()/Object.entries() 只列舉字串 key;JSON.stringify也會忽略 Symbol key。只有 Symbol 群組時,序列化結果甚至是 {}。要檢查完整分組,可用 Reflect.ownKeys;跨 JSON 傳輸則先轉成有明確契約的字串 ID。
以下是 2026-11-07 新增並在 Node.js v24.13.0 執行的獨立反例,包含兩個 description 相同但身分不同的 Symbol:
import assert from 'node:assert/strict';
const localA = Symbol('team');const localB = Symbol('team');const rows = [{ id: 1, team: localA }, { id: 2, team: localB }];const groups = Object.groupBy(rows, row => row.team);assert.equal(Reflect.ownKeys(groups).length, 2);assert.deepEqual(Object.keys(groups), []);assert.deepEqual(Object.entries(groups), []);assert.equal(JSON.stringify(groups), '{}');assert.equal(groups[localA][0].id, 1);assert.equal(groups[localB][0].id, 2);assert.equal(localA.description, localB.description);assert.notEqual(localA, localB);
// 應用契約提供各群組的穩定 ID;不要用 description 代替身分。const ids = new Map([[localA, 'team-a'], [localB, 'team-b']]);const payload = Reflect.ownKeys(groups).map(key => { const id = ids.get(key); if (typeof id !== 'string') throw new TypeError('群組缺少傳輸 ID'); return { id, rows: groups[key] };});// 每筆 row 也轉成真正要傳輸的欄位,避免 Symbol 值被省略。const wire = payload.map(group => ({ id: group.id, rows: group.rows.map(row => ({ id: row.id })),}));assert.deepEqual(JSON.parse(JSON.stringify(wire)), [ { id: 'team-a', rows: [{ id: 1 }] }, { id: 'team-b', rows: [{ id: 2 }] },]);console.log('Symbol 分組與 JSON 傳輸:PASS');轉成字串 ID 的映射由應用程式定義,接收端若需要重建分類,直接依這個 ID 操作。任意 String(symbol) 或 symbol.description 都可能把不同群組合併;測試特別以同名 Symbol 保留這個失敗邊界。若分類一開始就要跨請求保存,讓來源資料提供穩定字串 ID,可省去額外映射。
參考資料: