JavaScript Object.groupBy 的键转换:两个不同对象,为什么被分进同一组
按owner对象给任务分组,回调返回了两个不同的owner,Object.groupBy却只产出一组。原因是回调返回值会转换成对象属性键:两个普通对象在本例都变成同一个字符串[object Object],区别在分组之前就丢了。
示例要求运行环境支持Object.groupBy与Map.groupBy,已在Node.js v24.19.0实跑,并在开头断言两者存在。保存为demo.mjs,运行node demo.mjs;只分组三条固定记录,旧运行时若缺少API应先使用支持它的环境。
AI模型生成概念图:左侧不同钥匙换成相同标签后汇入一个托盘,右侧保留钥匙身份而进入不同托盘;是键转换比喻,不是内存或接口截图。
完整实验程序
两个owner的name都叫same,但它们是两个对象实例。三条记录按第一个、第二个、第一个owner排列,因此可以同时检查类型转换造成的合并、对象身份分组,以及同组成员的原始先后。
完整可运行程序
import assert from 'node:assert/strict';
assert.equal(typeof Object.groupBy, 'function');
assert.equal(typeof Map.groupBy, 'function');
const firstOwner = { name: 'same' };
const secondOwner = { name: 'same' };
const rows = [
{ id: 1, owner: firstOwner },
{ id: 2, owner: secondOwner },
{ id: 3, owner: firstOwner }
];
const objectGroups = Object.groupBy(rows, row => row.owner);
assert.deepEqual(Object.keys(objectGroups), ['[object Object]']);
assert.deepEqual(objectGroups['[object Object]'].map(row => row.id), [1, 2, 3]);
console.log('Object.groupBy ids:', JSON.stringify(objectGroups['[object Object]'].map(row => row.id)));
const mapGroups = Map.groupBy(rows, row => row.owner);
assert.equal(mapGroups.size, 2);
assert.deepEqual(mapGroups.get(firstOwner).map(row => row.id), [1, 3]);
assert.deepEqual(mapGroups.get(secondOwner).map(row => row.id), [2]);
console.log('Map.groupBy ids:', JSON.stringify([...mapGroups.values()].map(group => group.map(row => row.id))));
const mixed = Object.groupBy([1, '1'], value => value);
assert.equal(mixed['1'].length, 2);
assert.equal(Map.groupBy([1, '1'], value => value).size, 2);
console.log('number and string:', 'objectGroups=' + Object.keys(mixed).length, 'mapGroups=2');
const special = Object.groupBy(['x', 'y'], () => '__proto__');
assert.equal(Object.getPrototypeOf(special), null);
assert.equal(Object.hasOwn(special, '__proto__'), true);
assert.deepEqual(special.__proto__, ['x', 'y']);
assert.equal(special.hasOwnProperty, undefined);
console.log('special name:', 'nullPrototype=true ownGroup=true');
rows[0].id = 99;
assert.equal(mapGroups.get(firstOwner)[0].id, 99);
assert.equal(objectGroups['[object Object]'][0].id, 99);
console.log('group members share input objects:', 99);本次实际输出
Object.groupBy ids: [1,2,3] Map.groupBy ids: [[1,3],[2]] number and string: objectGroups=1 mapGroups=2 special name: nullPrototype=true ownGroup=true group members share input objects: 99
转成相同属性键,就会进入同一数组
第一行Object.groupBy ids是[1,2,3],属性名断言只有[object Object]。这个结果不是对owner.name做了结构比较,而是两个普通对象转换成了相同的属性键。若对象自己定义了转换行为,还会出现对应的额外规则与副作用。
第二行Map.groupBy ids变为[[1,3],[2]]。回调仍然返回owner,但Map分组保留了作为键的值,两个不同对象实例因此形成两组。选择哪一种,应看业务想按可读标签分组,还是按某个运行期实例分组,不能只比较返回容器看起来是否方便。
数字与字符串也可能在属性键处汇合
第三行number and string显示对象分组只有一组,Map分组有两组。数字一与字符串一进入Object.groupBy后都对应属性名"1";Map则保留这两种键的区别。只用一种输入类型测试,容易遗漏混合来源的数据合并。
若业务规定两者就是同一个编号,可以先显式规范化,再选择所需容器;若它们代表不同类别,就必须保留类型差异。需要跨请求稳定分组时,也不宜直接依赖每次反序列化新建的对象身份,应该由明确的业务标识决定键。
返回对象没有Object.prototype
special name验证组名__proto__成为普通自有属性,内容是['x','y'],结果对象的原型则为null。Object.groupBy建立的是空原型对象,因此这个组名没有调用普通对象继承来的原型setter。
同一个设计也使本例结果没有继承hasOwnProperty方法。检查分组是否存在时,使用Object.hasOwn(result, key)这类静态方法更合适,不要假定任何分组结果都能调用自身的hasOwnProperty。空原型只说明这项对象结构,不能替其他拼接、输出或权限规则提供安全保证。
新建分组数组不等于复制成员对象
最后把rows[0].id改为99,两种分组里对应的成员都读到99。groupBy创建新的组容器,却把原元素放进这些数组,所以记录本身仍与输入共享。若分组结果只用于只读展示,这可能很自然;若要分别编辑副本,就应另外安排复制边界。
分组也不是持续更新的索引。程序完成分组后,再改变记录的owner字段,不会自动把它从旧组搬到新组;需要重新分组或明确维护变化。本文只通过id修改验证引用共享,没有把分组结果当作响应式订阅。
本例使用普通对象与字符串键。Symbol键可以保留为Symbol属性,但Object.keys不会列出Symbol,因此不能把本例的Object.keys统计推广为任意键类型的完整组数统计。先约定键域,再验收分组数量与每组成员,才能让结果解释保持一致。
参考资料
官方资料核验日期:2026年10月2日。上述输出来自本文完整程序的本地执行,断言全部通过,退出状态为零。


