1. 去重:从最基础的写法到最优解
先说个现象,去重这功能,业务里到处都能撞见。表格里筛重、埋点数据清洗、接口返回的重复列表、表单提交前的标签去重……面试还爱考。我见过不少写了三五年业务代码的人,遇到这需求还是无脑 new Set() 一把梭。大部分场景确实没问题,但对象数组去重、大数据量去重、NaN 去重这些情况,套不对姿势就会踩坑。这个系列我会从最普通的写法开始,一路拆到快慢指针优化,你看完以后基本能应付所有实际场景。
1.1 基础数组去重:Set、filter、Map、对象标记法的对比
先把最常规的几种写法摆出来,大家感受一下差异:
javascript复制// 方案一:Set(最常见)
const arr = [1, 2, 3, 2, 1, 4];
const result = [...new Set(arr)]; // [1, 2, 3, 4]
// 方案二:filter + indexOf
const result2 = arr.filter((item, index) => arr.indexOf(item) === index);
// 方案三:Map
const map = new Map();
arr.forEach(item => map.set(item, item));
const result3 = [...map.keys()];
// 方案四:对象标记法(不推荐,有隐式转换问题)
const obj = {};
const result4 = arr.filter(item => {
if (obj[item]) return false;
obj[item] = true;
return true;
});
先说 Set,它内部基于 SameValueZero 算法判断值是否相等,能用最少的代码解决 90% 的基础需求,这是它火的核心原因。但要注意,Set 的去重是把值本身当作唯一标识,对 NaN 也能正确去重,这点比 indexOf 强,因为 indexOf 内部用的严格相等,NaN === NaN 永远返回 false。
filter + indexOf 的方案,本质上是一个 O(n²) 的写法。每次 indexOf 都得从头扫描到位,数据量小点没关系,但数据一多,运行时间有明显上涨。它的优点是兼容性极好,在老旧的浏览器环境里,不想引入 polyfill,那这个就是唯一能用的原生方案。
对象标记法我强烈不建议用。obj[item] 这一步会把 item 强制转换成字符串,数字和字符串的数字会互相覆盖。比如 [1, '1'] 这组数据,对象标记法会把 1 当成 '1',结果是 [1],丢失了真正的元素类型。Map 的方案本质上和 Set 是一个级别,但写法更啰嗦,除非你后面还要借助 Map 维护额外状态,否则没有必要绕这一圈。
平时写业务,我的选择标准是这样的:
- 数组元素是基本类型(number、string、boolean、null、undefined、symbol、bigint),直接用 Set。
- 需要兼容极其老旧的面试场景或低版本浏览器环境,才考虑 filter + indexOf。
- 数据量超过一万条,就不要用 filter + indexOf 了,换 Set 或者 Map,性能差距能到一个数量级。
1.2 对象数组去重:JSON.stringify 的坑和 reduce 的正确用法
对象数组去重才是真正有含金量的点。很多人一听对象数组去重,第一反应就是序列化比较:
javascript复制const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三' }
];
const result = [...new Map(users.map(item => [JSON.stringify(item), item])).values()];
这个写法看起来简洁,但它有几个隐患。最典型的是对象里 key 的顺序不一致,{ id: 1, name: '张三' } 和 { name: '张三', id: 1 } JSON.stringify 出来的字符串就不同,会被误判为两条数据。另外,如果对象里有函数、undefined、Symbol 这些不会出现在 JSON 序列化结果里的字段,这些字段就被直接忽略了。更麻烦的是嵌套对象,某个对象内部字段稍一变,整条数据的唯一标识就变了,业务上这是不是你想要的效果,需要你想清楚。
大多数情况下,去重的真正诉求其实是“某个 key 或某几个 key 组合起来唯一”。比如用户列表里按 id 去重,或者埋点数据里按 eventName + pageUrl + userId 三者组合去重。这种按字段去重的场景,最实用的写法是用 Map:
javascript复制const users = [
{ id: 1, name: '张三', page: '/home' },
{ id: 2, name: '李四', page: '/about' },
{ id: 1, name: '张三', page: '/home' }
];
// 按 id 去重
const byId = [...new Map(users.map(user => [user.id, user])).values()];
// 按多个字段组合去重
const byCombination = [...new Map(
users.map(user => [`${user.id}-${user.page}`, user])
).values()];
Map 的 key 用的是严格相等判断,只要保证 key 拼出来是基本类型,就不会存在隐式转换的问题,也不会受对象内部字段顺序影响。配合 reduce 还能灵活控制保留哪一条数据:
javascript复制const result = users.reduce((acc, user) => {
const key = `${user.id}-${user.page}`;
if (!acc.has(key)) {
acc.set(key, user);
}
// 如果已经有同 key 的了,还能在这里按业务逻辑决定是保留旧值还是覆盖
// 比如 acc.set(key, user) 就是保留最后一条
return acc;
}, new Map());
console.log([...result.values()]);
reduce + Map 这种方式最大的价值在于控制权在你手上。想保留第一条,就判断 has 不 set;想保留最新一条,就直接 set 覆盖;想在同 key 下做字段合并,也能在 reduce 回调里操作。这种灵活性是 JSON.stringify 方案给不了的。
1.3 复杂数据类型去重:嵌套对象、Date、RegExp 的处理
除了普通对象数组,实际业务里还会遇到一些“怪类型”。比如数组里混着 Date 对象、RegExp 对象,或者对象里嵌套对象、嵌套数组。
对这种复杂结构,判断“重复”的标准就变得很微妙了。举个具体例子,一个列表里存的是日历事件,每个事件有 startTime(Date 对象)和 endTime(Date 对象)。按业务逻辑,两个事件开始时间相同就算重复。那你的去重 key 就应该是 event.startTime.getTime(),而不是把这个 Date 对象丢给 Set 去判断——因为 Date 对象是引用类型,即使两个 Date 指向同一个时间点,Set 也把它们当不同值。
处理方案依然是用 Map 拼 key,关键是 key 要能准确表达业务语义:
javascript复制const events = [
{ id: 1, startTime: new Date('2025-03-01T10:00:00'), endTime: new Date('2025-03-01T11:00:00') },
{ id: 2, startTime: new Date('2025-03-01T10:00:00'), endTime: new Date('2025-03-01T12:00:00') }
];
// 按开始时间去重,key 用时间戳
const uniqueEvents = [...new Map(
events.map(event => [event.startTime.getTime(), event])
).values()];
那如果深度嵌套的普通对象,确实没法按某个字段区分呢?这时候尽可能不要去写一个“深度比较”工具函数。深度比较的性能代价高,而且递归深度一大还有栈溢出的风险。更务实的做法是:在数据源头就加一个唯一 ID 字段,或者约定好一个组合 key。接口没有就给前端自己生成一个 hash,比如把对象拍平后取 sha1、md5 之类,虽然也需要考虑性能,但比运行时递归比较要稳得多。给数据生成唯一 ID 这件事,在数据清洗阶段做,比在去重阶段临时抱佛脚省心得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序:sort 的底层逻辑和应用陷阱
排序是另一个高频需求。普通数组排序、对象数组按字段排序、中文按拼音排序、数字和字符串混排,这些场景跟去重一样,都是表面上看起来简单,实际一跑就出幺蛾子。
2.1 默认 sort 不按数值大小排序,很多人会栽在这
JavaScript 的 Array.prototype.sort() 有一个非常容易踩的坑:不传比较函数时,所有元素会先被转成字符串,然后按 UTF-16 码元顺序排序。这意味着 [2, 11, 1] 排序后是 [1, 11, 2],因为 '11' 的字典序在 '2' 前面。我见过实际业务里这个问题导致分页数据乱掉的情况,因为后端返回的数据里把 id 拼成了字符串,前端直接 sort,页面顺序全是乱的。
要按数值大小排序,必须传比较函数:
javascript复制const arr = [2, 11, 1, 23, 4];
arr.sort((a, b) => a - b); // 升序
arr.sort((a, b) => b - a); // 降序
如果你在做一个表格排序功能,列里有整数、有小数、还有字符串标记,那就得先统一类型再做比较。比如把所有值先转成 number,解析不了的再单独做兜底。
顺便说一下 sort 的底层实现。现代 V8 引擎(Node 7.0 以上版本)对数组排序用的是 TimSort 算法。TimSort 的核心思想是:先找出数组里已经天然有序的子序列(叫做 run),再把这些 run 按一定规则归并。它利用了现实数据中经常存在的局部有序特性,所以最好的情况下时间复杂度是 O(n),平均和最坏也没有超过 O(n log n)。早年间 V8 使用的还是插入排序和快速排序结合的方案,对短数组用插入排序,长数组用快排,但快排最差会退化到 O(n²),所以后来才被 TimSort 全面替换。
你在业务里写排序时,一般不需要自己关心引擎是怎么实现的,但知道这一点能帮你理解一个现象:对一个基本有序的数组排序,现代 sort 的效率会高出预期不少。
2.2 中文按拼音排序、字符串排序、多字段排序
中文排序是排序里的一个高频坑。如果直接用 sort(),中文字符串会按 Unicode 编码排序,结果是“一”、“丁”、“万”这种按码点排出来的顺序,完全不是拼音顺序。换句话说,姓“陈”的会排在姓“赵”的前面,并不是因为拼音,而是因为陈的 Unicode 码点比赵小。
要让中文按拼音排序,正确的做法是使用 localeCompare:
javascript复制const names = ['张三', '李四', '王五', '陈六'];
// 按拼音升序
names.sort((a, b) => a.localeCompare(b, 'zh-CN'));
// 输出:['陈六', '李四', '王五', '张三']
localeCompare 的第二个参数传 'zh-CN',是用中文本地化规则做比较。千万别不传这个参数,不同浏览器默认 locale 不一样,排序结果可能不一致,尤其在生产环境里,用户浏览器的系统语言五花八门,同一个方法在不同机器上跑出的顺序不同,这坑相当隐蔽。
还有一类字符串排序,就是那种“自然排序”。比如文件名列表 ['file1', 'file2', 'file10'],普通字符串排序会得到 ['file1', 'file10', 'file2'],因为 '1' 比 '2' 的码点靠前。如果需要按文件序号排,就得把数字部分提取出来比较:
javascript复制const files = ['file1', 'file10', 'file2', 'file20'];
files.sort((a, b) => {
const numA = parseInt(a.match(/\d+/)?.[0] || '0', 10);
const numB = parseInt(b.match(/\d+/)?.[0] || '0', 10);
return numA - numB;
});
// 输出:['file1', 'file2', 'file10', 'file20']
多字段排序也是业务里天天用的。比如按价格从低到高排,价格一样按销量从高到低,再一样按上架时间从新到旧。写法上就是比较函数里一层层嵌套:
javascript复制const products = [
{ name: 'A', price: 20, sales: 300, createdAt: '2025-01-01' },
{ name: 'B', price: 20, sales: 100, createdAt: '2025-03-01' },
{ name: 'C', price: 10, sales: 500, createdAt: '2025-02-01' }
];
products.sort((a, b) => {
if (a.price !== b.price) return a.price - b.price;
if (a.sales !== b.sales) return b.sales - a.sales;
return new Date(b.createdAt) - new Date(a.createdAt);
});
这种链路式比较的写法,核心是“当前面的字段分出胜负就立刻返回,不再比较后续字段”。几个字段一层层往下加就行。注意这里排序不是纯函数,它改变的是原数组。想保留原始数组,得先 [...arr].sort(...) 或者用 arr.slice().sort(...)。
2.3 稳定性和原地排序:什么时候需要特别注意
Array.prototype.sort 在 ES2019 之后被规范要求必须是稳定排序。这是什么意思?稳定排序就是指:如果两个元素的比较结果相等,它们在排序后的相对位置保持和排序前一致。举个例子,有一组人先按身高排好了序,再用 sort 按年龄排序,稳定排序能保证年龄相同的人,身高顺序还是原来的。
这在业务里很重要。最常见的场景是表格的多列排序。用户先点了“类型”列,又点了“金额”列。你拿着一个排序后的数组再按金额排,如果引擎不稳定,类型相同的行在金额相同时可能被打乱,用户就会觉得表格“跳了一下”。一般浏览器里的 V8 引擎都是稳定排序了,但一些老旧的 WebView 内核未必遵循新规范,所以如果在写需要极端兼容的场景,建议自己封装一层按序字段排序的函数。
原地排序(in-place)也是一个要注意的点。sort 是原地修改原数组的,这点很多人写代码时没意识到。比如你拿到了后端返回的一个列表,分页展示前先排序,一不小心就会把原列表的顺序也改掉了。等下次需要按原始顺序重新展示时,数据已经乱了。正确的做法是用 [...list]、slice()、或者 Array.from 先把数组复制一份再排序。
javascript复制const sortedList = [...originalList].sort((a, b) => a.price - b.price);
这可能是我在 code review 里看到过最多的问题之一了。sort 不是返回新数组,而是修改原数组并返回同一个数组引用,这一点网上很多教程里没讲透,实战里酿事故的却不少。
3. 场景实战:去重和排序怎么配合使用
这一节我专门讲一个业务里很常见的问题:既要排序又要去重,顺序怎么处理?很多人上来就 new Set() 搞完再 sort,这没问题,但有些场景下,先排序再去重反而能带来额外的好处。下面把这些场景和优化点拆开讲。
3.1 先排序再去重:快慢指针原地去重的思路
先排序再去重,有个特殊优势:排序之后,重复的元素会变成相邻元素。这种有序数组去重,可以做到只扫描一遍,而且不用额外空间,这个技巧就是快慢指针。
快慢指针的思路非常简单:慢指针指向“最终去重结果数组的最后一个位置”,快指针遍历整个数组。因为数组已经排好序了,所以每当快指针指向的元素和慢指针指向的元素不一样时,就把慢指针往后移一位,把当前值填进去。如果一样,说明是重复值,直接跳过。
javascript复制function uniqueSorted(nums) {
if (nums.length === 0) return 0;
let slow = 0;
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] !== nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
// 返回去重后数组的长度,原地去重后的元素在 nums 前 slow+1 个位置
return slow + 1;
}
const arr = [1, 1, 2, 2, 3, 4, 4, 5];
const len = uniqueSorted(arr);
console.log(arr.slice(0, len)); // [1, 2, 3, 4, 5]
这个算法的复杂度是 O(n),空间复杂度 O(1)。相比先 Set 去重再排序,它额外省了一次完整数组的遍历和一份 Set 的内存开销。你要是遇到那种一次性加载了几万、几十万条数据的大列表(比如从本地缓存里读取的历史记录),这个优化是能直观感受到的。
这么写代码在项目里可能有点炫技,但它同样是面试高频题,理解了原理对深入理解数组操作也有帮助。实际写业务的时候,如果数据量不大,完全可以无脑先 new Set 去完重再 sort。
不过要注意一点:快慢指针只适用于“基本类型数组”和“按值唯一”的场景。如果你的数据是对象数组,即使排序了,对象之间也还是引用比较,做不到“排完序相邻即重复”的效果,除非你是按某个唯一字段排的序。
3.2 场景一:埋点上报数据清洗
做前端埋点的同学应该深有体会。前端把用户行为缓存到 localStorage 里,等攒到一定量再批量上报。这个过程中,用户可能会多次点击同一个按钮,或者页面切换导致同一事件被记录多次,上报前如果不做清洗,后端收到的数据里全是重复记录,统计结果就直接失真。
我去重时通常按“事件类型 + 页面路径 + 用户点击的目标标识 + 触发时间窗”来拼 key。为什么加时间窗?因为用户本来就可能在一个小时内点击同一个按钮两次,这两次都是有意义的行为,不能因为 key 一样就被去重掉。所以实际操作中会把时间戳按分钟归一到 key 里:
javascript复制const logs = [
{ event: 'click', page: '/home', target: 'btn-buy', timestamp: 1735000000000 },
// ... 非常多条
];
const uniqueLogs = [...new Map(
logs.map(log => {
const key = `${log.event}-${log.page}-${log.target}-${new Date(log.timestamp).getMinutes()}`;
return [key, log];
})
).values()];
// 上报前再按时间排个序,避免后端处理乱序数据
uniqueLogs.sort((a, b) => a.timestamp - b.timestamp);
这套组合在处理真实数据时非常管用。记住一个原则:去重 key 的设计,永远取决于业务语义,不要机械地把整条数据序列化后比一比。
3.3 场景二:省市区级联数据和三级联动排序
再讲一个更接地气的场景,前端做省市区三级联动。以前网上有人分享过省市区编码和名称的 JS 数据,这些数据的格式基本上是:
javascript复制const regions = [
{ code: '110000', name: '北京市', parentCode: '0' },
{ code: '110101', name: '东城区', parentCode: '110000' },
{ code: '310000', name: '上海市', parentCode: '0' },
// ...
];
这种数据从接口拿回来,顺序往往是乱的。如果直接用,省市区在联动下拉框里的展示顺序会乱七八糟。二级联动、三级联动的下拉框全是乱序,就很尴尬。
所以一般拿到原始数据后的标准动作是:先按拼音排序,再构建父子关系。可这里有个细节,区域的拼音排序不能直接对 name 做 localeCompare 吗?可以,但有时省里的“省”字和市里的“市”字会影响排序结果,更好的做法是给数据预处理阶段加一个拼音首字母字段。不过在不方便改数据结构的情况下:
javascript复制const sortedRegions = [...regions].sort((a, b) => {
// 先按父级分组,父级为 '0' 的是省,排到最前面
if (a.parentCode === '0' && b.parentCode !== '0') return -1;
if (a.parentCode !== '0' && b.parentCode === '0') return 1;
// 同级区域按拼音排
return a.name.localeCompare(b.name, 'zh-CN');
});
这个场景也说明一个道理:排序不是永远的“最后一步”,它是你在组装数据过程中的一个环节。每一步输出都是下一步的输入,顺序错了,后面的联动关系就乱了。
3.4 场景三:表格排序配合对象数组去重
表格里数据去重排序也很常见。比如你在做客户管理,表格里每条记录代表一个客户,但导入数据时同一个客户可能导入了多次,客户编号相同,姓名可能有些微差异。这种场景下,按客户编号去重是正确的业务选择,不能因为姓名不同就当成两个人。
我的建议是把去重和排序封装成两个独立函数,组合使用:
javascript复制function deduplicateByField(list, field) {
return [...new Map(list.map(item => [item[field], item])).values()];
}
function sortByFields(list, rules) {
return [...list].sort((a, b) => {
for (const { field, order = 'asc' } of rules) {
if (a[field] > b[field]) return order === 'asc' ? 1 : -1;
if (a[field] < b[field]) return order === 'asc' ? -1 : 1;
}
return 0;
});
}
// 组合使用:先按客户编号去重,再按创建时间和姓名排序
const cleaned = deduplicateByField(customers, 'customerId');
const sorted = sortByFields(cleaned, [
{ field: 'createdAt', order: 'desc' },
{ field: 'name', order: 'asc' }
]);
两个函数分开维护,后续加需求不用改逻辑,直接加字段规则就行。这比在业务堆里直接写 for 循环和 sort 回调要清晰得多。
4. 性能、边界情况和踩坑实录
最后一个部分,聊聊性能和边界情况。这块内容就是我一路踩坑积累下来的。内容不长,但每一条都是在真实业务现场血泪验证过的。
4.1 大数据量下的性能对比:Set 为什么几乎总是赢
如果你是纯数据清理场景(不要求保持原始顺序),大数据量下 Set 的性能通常是最好的。原因很简单,Set 内部是哈希表,查找和插入的平均时间复杂度都是 O(1)。而 filter + indexOf 每次调用 indexOf 都要线性扫描数组,整体是 O(n²),数据量一上去就差别显著。
我做过一个简单的基准测试,数组长度 10 万,元素是随机整数:
| 方法 | 耗时(相对) | 内存占用 | 推荐场景 |
|---|---|---|---|
| Set 展开 | 极低 | 高(创建新数组) | 通用首选 |
| Map 去重 | 低 | 高 | 需要自定义 key |
| filter + indexOf | 高 | 低(不创建 Map/Set) | 超低版本兼容 |
| 快慢指针(需预排序) | 最低 | 最低 | 内存敏感 + 允许原地修改 |
快慢指针是空间性能最好的一种,因为它不创建额外容器。但代价是必须预先排序,而且只能处理基本类型数组。数据量再大一点,比如百万级,Set 虽然内存占用高一些,但现代引擎的底层优化已经很成熟,仍然是可以接受的方案。
这里有个容易被忽略的点:new Set(arr) 之后展开,性能瓶颈其实在展开操作,不在 Set 构建。所以如果你最终想要一个数组,直接用 [...new Set(arr)] 或 Array.from(new Set(arr)) 都行,后者在大数组下性能稍好一点,因为 Array.from 是按长度预分配空间的,而展开运算符是不断 push,可能触发多次扩容。虽然这个差距在大多数场景里感知不到,但追求极致时可以留意一下。
4.2 容易翻车的边界情况:NaN、引用类型、Symbol、稀疏数组
几个边界情况,每一个都是翻车现场。
第一个是 NaN。indexOf 和 includes 对 NaN 的处理不一样。arr.indexOf(NaN) 永远返回 -1,所以过滤方案遇上 NaN 会遗漏。但 Set 和 includes 都能正确识别 NaN。所以数组里可能混入 NaN 的场景,只能用 Set 或 Map。
javascript复制const arr = [NaN, NaN, 1, 2];
console.log(arr.indexOf(NaN)); // -1
console.log([...new Set(arr)]); // [NaN, 1, 2]
第二个是引用类型。两个内容相同的对象,在 Set 里是两条记录,因为 Set 比较的是引用地址。所以对象数组去重,除非你有意识地生成 key,否则别指望能自动去重。
第三个是 Symbol。Symbol 可以出现在数组里,new Set(arr) 可以正确去重,但如果是对象里的 Symbol 字段,JSON.stringify 会直接忽略它,所以序列化方案遇到 Symbol 字段时就失效了。
第四个是稀疏数组。new Set(arr) 会保留稀疏数组的稀疏位吗?不会,set 遍历会跳过空位,展开后空位会被当成 undefined 处理,导致结果里多出 undefined。所以处理用户输入类数据时,最好先做一次过滤:
javascript复制const dirty = [1, , 2, , 3];
console.log([...new Set(dirty)]); // [1, undefined, 2, 3]
如果业务上不允许 undefined 存在,那就先 filter(item => item !== undefined) 再走 Set。
4.3 实用的工具函数封装:安全排序 + 去重合并
最后分享一个我项目里常用的小工具,把安全排序和多维度去重揉在一起。这套工具的核心思路是:不去试图覆盖所有场景,而是把“排序规则”和“去重 key 规则”作为可配置项,这样代码的可复用性一下就上来了。
javascript复制// 安全的深拷贝 + 排序(避免修改原数组)
function stableSortBy(list, fieldSelector, order = 'asc') {
return list.map((item, index) => ({ item, index }))
.sort((a, b) => {
const av = fieldSelector(a.item);
const bv = fieldSelector(b.item);
if (av === bv) return a.index - b.index; // 保持原顺序
if (order === 'desc') return bv > av ? 1 : -1;
return av > bv ? 1 : -1;
})
.map(entry => entry.item);
}
// 按一个或多个 key 去重
function deduplicateBy(list, keySelector) {
const map = new Map();
for (const item of list) {
const key = keySelector(item);
if (!map.has(key)) {
map.set(key, item);
}
}
return [...map.values()];
}
使用示例:
javascript复制const data = [
{ id: 3, name: 'apple', price: 5 },
{ id: 1, name: 'banana', price: 4 },
{ id: 3, name: 'apple', price: 5 }
];
const unique = deduplicateBy(data, item => item.id);
const sorted = stableSortBy(unique, item => item.price);
console.log(sorted);
这套工具的健壮性在于,它不依赖对象的 JSON 序列化,也不依赖默认的 sort 行为,排序和去重的决定权完全掌握在调用方手里。后续不管是列表加了字段还是改了排序规则,调用处改一行参数就行,核心逻辑一行都不用动。
从实战角度来说,我强烈建议每个前端项目里都维护这样一套小小的纯函数工具库,不需要引 lodash,自己写的反而更贴合业务。去重和排序这种基础能力,值得花一点时间抽出来沉淀,以后每个项目都能用。
