1096 字
5 分鐘

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 中,還要把重新分組接到專案實際使用的狀態更新流程,不能期待這份普通物件自行維持分類。

上線前確認執行環境,不只看編譯成功#

先檢查實際執行端的 typeof Object.groupBy === 'function',若也使用 Map 版本則分別檢查 Map.groupBy。TypeScript 型別存在、建置成功,都不會自動替舊瀏覽器補上 API;依專案支援範圍選擇 polyfill 或既有分組實作,並重跑相同斷言。不要在 prototype 上自行補同名方法。

如果原需求只是累計每個分類的數量,不需要保存每筆資料,可維持既有累計寫法;改用 groupBy 並不是必要升級。reduce 空陣列的初始值處理則適合接續檢查累計流程。這篇沒有做效能基準,不用語法較新來推論速度較快。

參考資料:

ECMAScript:Object.groupBy

MDN:Object.groupBy

MDN:Object.hasOwn

MDN:Map.groupBy

Object.groupBy 分組排錯:hasOwnProperty、key 合併與 Map 選擇
https://laplusda.com/posts/javascript-object-groupby-map-key-pitfalls/
作者
Zero
發佈於
2026-10-24
許可協議
CC BY-NC-SA 4.0