☰
ES6对象数组对比去重:方案选择、边界问题与性能优化
2026/10/14 4:13:31 网站建设 项目流程

前端人应该都被这个需求锤过:两个接口各返回一坨数据,你要把它们合并展示到同一张列表里,重复的只留一个。es6数组对象对比去重这个词,看起来是“数组去重顺手一写”,实际上里面藏着一堆选择和边界问题:按什么字段判断重复、多个数组谁当基准、新数据要不要覆盖旧数据、字段顺序不同还算不算重复。这篇文章不打算只给一个函数,而是把这件小事从头到尾拆透,把我踩过的坑一并写出来。

我默认读这篇文章的朋友已经有 ES6 基础,知道Set、Map、findIndex、filter这些 API,但可能没系统想过对象数组去重为什么比基本类型数组麻烦。看完之后,你能根据自己的业务节奏选方案,而不是只会背一个模板。无论你是刚接手业务需求的初级前端,还是想把手头工具函数封装得更健壮的进阶选手,这篇都能给你一点实际参考。

1. 先把问题定义清楚:多个数组对象“对比去重”到底在干什么

1.1 一个具体的业务场景

我印象最深的是某次做后台权限管理页面。一个接口返回用户全量列表,另一个接口返回某个角色下已经分配的用户列表,前端要把两个列表合并展示,让管理员在同一个弹窗里看到“全部用户 + 已经勾选的”。这时候不可避免要判断:同一个用户是不是在两边都出现了,如果出现了,只展示一条,且保留勾选状态。

这就是典型的“多个数组对象对比去重”。它跟普通数组去重的区别在于:普通数组[1, 2, 2, 3]去重,Set一把梭就完了;对象数组一个对象里可能有十几二十个字段,你不可能指望 JavaScript 像判断数字 1 和 1 那样去判断两个对象“内容一样”。你至少得先回答一个问题:什么样的两个对象算重复。

1.2 为什么 Set 和 includes 对对象数组“失灵”

很多人第一次写对象数组去重,下意识会写出这样的代码:

const arr = [ { id: 1, name: '张三' }, { id: 1, name: '张三' } ]; const unique = [...new Set(arr)]; console.log(unique);

跑完之后发现数组里两个对象都在,完全没去重。原因是对象是引用类型,Set内部判断是否重复用的算法在对象上只比较引用地址。你写两个字面量{ id: 1 }和{ id: 1 },它们内存地址完全不同,Set会认为这是两个不相干的东西。

includes也是一样的道理。arr[0] === arr[1]直接在引用的层面就不相等。这就像去食堂打饭,刷校园卡的时候系统只看卡号,不看人长得像不像。对象每次都是新建的“复印件”,原件编号自然不一样。所以对象数组去重,必须先把“重复”翻译成一个可以被普通比较机制识别的判断依据。

1.3 动手之前先定三件事

项目里遇到过不少同事拿到需求就开写,写完发现逻辑不对,又回来返工。核心原因是没在做之前确认三件事:

第一,去重依据是什么。是只有一个唯一字段,比如id或userId;还是需要日期、类型、门店多个字段共同决定唯一性;还是两个对象所有字段完全相同才算重复。依据不同,实现方式差很远。

第二,多个数组之间谁当基准。比如 A 是主列表,B 是追加的增量数据,那么去重合并后的结果应该以 A 为主,只把 B 里 A 没有的项塞进来。反过来如果 B 是主数据源,处理逻辑就完全不同。

第三,重复时保留哪一份。同一主键下,A 里有老数据,B 里有更新的数据,是保留先出现的(保前),还是用后出现的覆盖(保后)。比如用户改了昵称,接口返回的最新对象线上应该沿用新昵称。

这三件事如果不定清楚,代码写得再漂亮也是空中楼阁。下面所有方案,本质都是围绕这三个选择展开的。

2. 判断“重复”的标准:三种常见做法与适用场景

2.1 按唯一标识去重

