core-js 中的 Array Grouping 提案:Object.groupBy 与 Map.groupBy 的完整解析
【免费下载链接】core-jsStandard Library项目地址: https://gitcode.com/GitHub_Trending/co/core-js
导读
Array Grouping 提案为 JavaScript 引入了按回调函数返回值对可迭代对象进行分组的原生能力,其最终形态落地为Object.groupBy与Map.groupBy两个静态方法,而提案早期版本中的Array.prototype.groupBy/groupByToMap以及 TypedArray 版本则作为已废弃入口保留在esnext命名空间中。本文以仓库文档 docs/web/docs/features/proposals/array-grouping.md 为核心骨架,结合 core-js 的源码实现、稳定版入口与单元测试,完整讲解这两个 API 的签名、行为差异、底层实现原理以及在不同阶段入口下的使用方式,帮助你准确地在旧引擎中落地分组逻辑。
提案背景:从 v1 原型方法到 v2 静态方法的演进
Array Grouping 提案(TC39proposal-array-grouping)在演进过程中经历了两次 API 形态的重大调整:
- v1(早期草案):在
Array.prototype上添加groupBy与groupByToMap实例方法,并为 TypedArray 添加groupBy。该阶段 API 最终被废弃,Array.prototype.groupBy等并未进入 ECMA-262 标准。 - v2(最终形态):改为
Object.groupBy与Map.groupBy两个静态方法,已写入 ECMA-262,属于标准 ES 特性。两者的核心区别在于返回结构:Object.groupBy返回普通对象,Map.groupBy返回Map实例。
core-js 对这两个阶段都有支持:最终标准版实现在es/stable/actual/full命名空间下以Object.groupBy、Map.groupBy形式提供(如 stable/object/group-by.js、stable/map/group-by.js),而废弃的 v1 原型方法则继续保留在esnext命名空间中,方便需要兼容旧 API 的实验性代码。
内置签名(Built-ins signatures)
按关联文档给出的 TypeScript 签名,两个方法的参数与返回结构如下:
class Object { static groupBy(items: Iterable, callbackfn: (value: any, index: number) => key): { [key]: Array<mixed> }; } class Map { static groupBy(items: Iterable, callbackfn: (value: any, index: number) => key): Map<key, Array<mixed>>; }两个方法都接收两个参数:
| 参数 | 类型 | 说明 |
|---|---|---|
items | Iterable | 任意可迭代对象(数组、字符串、Set、Map、生成器等),也可以是类数组对象 |
callbackfn | (value, index) => key | 对每个元素执行的分组回调,接收元素值与递增下标,返回该元素所属的分组键 |
返回值差异是理解提案的关键:
Object.groupBy返回一个以null为原型的普通对象(其原型为null,避免hasOwnProperty、toString等继承属性干扰分组结果),所有键会被隐式转换为字符串(Symbol 键在转换时使用其描述文本)。Map.groupBy返回一个Map实例,键保持原始类型与引用相等性,因此不会发生键的类型塌缩——例如数字1与字符串'1'会被视为不同分组,对象与 Symbol 键也能被精确区分。
行为差异与典型示例
下面用同一份数据对比两种 API 的结果形态:
const numbers = [1, 2, 3, 4, 5, 6]; // Object.groupBy —— 返回 null 原型对象,键被字符串化 const byParity = Object.groupBy(numbers, (n) => (n % 2 === 0 ? 'even' : 'odd')); // 结果:{ even: [2, 4, 6], odd: [1, 3, 5] } // 注意:Object.getPrototypeOf(byParity) === null // Map.groupBy —— 返回 Map,键保留原始类型 const byParityMap = Map.groupBy(numbers, (n) => (n % 2 === 0 ? 'even' : 'odd')); // 结果:Map { 'even' => [2, 4, 6], 'odd' => [1, 3, 5] }键类型不塌缩这一点在Map.groupBy中体现得最为直观:
// 数字 1 与字符串 '1' 在 Map.groupBy 中是两个分组 const grouped = Map.groupBy([1, '1'], (v) => v); console.log(grouped.size); // 2 console.log(grouped.get(1)); // [1] console.log(grouped.get('1')); // ['1'] // 而在 Object.groupBy 中键会被字符串化,两个值被合并到同一分组 const groupedObj = Object.groupBy([1, '1'], (v) => v); console.log(Object.keys(groupedObj)); // ['1'] —— 键 '1' 覆盖了键 1回调函数同样接收(value, index)两个参数,元素按可迭代顺序被依次处理,且两种方法都会在实现内部对索引做安全整数上限校验(见下文源码分析)。
源码级实现:es.object.group-by 与 es.map.group-by
仓库中两个标准实现的入口分别是 modules/es.object.group-by.js 与 modules/es.map.group-by.js,两者通过$({ target: ..., stat: true, forced: ... })将方法挂载为静态方法,且都设置了forced条件来决定是否覆盖原生实现。
Object.groupBy 的实现要点
// packages/core-js/modules/es.object.group-by.js(结构摘录) $({ target: 'Object', stat: true, forced: DOES_NOT_WORK_WITH_PRIMITIVES }, { groupBy: function groupBy(items, callbackfn) { requireObjectCoercible(items); // 1. 强制可迭代对象可被读取 aCallable(callbackfn); // 2. 校验回调必须是可调用函数 var obj = create(null); // 3. 创建 null 原型对象作为结果容器 var k = 0; iterate(items, function (value) { doesNotExceedSafeInteger(k); // 4. 索引不超过安全整数上限 var key = toPropertyKey(callbackfn(value, k++)); // 5. 回调结果转属性键 if (key in obj) push(obj[key], value); else createProperty(obj, key, [value]); // 6. 首次出现时创建分组数组 }); return obj; } });实现细节可以从源码中逐条印证:
create(null)创建 null 原型对象:源码中结果容器通过getBuiltIn('Object', 'create')创建为null原型对象,与规范一致。测试 tests/unit-global/es.object.group-by.js 中也有assert.same(getPrototypeOf(groupBy([], it => it)), null, 'null proto')的断言。toPropertyKey完成键字符串化:回调返回值经由toPropertyKey转换为属性键(字符串或 Symbol),这与"对象键被字符串化"的行为一一对应。- 注释中的 IE 兼容细节:源码注释指出"in some IE versions,
hasOwnPropertyreturns incorrect result on integer keys, but since it's anullprototype object, we can safely usein"——由于结果对象原型为null,直接用in运算符判断键是否存在是安全的,这也是为什么返回 null 原型对象不仅是规范要求,还顺带规避了老 IE 上hasOwnProperty的已知缺陷。 doesNotExceedSafeInteger(k)与iterate:iterate是 core-js internals 中统一的迭代器封装(见 internals/iterate.js),负责兼容所有可迭代与类数组输入;每次回调前都会做安全整数上限校验,防止极端数据量下的索引溢出。
Map.groupBy 的实现要点
// packages/core-js/modules/es.map.group-by.js(结构摘录) $({ target: 'Map', stat: true, forced: IS_PURE || DOES_NOT_WORK_WITH_PRIMITIVES }, { groupBy: function groupBy(items, callbackfn) { requireObjectCoercible(items); aCallable(callbackfn); var map = new Map(); var k = 0; iterate(items, function (value) { doesNotExceedSafeInteger(k); var key = callbackfn(value, k++); // 注意:不经过 toPropertyKey if (!has(map, key)) set(map, key, [value]); else push(get(map, key), value); // 已有分组则追加元素 }); return map; } });与Object.groupBy的对照点:
- 键不做转换:
Map.groupBy中回调返回值直接作为 Map 键使用,不经过toPropertyKey,因此键保持原始类型与引用相等性,这正是Map.groupBy支持对象键、Symbol 键且不塌缩1与'1'的原因。 MapHelpers内部封装:实现通过internals/map-helpers引入Map、has、get、set辅助函数,保证在core-js-pure(不污染全局命名空间的纯版本)下也能正常工作。forced条件不同:Map.groupBy的forced条件为IS_PURE || DOES_NOT_WORK_WITH_PRIMITIVES,即纯版本下强制启用 polyfill,同时修复了 WebKit 中原始字符串输入(如Map.groupBy('ab', ...))无法正确分组的缺陷(对应 WebKit bug 271524 场景)。
共享实现:internals/array-group 与 esnext 旧入口
v1 原型方法的共享内核
废弃的 v1 API 并未被删除,core-js 通过共享 helper 复用了它们的核心分组逻辑:
- internals/array-group.js:
Array.prototype.groupBy与%TypedArray%.prototype.groupBy的共享实现,内部同样使用objectCreate(null)作为结果容器、toPropertyKey转换键、push追加元素;代码中带有// TODO: Remove this block from core-js@4注释,表明该兼容逻辑计划在 core-js 4 中移除。 - internals/array-group-to-map.js:
Array.prototype.groupByToMap的共享实现,使用MapHelpers完成键值存取。
对应挂载模块:
- esnext.array.group-by.js ——
Array.prototype.groupBy(callbackfn, thisArg),并通过addToUnscopables('groupBy')登记到Symbol.unscopables。 - esnext.array.group-by-to-map.js ——
Array.prototype.groupByToMap(callbackfn, thisArg),挂载时指定name: 'groupToMap'。 - esnext.typed-array.group-by.js ——
%TypedArray%.prototype.groupBy,通过exportTypedArrayMethod导出。
这三个 esnext 模块统一由入口文件 proposals/array-grouping.js 加载(require三个esnext.*模块),文件首行注释同样标注了对应提案地址,且带有// TODO: Remove from core-js@4的清理标记。
为什么 v1 与 v2 不能混用
v1 的groupBy是实例方法(arr.groupBy(fn)),v2 的groupBy是静态方法(Object.groupBy(arr, fn))。两者调用形态完全不同,使用旧入口引入的Array.prototype.groupBy不会与标准的Object.groupBy冲突,但新代码应统一使用 v2 静态 API,仅在维护旧代码时通过core-js/proposals/array-grouping引入 v1 兼容实现。
入口点(Entry points)与使用方式
关联文档中明确给出的提案级入口点是:
core-js/proposals/array-grouping-v2该入口对应 v2 阶段提案,加载Object.groupBy与Map.groupBy的实现。在应用入口处引入即可:
// 按提案入口加载(实验性用途,包含早期阶段提案) import 'core-js/proposals/array-grouping-v2'; // 推荐:直接使用 actual 命名空间(含全部标准 ES + web 标准特性) import 'core-js/actual'; // 或只加载所需模块,进一步减小体积 import 'core-js/actual/object/group-by'; import 'core-js/actual/map/group-by';core-js 的入口点体系在 docs/web/docs/usage.md 中有完整说明,其层级递进关系为:full(含早期提案)→actual(标准 ES + web 标准 + stage 3 提案)→stable(稳定 ES + web 标准)→es(仅稳定 ES)。由于Object.groupBy与Map.groupBy已是写入 ECMA-262 的标准特性,它们可以直接从stable/es命名空间引用,例如 stable/object/group-by.js 与 stable/map/group-by.js 都只是简单转发到es/对应实现:
// packages/core-js/stable/object/group-by.js 'use strict'; var parent = require('../../es/object/group-by'); module.exports = parent;es/下的实现则会在运行时检测原生Object.groupBy的存在与正确性(通过fails探测),原生可用时不再重复覆盖,实现"原生优先、缺失兜底"的 polyfill 策略。如果希望完全不污染全局命名空间,可以使用core-js-pure的纯版本入口(如core-js-pure/actual/object/group-by)。
[!NOTE] 关联文档中给出的入口
core-js/proposals/array-grouping-v2属于提案级入口,适合实验与早期采用;生产环境建议优先使用actual或更窄的按需模块入口。若使用 Vite、webpack 等打包器,建议在应用入口文件顶部一次性加载所需 core-js 模块,避免与业务代码中扩展原生对象的逻辑产生冲突(详见 docs/web/docs/usage.md 中的相关警告)。
测试验证:单元测试如何锁定行为
仓库为两个静态方法都准备了完整的单元测试,见 tests/unit-global/es.object.group-by.js。测试断言覆盖了规范要求的关键行为:
- API 形态:
assert.isFunction(groupBy)、assert.arity(groupBy, 2)、assert.name(groupBy, 'groupBy')、assert.nonEnumerable(Object, 'groupBy'),锁定方法的存在性、参数个数(2)、函数名与不可枚举属性。 - null 原型:
assert.same(getPrototypeOf(groupBy([], it => it)), null, 'null proto')。 - 分组正确性:
groupBy([1, 2, 1], it => it ** 2)得到[['1', [1, 1]], ['4', [2]]],验证重复元素并入同一分组、键被字符串化。 - 输入多样性:支持任意可迭代对象(
createIterable([1, 2]))与字符串(groupBy('qwe', it => it)逐字符分组)。 - 回调参数:断言回调恰好收到
(value, index)两个参数,index从 0 递增。 - Symbol 分组键:测试使用
Symbol('even')/Symbol('odd')作为分组键(tests/unit-global/es.object.group-by.js#L27-L30附近),验证 Symbol 键在Object.groupBy中的处理路径。
Map.groupBy的同类测试位于 tests/unit-global/es.map.group-by.js。这些测试同时运行在unit-global(全局命名空间版本)与unit-pure(纯版本)两套测试目录中,确保标准实现与不污染全局的core-js-pure版本行为一致。
小结与选型建议
| 对比维度 | Object.groupBy | Map.groupBy |
|---|---|---|
| 返回结构 | null 原型普通对象 | Map实例 |
| 键处理 | 经toPropertyKey,字符串化 | 原样保留,支持任意键类型 |
| 适合场景 | 字符串分组、序列化输出、对象字面量消费 | 需要对象/Symbol 键、避免键塌缩、依赖 Map 方法(size、get、has) |
| 实现位置 | es.object.group-by.js | es.map.group-by.js |
| 稳定入口 | stable/object/group-by.js | stable/map/group-by.js |
在核心场景中:需要将结果直接作为对象使用或序列化时选Object.groupBy;需要保留键类型、使用引用相等性分组(例如按对象分组)、或需要Map的高效查找与size统计时选Map.groupBy。core-js 对两种形态均提供了标准、稳定、纯版本三档入口,且源码级实现严格对齐 ECMA-262 的Object.groupBy/Map.groupBy规范语义,配合fails探测与forced覆盖策略,可以在任意老环境中得到与原生一致的行为。
【免费下载链接】core-jsStandard Library项目地址: https://gitcode.com/GitHub_Trending/co/core-js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考