最直接、最现代的javascript数组去重方法是使用set,因其设计初衷即为存储唯一值,可高效去除基本类型重复项;2. 对于对象数组去重,需基于唯一标识属性结合map实现,或通过自定义比较逻辑处理复杂场景;3. 需警惕类型隐式转换、nan特殊性等潜在陷阱,并根据数据规模权衡性能与可读性,确保明确“重复”定义后再选择合适方案。
要说JavaScript里怎么去掉数组重复项,最直接、最现代的答案就是用Set。它就像个天然的去重机,一丢进去,出来就都是独一无二的了。
解决方案
在我看来,处理数组去重,最优雅且效率不错的方案,首推ES6的Set对象。它的设计初衷就是为了存储不重复的值,所以用它来去重简直是量身定制。
使用 Set 对象
这种方法简洁明了,可读性极高。
function uniqueArrayWithSet(arr) { return [...new Set(arr)]; // 或者:return Array.from(new Set(arr)); } const numbers = [1, 2, 2, 3, 4, 4, 5]; const uniqueNumbers = uniqueArrayWithSet(numbers); console.log(uniqueNumbers); // 输出: [1, 2, 3, 4, 5] const strings = ['apple', 'banana', 'apple', 'orange']; const uniqueStrings = uniqueArrayWithSet(strings); console.log(uniqueStrings); // 输出: ['apple', 'banana', 'orange']
这种方式的优点是代码量少,语义清晰,而且内部实现通常是经过优化的,对于大多数情况性能表现都很好。它能处理基本数据类型(数字、字符串、布尔值、null、undefined)的去重。不过,对于对象(Object),Set是基于引用的去重,这意味着 {a:1} 和 {a:1} 会被视为两个不同的值,因为它俩在内存中的引用地址不同。这一点在使用时需要特别注意。
当然,除了 Set 这种“现代”方式,JavaScript 发展这么多年,也积累了不少“传统”的去重手段,它们在特定场景下依然有用,或者说,理解它们能帮助我们更好地理解数组操作的底层逻辑。
为什么数组去重是个常见需求?(以及我们为什么要关心效率)
说实话,我在日常开发中,遇到数组去重的场景简直太多了。这并不是一个孤立的问题,它几乎渗透在数据处理的方方面面。比如,你可能从后端API拿了一堆数据,结果发现某些ID或者标签重复了,你肯定不希望在前端展示的时候也重复吧?又或者,用户在一个多选框里不小心点了两次同一个选项,你后台接收到的数组里就有了重复项。再比如,当你合并多个数组时,为了保持数据的纯粹性,去重就成了必不可少的一步。
为什么要关心效率?这其实是个很实际的问题。如果你的数组只有几十个、几百个元素,那么用什么方法去重,性能差异几乎可以忽略不计。但如果你的数组有几万、几十万甚至上百万个元素,那么一个O(N²)复杂度的算法,和O(N)或者O(N log N)的算法,其执行时间可能就是几毫秒和几秒甚至几十秒的区别。在用户体验至上的今天,没有人愿意等待一个慢吞吞的页面。所以,选择一个合适的、高效的去重方法,不仅仅是代码写得好不好看的问题,更是直接影响产品性能和用户体验的关键。这就像你在修一条路,小路可能随便铺铺就行,但要是修高速公路,你肯定要考虑材料、施工方式和未来的承载能力,对吧?
除了Set,还有哪些经典的JavaScript数组去重方法?(性能与场景考量)
除了 Set 这个“万金油”,我们还有一些其他经典的去重方法,它们各有特点,也适合不同的场景。理解它们能帮助你更灵活地应对各种去重需求。
1. 使用 filter() 结合 indexOf()
这是非常经典的一种方式,也相对容易理解。filter() 方法会创建一个新数组,其中包含通过所提供函数实现的测试的所有元素。而 indexOf() 则会返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回 -1。
function uniqueArrayWithFilterAndIndexOf(arr) { return arr.filter((item, index, self) => { return self.indexOf(item) === index; }); } const numbers = [1, 2, 2, 3, 4, 4, 5]; const uniqueNumbers = uniqueArrayWithFilterAndIndexOf(numbers); console.log(uniqueNumbers); // 输出: [1, 2, 3, 4, 5]
性能考量: 这种方法对于每个元素,都需要遍历(或至少部分遍历)数组来查找其首次出现的位置。因此,它的时间复杂度是 O(N²)。对于小型数组(几百个元素以内),这种性能开销通常可以接受,代码也比较直观。但如果数组非常大,性能瓶颈就会非常明显,我个人是不太推荐在大规模数据处理中使用它。
2. 使用 reduce() 结合 includes() 或 Map/Object
reduce() 方法可以把数组“规约”成一个单一的值,这里我们可以用它来构建一个不重复的新数组。
// 方法A: 结合 includes() function uniqueArrayWithReduceAndIncludes(arr) { return arr.reduce((accumulator, current) => { if (!accumulator.includes(current)) { accumulator.push(current); } return accumulator; }, []); } const numbers = [1, 2, 2, 3, 4, 4, 5]; const uniqueNumbers = uniqueArrayWithReduceAndIncludes(numbers); console.log(uniqueNumbers); // 输出: [1, 2, 3, 4, 5]
性能考量: 类似 filter + indexOf,includes() 也会在每次迭代时遍历 accumulator 数组,所以这种方法的平均时间复杂度也是 O(N²)。
方法B: 结合 Map 或普通 Object (作为哈希表)
这种方式的思路是利用 Map 或普通对象的键值对特性,将数组中的元素作为键存储,从而达到去重的目的。因为 Map 或对象查找键的效率非常高(接近 O(1)),所以整体性能会好很多。
function uniqueArrayWithReduceAndMap(arr) { const map = new Map(); return arr.reduce((accumulator, current) => { if (!map.has(current)) { map.set(current, true); // 存入Map,标记已见过 accumulator.push(current); } return accumulator; }, []); } const numbers = [1, 2, 2, 3, 4, 4, 5]; const uniqueNumbers = uniqueArrayWithReduceAndMap(numbers); console.log(uniqueNumbers); // 输出: [1, 2, 3, 4, 5] // 也可以直接用Object,但Map在键类型和性能上更灵活 function uniqueArrayWithObject(arr) { const obj = {}; const result = []; for (let i = 0; i < arr.length; i++) { const item = arr[i]; if (!obj[item]) { // 判断是否已存在 obj[item] = true; result.push(item); } } return result; } const moreNumbers = [1, 2, 2, 3, 4, 4, 5, '1', 'a', 'a']; // 注意 '1' 和 1 的区别 const uniqueMoreNumbers = uniqueArrayWithObject(moreNumbers); console.log(uniqueMoreNumbers); // 输出: [1, 2, 3, 4, 5, "1", "a"]
性能考量: 这种方法的时间复杂度接近 O(N),因为 Map.has() 或对象属性查找的平均时间是常数时间。这使得它在处理大型数组时,性能表现非常接近 Set,是除了 Set 之外我个人比较推荐的通用去重方案。
总结一下:
- Set: 最佳选择,代码简洁,性能优异(O(N)),但要注意对象去重是基于引用的。
- filter + indexOf / reduce + includes: 代码直观,但性能较差(O(N²)),不适合大型数组。
- reduce + Map/Object: 性能优异(O(N)),可以作为 Set 的替代,尤其是在需要兼容旧环境,或者对键有特殊处理需求时。
选择哪种方法,说到底还是要看你的具体需求:数组大小、数据类型、以及对代码简洁性或兼容性的偏好。
处理包含对象的数组去重:一个更复杂的挑战
前面我们讨论的去重方法,对于基本数据类型(数字、字符串等)都很有效。但当数组里装的是对象时,事情就变得有点复杂了。这是因为 JavaScript 在比较对象时,默认是比较它们的引用地址,而不是它们内部的属性值。也就是说,{ id: 1, name: ‘A’ } 和 { id: 1, name: ‘A’ } 在内存中是两个不同的对象,即使它们的属性值完全一样,Set 也会认为它们是不同的。这就像你有两张一模一样的照片,但它们是不同的冲印出来的纸张,不是同一张。
那么,如果我们想根据对象的某个或某几个属性来判断“重复”,该怎么办呢?
1. 根据唯一标识属性去重 (推荐)
如果你的对象有一个或多个可以作为唯一标识的属性(比如 id、uuid、sku 等),这是最常用也最可靠的方法。我们可以利用 Map 来存储这些唯一标识,并把对应的对象存起来。
function uniqueObjectsById(arr, key) { const map = new Map(); const result = []; for (const item of arr) { // 确保对象有这个key,并且这个key的值不为空 if (item && item[key] !== undefined && !map.has(item[key])) { map.set(item[key], item); // 以key的值作为Map的键 result.push(item); } } return result; } const users = [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 1, name: 'Alicia' }, // id重复,但name不同 { id: 3, name: 'Charlie' }, { id: 2, name: 'Bobby' } // id重复,但name不同 ]; const uniqueUsers = uniqueObjectsById(users, 'id'); console.log(uniqueUsers); /* 输出: [ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Charlie' } ] */
这种方法非常高效,因为它利用了 Map 的 O(1) 查找特性。它会保留第一个遇到的具有该 id 的对象。
2. 将对象转换为字符串进行比较 (有风险,慎用)
如果你没有一个明确的唯一标识符,或者需要根据对象的所有属性来判断重复,一个“歪招”是把对象转换成字符串(比如用 JSON.stringify),然后对这些字符串进行去重。
function uniqueObjectsByStringify(arr) { const stringifiedSet = new Set(); const result = []; for (const item of arr) { const stringifiedItem = JSON.stringify(item); if (!stringifiedSet.has(stringifiedItem)) { stringifiedSet.add(stringifiedItem); result.push(item); } } return result; } const data = [ { a: 1, b: 2 }, { b: 2, a: 1 }, // 属性顺序不同,JSON.stringify结果可能不同! { a: 1, b: 2 }, { c: 3, d: 4 } ]; const uniqueData = uniqueObjectsByStringify(data); console.log(uniqueData); /* 输出: [ { a: 1, b: 2 }, { b: 2, a: 1 }, // 注意这里,如果属性顺序不同,会被认为是两个不同的对象 { c: 3, d: 4 } ] */
风险点:
- 属性顺序: JSON.stringify 的结果会受对象属性顺序的影响。{a:1, b:2} 和 {b:2, a:1} 转换成的字符串是不同的,即使它们在逻辑上是“相同”的对象。
- 循环引用: 如果对象中存在循环引用,JSON.stringify 会抛出错误。
- 不可序列化的值: 如果对象包含 undefined、函数、Symbol 值,或者 BigInt,JSON.stringify 会忽略它们或抛出错误。
所以,这种方法只适用于非常简单的、属性顺序固定的、不含特殊值的对象数组。在我看来,除非你对数据结构有绝对的掌控,否则尽量避免这种方式。
3. 自定义比较函数 (更灵活但复杂)
对于更复杂的场景,比如需要根据多个属性组合判断唯一性,或者属性值本身也是对象,你可能需要编写一个自定义的比较函数,然后结合 filter 或 reduce 来实现。
function uniqueObjectsByCustomLogic(arr, compareFn) { const result = []; arr.forEach(item => { // 检查结果数组中是否已存在与当前item“相同”的元素 const isDuplicate = result.some(existingItem => compareFn(existingItem, item)); if (!isDuplicate) { result.push(item); } }); return result; } // 示例比较函数:根据 id 和 type 两个属性判断 const compareUsers = (user1, user2) => user1.id === user2.id && user1.type === user2.type; const complexUsers = [ { id: 1, type: 'admin', name: 'A' }, { id: 2, type: 'user', name: 'B' }, { id: 1, type: 'admin', name: 'C' }, // id和type都重复 { id: 1, type: 'guest', name: 'D' } // id重复,但type不同 ]; const uniqueComplexUsers = uniqueObjectsByCustomLogic(complexUsers, compareUsers); console.log(uniqueComplexUsers); /* 输出: [ { id: 1, type: 'admin', name: 'A' }, { id: 2, type: 'user', name: 'B' }, { id: 1, type: 'guest', name: 'D' } ] */
性能考量: 这种方法因为 some() 内部的循环,以及 compareFn 的执行,性能通常是 O(N²)。对于大型数组,需要慎重考虑。但它的优势在于极高的灵活性,你可以根据任何复杂的逻辑来定义“重复”。
总的来说,处理对象数组去重,核心思想就是找到一个可靠的“判重”依据。如果能通过某个唯一ID,那用 Map 绝对是首选。如果不行,就得根据具体业务逻辑来权衡是接受 JSON.stringify 的局限性,还是投入精力去写一个更复杂的自定义比较函数。
数组去重时可能遇到的“坑”和最佳实践
在处理数组去重时,虽然方法很多,但有些小细节或者“坑”是需要注意的。这些东西往往不是代码本身的问题,而是数据特性或者我们对数据理解的偏差导致的。
1. 数据类型隐式转换的陷阱
JavaScript 的类型转换有时会让人头疼。比如,当你用 indexOf 或者 Set 去重时,1 和 ‘1’ 是被认为是不同的值。但如果你用 Object 作为哈希表,并且键是数字,那么 obj[1] 和 obj[‘1’] 可能会指向同一个属性,因为对象键会被强制转换为字符串。
const mixedArray = [1, '1', 2, '2', 1]; const uniqueWithSet = [...new Set(mixedArray)]; console.log(uniqueWithSet); // [1, "1", 2, "2"] - Set区分了数字和字符串 const uniqueWithObjectHash = (() => { const obj = {}; const result = []; for (const item of mixedArray) { // obj[item] 会将 item 转换为字符串作为键 // obj[1] 和 obj['1'] 都会变成 obj['1'] if (!obj[item]) { obj[item] = true; result.push(item); } } return result; })(); console.log(uniqueWithObjectHash); // [1, 2] - '1' 和 '2' 被视为重复
这里 uniqueWithObjectHash 的结果可能会出乎意料,因为它将 1 和 ‘1’ 视为相同。所以,在去重前,最好确保你的数组元素类型是一致的,或者你清楚这种类型转换带来的影响。
2. NaN 的特殊性
NaN(Not a Number)是一个非常特殊的值。在 JavaScript 中,NaN 和任何值都不相等,包括它自己 (NaN === NaN 返回 false)。这意味着,如果你数组里有多个 NaN,indexOf 会认为它们都是不同的。
const nanArray = [1, NaN, 2, NaN, 3]; const uniqueWithFilter = nanArray.filter((item, index, self) => self.indexOf(item) === index); console.log(uniqueWithFilter); // [1, NaN, 2, NaN, 3] - 两个NaN都保留了 const uniqueWithSet = [...new Set(nanArray)]; console.log(uniqueWithSet); // [1, NaN, 2, 3] - Set 对 NaN 的处理是特殊的,它只会保留一个 NaN
Set 在处理 NaN 时表现得更“智能”,它只会存储一个 NaN。这是 Set 的一个优点,但如果你不了解,可能会感到困惑。
3. 性能与可读性的权衡
前面提到了各种方法的性能差异。在实际开发中,我们总是在性能和代码可读性之间做权衡。对于小数组,我个人倾向于选择最直观、最简洁的 Set 方法,或者 filter + indexOf,因为它们的性能开销几乎可以忽略,而代码维护成本低。只有当面对性能瓶颈时,才会去考虑更复杂的优化,比如手动构建哈希表。过度优化一个非瓶颈点,反而会增加代码的复杂性。
4. 保持数组的原始顺序
大部分去重方法都会保留元素在原数组中的首次出现顺序,比如 Set、filter、reduce。但如果你自己实现一些基于排序的去重(比如先排序,再遍历去重),那么原始顺序就会丢失。这在某些业务场景下可能是不可接受的。
5. 明确“重复”的定义
尤其是在处理对象数组时,最关键的一点就是:你到底如何定义“重复”?是所有属性都相同才算重复?还是某个ID相同就算重复?这个定义直接决定了你选择哪种去重策略,以及如何编写你的比较逻辑。没有明确的定义,再好的去重方法也可能无法满足你的需求。
在我看来,最好的实践是:
- 优先使用 Set,
暂无评论内容