最常见的做法是依赖业务主键。接口返回的用户有id,商品有skuId,订单有orderNo,这类字段天然唯一。判断重复时只取这个字段的值做比较,完全不需要关心对象其他字段。

function dedupeByKey(arr, keyFn) { const seen = new Set(); const result = []; for (const item of arr) { const key = keyFn(item); if (!seen.has(key)) { seen.add(key); result.push(item); } } return result; } const list = [ { id: 1, name: '张三', role: 'admin' }, { id: 2, name: '李四', role: 'user' }, { id: 1, name: '张三', role: 'admin' } ]; console.log(dedupeByKey(list, item => item.id));

这段代码用了Set保存已经出现过的id,遍历一次就能完成去重,时间复杂度是稳定的 O(n)。keyFn传入的是一个取值函数,这样就把“取哪个字段”和“怎么去重”解耦了。业务里遇到“张三”改名成“张老三”,只要id没变,按主键去重后保留哪份就看后续策略,这份对象本身并不会被误判成两条。

按主键去重的适用场景非常广:用户列表、订单列表、日志数据、表单提交记录,基本只要是后端落过库的数据,都能找到主键。这也是我推荐优先使用的方案。

2.2 按组合字段去重

业务里经常没有单一主键。最典型的例子是库存流水:同一个商品在同一个仓库里可能有多条记录,需要“商品 id + 仓库 id”共同决定唯一性。还有活动报名表里,可能用“活动 id + 报名人 id”才能确定一条报名记录。

组合字段去重本质上还是生成一个“合成字符串”,只是这个字符串由多个字段拼出来:

function dedupeByKeys(arr, keyFns) { const seen = new Set(); const result = []; for (const item of arr) { const key = keyFns.map(fn => fn(item)).join('__'); if (!seen.has(key)) { seen.add(key); result.push(item); } } return result; } const records = [ { productId: 'P001', warehouseId: 'WH01', count: 10 }, { productId: 'P001', warehouseId: 'WH02', count: 5 }, { productId: 'P001', warehouseId: 'WH01', count: 20 } ]; console.log(dedupeByKeys(records, [ item => item.productId, item => item.warehouseId ]));

这里我用了join('__')来拼接,中间的分隔符很重要。如果直接用productId + warehouseId拼接,'P001' + 'WH01'和'P00' + '1WH01'会撞出同一个字符串,造成误判。用分隔符能大幅降低这种冲突概率,但依然不是绝对安全。如果字段值本身就包含分隔符,更稳妥的做法是把字段序列化成 JSON:

const key = JSON.stringify([item.productId, item.warehouseId]);

组合字段去重适合所有“单字段不唯一、多字段联合才唯一”的场景,比如排课表、商品配置项、城市分门店数据。

2.3 按全字段深度比较去重

还有一种情况比较少见但确实存在:两个对象没有任何业务主键,纯粹要求“所有字段都相同才算重复”。比如本地维护的一份静态配置表,后端不给你标识,字段可能随时增删。这时候需要用深度比较。

最简单的实现用JSON.stringify:

function dedupeByFullJson(arr) { const seen = new Set(); const result = []; for (const item of arr) { const key = JSON.stringify(item); if (!seen.has(key)) { seen.add(key); result.push(item); } } return result; }

数据量小、字段结构稳定的时候这个写法最省事。但注意它有天然缺陷,比如对象属性顺序不同会被当成两条,函数、undefined、Date等类型序列化后和预期也不一致。后面第 5 章我会专门展开说这些问题。

如果不想依赖序列化,可以自己写一个递归的深度比较函数:

function isDeepEqual(a, b) { if (a === b) return true; if (typeof a !== 'object' || typeof b !== 'object') return false; if (a === null || b === null) return false; if (Object.keys(a).length !== Object.keys(b).length) return false; for (const key of Object.keys(a)) { if (!Object.prototype.hasOwnProperty.call(b, key)) return false; if (!isDeepEqual(a[key], b[key])) return false; } return true; }

注意这个简化版没有处理循环引用,业务数据一般不会有循环引用,但如果你的对象里有互相指向的情况,建议加一个递归深度上限或者记录访问过的对象集合,防止栈溢出。

全字段对比适合本地配置、低代码平台的属性面板、导出的静态字典这类“没有主键逻辑”的数据。一旦数据量上万,不建议优先用这种方案,因为每次比较都很重,性能容易拖垮页面。

3. 四种基于 ES6 的去重实现,从直白到高效

3.1 方案一:findIndex + filter,最直观但有点笨

这是新手最容易写出来的方案。思路很简单:声明一个空结果数组,遍历原数组,用findIndex在已经收集的结果里找有没有重复的,找不到才push进去。

function dedupeByFindIndex(arr, keyFn) { const result = []; for (const item of arr) { const idx = result.findIndex(existing => keyFn(existing) === keyFn(item)); if (idx === -1) { result.push(item); } } return result; }

它逻辑直白,代码可读性很高,适合数据量几百条以内的场景。但问题也很明显:外层遍历 n 次,内层findIndex又要遍历可能增长的结果数组,整体复杂度高达 O(n²)。有一次我在项目里拿一份两万条的日志数据跑这个函数,浏览器直接卡到几百毫秒才出结果,肉眼可见的掉帧。

如果你只是写个小 Demo、处理十来个配置项,这个方案没有问题。但在真实业务里,我希望你尽量避开它,因为你无法预测数据量什么时候会涨上去。

3.2 方案二:Map + 唯一键,主力推荐

用Map做去重是目前我在业务里最常用的方案。思路是把对象的唯一键映射成Map的 key,对象本身作为 value。因为Map的has和set都是 O(1) 操作,整体遍历一次就能完成去重:

function dedupeByMap(arr, keyFn) { const map = new Map(); for (const item of arr) { const key = keyFn(item); if (!map.has(key)) { map.set(key, item); } } return Array.from(map.values()); }

这段代码的巧妙之处在于,Map天然保证了 key 的唯一性。如果一个对象已经在Map里占住了 key,后面再遇到同样 key 的对象,直接跳过。最后用Array.from(map.values())把 value 全部取出来,结果就是去重后的数组。

为什么推荐Map而不是Set?因为这里你不仅要“记录哪些 key 出现过”,还要“把对应的对象保存下来”。用Set只能存 key,还得另开一个数组存对象,操作起来反而多一步。Map把两件事合并成一件,代码更紧凑,遍历次数也更少。

这个方案适合大多数按主键或组合键去重的业务,十万条以内基本无压力,是性能和可读性的平衡点。

3.3 方案三:Set + JSON 序列化,适合小数据量的无钥匙去重

如果你连“该拿哪个字段当 key”都不确定,只想把“内容完全一样”的对象去掉,可以用Set配合JSON.stringify:

function dedupeBySetJson(arr) { const seen = new Set(); const result = []; for (const item of arr) { const key = JSON.stringify(item); if (!seen.has(key)) { seen.add(key); result.push(item); } } return result; }

这个方案有个很友好的特性:不需要你设计keyFn。只要对象序列化后的字符串相同,就认定重复。数据量小的时候写起来非常爽,比如给新手一个筛选列表,十几条数据秒出结果。

但代价是它并不总是可靠。对象字段顺序不同会误判,Date会被转成字符串,NaN会变成null,函数会被直接丢弃。这些坑我后面会详细列出来。我的建议是:这个方案只适合本地静态数据的简单去重,别用在接口返回的业务数据上。

3.4 方案四:通用对比函数,处理“多个数组对比”的灵活底座

说完了单数组去重,回到标题里那个关键词:“对比”。所谓多个数组对象对比去重,其实有两种语义。

第一种语义是“合并后全局去重”,把所有数组展开成一个数组,再按统一规则去重。第二种语义是“以某个数组为基准,找出其他数组里不重复的项并追加”。这两种语义都需要一个更通用的底层函数。

先写一个能自定义“是否重复”的函数:

function uniqWith(arr, isDuplicate) { const result = []; for (const item of arr) { const idx = result.findIndex(existing => isDuplicate(existing, item)); if (idx === -1) { result.push(item); } } return result; }

调用的时候传入比较函数,比如按 id 判断:

const merged = uniqWith( [...listA, ...listB], (a, b) => a.id === b.id );

这个uniqWith相当于手动实现了一个简化版的Lodash.uniqWith。它的优势是灵活,比较逻辑完全由调用方决定;劣势是 O(n²),不适合大数据量。

如果要对两个数组做“差集对比”,可以这样找两边独有和共有的数据:

function diffByKey(base, target, keyFn) { const baseKeys = new Set(base.map(keyFn)); const targetKeys = new Set(target.map(keyFn)); return { onlyInBase: base.filter(item => !targetKeys.has(keyFn(item))), onlyInTarget: target.filter(item => !baseKeys.has(keyFn(item))), common: base.filter(item => targetKeys.has(keyFn(item))) }; }

这个diffByKey在需求里很有用,比如你要对比“旧列表”和“新列表”,找到新增、删除、共有的项。它是“多个数组对象对比去重”里“对比”两个字最扎实的落地。

4. 多个数组对象合并去重的完整实操

4.1 全局合并去重:多组数据合成一份干净列表

最直白的多数组去重,是把所有数组合并成一个大数组,然后只做一次去重。用展开运算符或flat都行:

function mergeUnique(arrays, keyFn) { const merged = arrays.flat(); return dedupeByMap(merged, keyFn); } const listA = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]; const listB = [ { id: 2, name: '李四' }, { id: 3, name: '王五' } ]; console.log(mergeUnique([listA, listB], item => item.id)); // 结果:id 为 1、2、3 的三条数据

arrays.flat()会把两层数组压平成一层,ES2019 里提供的方法,现在的主流浏览器和 Node 环境都能用。如果你还在兼容老环境,可以改成[].concat(...arrays)。

全局合并去重的语义是“所有数据一视同仁”。适合的场景包括:多个筛选项分别返回结果列表,页面上要展示一个聚合后的结果集;或者多端同步的数据合并成一个总列表。它不关心哪个数组是主,只关心最终不重复。

4.2 按基准数组做补集合并:主列表不动,只追加新面孔

“对比去重”在业务里更常见的语义是这个:你有一个已经展示出来的主列表,现在又来了一批新数据,你要把新数据里已经存在的主列表项忽略掉,只把真正的新面孔追加到主列表后面,而且主列表原有顺序不能乱。

实现思路是先拿主列表生成key集合,然后遍历所有附加数组:

function mergeByReference(base, extraArrays, keyFn) { const result = [...base]; const seenKeys = new Set(base.map(keyFn)); for (const arr of extraArrays) { for (const item of arr) { const key = keyFn(item); if (!seenKeys.has(key)) { seenKeys.add(key); result.push(item); } } } return result; } const baseList = [ { id: 1, name: '张三' }, { id: 2, name: '李四' } ]; const extraList = [ { id: 2, name: '李四', age: 30 }, { id: 3, name: '王五' } ]; console.log(mergeByReference(baseList, [extraList], item => item.id)); // 结果:张三、李四(原主列表里的)、王五(追加)

注意我用了Set来存已经出现过的 key。这样主列表顺序保持原样,追加的数据按出现顺序排在后面,不会因为findIndex这种 O(n) 查找拖慢速度。这个写法就是我做权限管理页面弹窗合并用户列表时用到的,效果非常稳定。

4.3 保后覆盖:同一主键下面,新数据要“赢”

有时候业务要求不是“保留先出现的”,而是“用最新数据覆盖旧数据”。最典型的场景是用户资料更新:A 接口返回的name是旧值,B 接口返回的name是新值,合并后应该显示新的。

用Map实现保后覆盖非常简单,因为Map.set本来就会覆盖同 key 的值:

function mergeOverride(arrays, keyFn) { const map = new Map(); for (const arr of arrays) { for (const item of arr) { map.set(keyFn(item), item); } } return Array.from(map.values()); } const listA = [ { id: 1, name: '张三', role: 'admin', updateTime: '2024-01-01' } ]; const listB = [ { id: 1, name: '张三', role: 'user', updateTime: '2024-02-01' } ]; console.log(mergeOverride([listA, listB], item => item.id)); // 结果:id 为 1 的对象,role 变成 user,updateTime 变成 2024-02-01

这里有一个细节值得注意:mergeOverride是“遍历所有数组,后处理的覆盖先处理的”。如果你想让某个数组拥有最高优先级,把它放到数组参数的最后就行。这个顺序约定可以在函数注释里写清楚,方便同事使用。

4.4 完整演练:模拟一次真实数据合并

写一个更贴近实际的例子。假设你在做一个商品选择器,左侧是全量商品列表,右侧是根据分页加载的已选商品:

const fullProductList = [ { skuId: 'SKU001', name: '黑色卫衣', price: 199, selected: false }, { skuId: 'SKU002', name: '白色T恤', price: 89, selected: false }, { skuId: 'SKU003', name: '蓝色牛仔裤', price: 299, selected: false } ]; const selectedList = [ { skuId: 'SKU001', name: '黑色卫衣', price: 199, selected: true }, { skuId: 'SKU004', name: '灰色围巾', price: 129, selected: true } ]; const merged = mergeByReference(fullProductList, [selectedList], item => item.skuId); console.log(merged);

这段代码运行后,fullProductList里的SKU001会保留在原来的位置,且selected还是false;SKU002和SKU003原样保留;SKU004会被追加到列表末尾。如果你希望全量列表里那个已经被勾选过的商品也显示为已勾选,就不能用这种“只追加不覆盖”的策略,而是要改成mergeOverride([fullProductList, selectedList], item => item.skuId),让selectedList的selected: true覆盖掉前面的selected: false。

这个例子暴露出一个很关键的点:多数组对比去重,不只是“去掉重复”,它同时决定了重复保留后哪个字段生效。这也是我在需求评审阶段反复向产品确认的原因。

5. 过程中容易踩的坑与排查思路

5.1 JSON.stringify 去重的三个隐患

我用JSON.stringify做全字段去重时吃过不少亏,这里集中说三个最常见的坑。

第一个坑是字段顺序。JavaScript 对象的属性顺序在绝大多数情况下按插入顺序排列,但两个字段名相同、顺序不同的对象,序列化出来字符串完全不一样:

const a = { name: '张三', age: 20 }; const b = { age: 20, name: '张三' }; console.log(JSON.stringify(a) === JSON.stringify(b)); // false

这会直接导致明明内容相同的对象被判定为不重复。解决思路是在序列化前对 key 排序,但代码会变得啰嗦。

第二个坑是特殊值的处理。undefined和函数属性在JSON.stringify中会被直接丢弃,NaN会变成null,Date会变成toJSON()后的字符串。这意味着一个{ date: new Date('2024-01-01') }和一个{ date: '2024-01-01' }序列化后会变成一模一样的东西。如果你原本是想区分日期对象和普通字符串,这个方案就直接失真。

第三个坑是性能。序列化本身就是满负荷遍历对象,数据量大时每个对象都要转一次字符串,内存占用高,时间开销大。我见过一个表格页面用这个方案处理 5000 条数据,打开控制台明显能看到序列化过程卡住主线程。

所以我的心态很明确:JSON.stringify去重只适用于小型、静态、字段结构简单的数据。只要数据来自后端接口,我都改用Map + keyFn。

5.2 NaN、null、undefined 到底算不算重复

写深度比较函数或自定义比较函数时,最容易忽略NaN的问题。用===判断,NaN !== NaN,这导致两个都带NaN的对象会被认为不相等。但用Object.is判断,Object.is(NaN, NaN)返回true。

如果你要用Map做去重,其实不用担心这一点,因为NaN作为 key 时Map内部使用SameValueZero算法,NaN会被正常识别为同一个 key。但如果你自己写了isDeepEqual,第一行如果只写if (a === b) return true;,那么NaN和NaN就永远不会相等:

function isDeepEqual(a, b) { if (a === b) return true; // NaN 这里直接 false,提前返回了 // 后面的逻辑永远没机会处理 NaN }

改进方式是在开头加一个Object.is判断:

function isDeepEqual(a, b) { if (Object.is(a, b)) return true; if (typeof a !== 'object' || typeof b !== 'object') return false; // 继续处理对象 }

至于null和undefined,我遇到的项目里接口偶尔会拿null填充真实字符串,导致两个对象看起来字段结构一样,但一个name是null、另一个是''。它们当然不算相同数据,但如果你用Set存 key,null和undefined会被当成两种不同值,没问题;如果用JSON.stringify,undefined会直接消失,问题就来了。所以这一步的关键是:先确认你的数据里可能有哪些空值,再决定用哪种比较策略。

5.3 大数据量下的性能取舍

写去重函数最容易忽略的就是复杂度。findIndex + filter是 O(n²),1 万条数据要跑一亿次比较,浏览器主线程直接卡顿;Map + keyFn是 O(n),1 万条数据基本无感。我实际在业务里测过,两份 2 万条对象数组合并去重,Map方案耗时稳定在 10ms 以内,findIndex方案则可能冲到几百毫秒甚至更多。

如果你要处理的是十万条甚至更大规模的数据,还要关注keyFn本身的成本。比如组合键里JSON.stringify([a, b])每次都会重新分配内存,高频调用时会有明显压力。这时候可以退而求其次,用简单的分隔符拼接,只要保证分隔符不会出现在字段值里即可。另一个思路是如果数据本身有序,可以先按 key 排序,再在遍历时只比较相邻项,这样即使不用Map也能降到 O(n log n) 左右,但实现复杂度会上升。

我的建议是:默认无脑用Map + keyFn,除非已经用性能分析工具证明它是瓶颈。不要为了“万无一失”用 O(n²) 的写法。

5.4 去重前先做数据清洗,别让脏数据污染结果

我在真实项目里踩过这样一次坑:两个接口返回同一批用户,一个接口里用户的id是数字类型1,另一个接口里同一个用户id是字符串类型'1'。我按id去重时,Set.has(1)和Set.has('1')判断为不同,结果同一个用户出现了两条。

这种类型不统一的脏数据,单靠去重函数很难兜底。规范的做法是在去重之前先做一遍数据清洗,比如把所有id统一转成字符串:

const cleanList = list.map(item => ({ ...item, id: String(item.id) }));

还有另一种情况是字段缺失。A 接口的对象有phone字段,B 接口同主键的对象根本没有phone,如果使用全字段深度比较,它们会直接判定为不重复,完全违背了业务预期。这种情况下必须在取值函数里做兜底:

const keyFn = item => `${item.id}_${item.phone ?? ''}`;

数据清洗不属于去重函数的职责,但它直接决定去重结果是否符合预期。每次封装去重工具前,先看一眼两个接口的数据形态,比多写几个“防御性”分支划算得多。

6. 最后说一点我的习惯

每次接手“多个数组对象对比去重”这类需求,我现在的第一反应已经固化成一套流程:先跟产品确认主数据和增量数据的关系,再确认重复项的保留策略,然后写一个接受keyFn的Map方案,最后在合并前做一次字段清洗。这套组合拳在多个项目里跑得很稳,代码量不大,同事接手也容易理解。

如果你只是处理少量本地配置,Set + JSON.stringify确实省事,但别把它用在接口返回的大列表上。遇到比“去重”更复杂的合并需求,比如同一个对象去重后还要聚合数量、拼接标签,尽量在Map阶段同步完成聚合,而不是去重完再跑一次循环,那个性能差异会非常明显。

最后一个小技巧:封装工具函数时,除了返回最终结果,可以顺手返回Map本身。调试的时候把map打出来,你能直接看到每个 key 命中了谁,定位问题快很多。这个习惯帮我排查过好几回“明明字段一样却没去重成功”的诡异 bug,希望你也能用上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询