重要申明:感谢原文作者——toobug,与看原文请猛戳这里!我只是个转载者!
JavaScript 的数组去重是一个老生常谈的话题了。随便搜一搜就能找到非常多不同版本的解法。
昨天在微博上看到一篇文章,也写数组去重,主要推崇的方法是将利用数组元素当作对象 key 来去重。我在微博转发了“用对象 key 去重不是个好办法…”然后作者问什么才是推荐的方法。
细想一下,这样一个看似简单的需求,如果要做到完备,涉及的知识和需要注意的地方着实不少,于是诞生此文。
要去重,首先得定义,什么叫作“重复”,即具体到代码而言,两个数据在什么情况下可以算是相等的。这并不是一个很容易的问题。
对于原始值而言,我们很容易想到 1
和 1
是相等的, '1'
和 '1'
也是相等的。那么, 1
和 '1'
是相等的么?
如果这个问题还好说,只要回答“是”或者“不是”即可。那么下面这些情况就没那么容易了。
初看 NaN
时,很容易把它当成和 null
、 undefined
一样的独立数据类型。但其实,它是数字类型。
console.log(typeof NaN); // number
根据规范,比较运算中只要有一个值为 NaN
,则比较结果为 false
,所以会有下面这些看起来略蛋疼的结论:
0 < NaN; // 全都是false
0 > NaN;
0 == NaN;
0 === NaN;
以最后一个表达式 0 === NaN
为例,在规范中有明确规定 http://www.ecma-international.org/ecma-262/6.0/#sec-strict-equality-comparison :
- If Type(x) is Number, then
- If x is NaN, return false.
- If y is NaN, return false.
- If x is the same Number value as y, return true.
- If x is +0 and y is −0, return true.
- If x is −0 and y is +0, return true.
- Return false.
这意味着任何涉及到 NaN
的情况都不能简单地使用比较运算来判定是否相等。比较科学的方法只能是使用 isNaN()
:
var a = NaN;
var b = NaN;
console.log(isNaN(a) && isNaN(b)); // true
看完 NaN
是不是头都大了。好了,我们来轻松一下,看一看原始值和包装对象这一对冤家。
如果你研究过 'a'.trim()
这样的代码的话,不知道是否产生过这样的疑问: 'a'
明明是一个原始值(字符串),它为什么可以直接调用 .trim()
方法呢?当然,很可能你已经知道答案:因为 JS 在执行这样的代码的时候会对原始值做一次包装,让 'a'
变成一个字符串对象,然后执行这个对象的方法,执行完之后再把这个包装对象脱掉。可以用下面的代码来理解:
// 'a'.trim();
var tmp = new String('a');
tmp.trim();
这段代码只是辅助我们理解的。但包装对象这个概念在 JS 中却是真实存在的。
var a = new String('a');
var b = 'b';
a
即是一个包装对象,它和 b
一样,代表一个字符串。它们都可以使用字符串的各种方法(比如 trim()
),也可以参与字符串运算( +
号连接等)。
但他们有一个关键的区别:类型不同!
typeof a; // object
typeof b; // string
在做字符串比较的时候,类型的不同会导致结果有一些出乎意料:
var a1 = 'a';
var a2 = new String('a');
var a3 = new String('a');
a1 == a2; // true
a1 == a3; // true
a2 == a3; // false
a1 === a2; // false
a1 === a3; // false
a2 === a3; // false
同样是表示字符串 a
的变量,在使用严格比较时竟然不是相等的,在直觉上这是一件比较难接受的事情,在各种开发场景下,也非常容易忽略这些细节。
在涉及比较的时候,还会碰到对象。具体而言,大致可以分为三种情况:纯对象、实例对象、其它类型的对象。
纯对象(plain object)具体指什么并不是非常明确,为减少不必要的争议,下文中使用纯对象指代由字面量生成的、成员中不含函数和日期、正则表达式等类型的对象。
如果直接拿两个对象进行比较,不管是 ==
还是 ===
,毫无疑问都是不相等的。但是在实际使用时,这样的规则是否一定满足我们的需求?举个例子,我们的应用中有两个配置项:
// 原来有两个属性
// var prop1 = 1;
// var prop2 = 2;
// 重构代码时两个属性被放到同一个对象中
var config = {
prop1: 1,
prop2: 2
};
假设在某些场景下,我们需要比较两次运行的配置项是否相同。在重构前,我们分别比较两次运行的 prop1
和 prop2
即可。而在重构后,我们可能需要比较 config
对象所代表的配置项是否一致。在这样的场景下,直接用 ==
或者 ===
来比较对象,得到的并不是我们期望的结果。
在这样的场景下,我们可能需要自定义一些方法来处理对象的比较。常见的可能是通过 JSON.stringify()
对对象进行序列化之后再比较字符串,当然这个过程并非完全可靠,只是一个思路。
如果你觉得这个场景是无中生有的话,可以再回想一下断言库,同样是基于对象成员,判断结果是否和预期相符。
实例对象主要指通过构造函数(类)生成的对象。这样的对象和纯对象一样,直接比较都是不等的,但也会碰到需要判断是否是同一对象的情况。一般而言,因为这种对象有比较复杂的内部结构(甚至有一部分数据在原型上),无法直接从外部比较是否相等。比较靠谱的判断方法是由构造函数(类)来提供静态方法或者实例方法来判断是否相等。
var a = Klass();
var b = Klass();
Klass.isEqual(a, b);
其它对象主要指数组、日期、正则表达式等这类在 Object
基础上派生出来的对象。这类对象各有各的特殊性,一般需要根据场景来构造判断方法,决定两个对象是否相等。
比如,日期对象,可能需要通过 Date.prototype.getTime()
方法获取时间戳来判断是否表示同一时刻。正则表达式可能需要通过 toString()
方法获取到原始字面量来判断是否是相同的正则表达式。
==
和 ===
在一些文章中,看到某一些数组去重的方法,在判断元素是否相等时,使用的是 ==
比较运算符。众所周知,这个运算符在比较前会先查看元素类型,当类型不一致时会做隐式类型转换。这其实是一种非常不严谨的做法。因为无法区分在做隐匿类型转换后值一样的元素,例如 0
、 ''
、 false
、 null
、 undefined
等。
同时,还有可能出现一些只能黑人问号的结果,例如:
[] == ![]; //true
在一些版本的去重中,用到了 Array.prototype.indexOf()
方法:
function unique(arr) {
return arr.filter(function(item, index){
// indexOf返回第一个索引值,如果当前索引不是第一个索引,说明是重复值
return arr.indexOf(item) === index;
});
}
function unique(arr) {
var ret = [];
arr.forEach(function(item){
if(ret.indexOf(item) === -1){
ret.push(item);
}
});
return ret;
}
既然 ==
和 ===
在元素相等的比较中是有巨大差别的,那么 indexOf
的情况又如何呢?大部分的文章都没有提及这点,于是只好求助规范。通过规范 http://www.ecma-international.org/ecma-262/6.0/#sec-array.prototype.indexof ,我们知道了 indexOf()
使用的是严格比较,也就是 ===
。
再次强调:按照前文所述,
===
不能处理NaN
的相等性判断。
Array.prototype.includes()
是 ES2016 中新增的方法,用于判断数组中是否包含某个元素,所以上面使用 indexOf()
方法的第二个版本可以改写成如下版本:
function unique(arr) {
var ret = [];
arr.forEach(function(item){
if(!ret.includes(item)){
ret.push(item);
}
});
return ret;
}
那么,你猜猜,includes()
又是用什么方法来比较的呢?如果想当然的话,会觉得肯定跟 indexOf()
一样喽。但是,程序员的世界里最怕想当然。翻一翻规范,发现它其实是使用的另一种比较方法,叫作 “SameValueZero” 比较 https://tc39.github.io/ecma262/2016/#sec-samevaluezero 。
- If Type(x) is different from Type(y), return false.
- If Type(x) is Number, then
- If x is NaN and y is NaN, return true.
- If x is +0 and y is -0, return true.
- If x is -0 and y is +0, return true.
- If x is the same Number value as y, return true.
- Return false.
- Return SameValueNonNumber(x, y).
注意 2.a
,如果 x
和 y
都是 NaN
,则返回 true
!也就是 includes()
是可以正确判断是否包含了 NaN
的。我们写一段代码验证一下:
var arr = [1, 2, NaN];
arr.indexOf(NaN); // -1
arr.includes(NaN); // true
可以看到 indexOf()
和 includes()
对待 NaN
的行为是完全不一样的。
从上面的一大段文字中,我们可以看到,要判断两个元素是否相等(重复)并不是一件简单的事情。在了解了这个背景后,我们来看一些前面没有涉及到的去重方案。
双重遍历是最容易想到的去重方案:
function unique(arr) {
var ret = [];
var len = arr.length;
var isRepeat;
for(var i=0; ifalse;
for(var j=i+1; jif(arr[i] === arr[j]){
isRepeat = true;
break;
}
}
if(!isRepeat){
ret.push(arr[i]);
}
}
return ret;
}
双重遍历还有一个优化版本,但是原理和复杂度几乎完全一样:
function unique(arr) {
var ret = [];
var len = arr.length;
for(var i=0; ifor(var j=i+1; jif(arr[i] === arr[j]){
j = ++i;
}
}
ret.push(arr[i]);
}
return ret;
}
这种方案没什么大问题,用于去重的比较部分也是自己编写实现( arr[i] === arr[j]
),所以相等性可以自己针对上文说到的各种情况加以特殊处理。唯一比较受诟病的是使用了双重循环,时间复杂度比较高,性能一般。
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = {};
for(var i=0; iif(!tmp[arr[i]]){
tmp[arr[i]] = 1;
ret.push(arr[i]);
}
}
return ret;
}
这种方法是利用了对象( tmp
)的 key 不可以重复的特性来进行去重。但由于对象 key 只能为字符串,因此这种去重方法有许多局限性:
1
和 '1'
[object Object]
)'__proto__'
会挂掉,因为 tmp
对象的 __proto__
属性无法被重写对于第一点,有人提出可以为对象的 key 增加一个类型,或者将类型放到对象的 value 中来解决:
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = {};
var tmpKey;
for(var i=0; itypeof arr[i] + arr[i];
if(!tmp[tmpKey]){
tmp[tmpKey] = 1;
ret.push(arr[i]);
}
}
return ret;
}
该方案也同时解决第三个问题。
而第二个问题,如果像上文所说,在允许对对象进行自定义的比较规则,也可以将对象序列化之后作为 key 来使用。这里为简单起见,使用 JSON.stringify()
进行序列化。
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = {};
var tmpKey;
for(var i=0; itypeof arr[i] + JSON.stringify(arr[i]);
if(!tmp[tmpKey]){
tmp[tmpKey] = 1;
ret.push(arr[i]);
}
}
return ret;
}
可以看到,使用对象 key 来处理数组去重的问题,其实是一件比较麻烦的事情,处理不好很容易导致结果不正确。而这些问题的根本原因就是因为 key 在使用时有限制。
那么,能不能有一种 key 使用没有限制的对象呢?答案是——真的有!那就是 ES2015 中的 Map
。
Map
是一种新的数据类型,可以把它想象成 key 类型没有限制的对象。此外,它的存取使用单独的get()
、set()
接口。
var tmp = new Map();
tmp.set(1, 1);
tmp.get(1); // 1
tmp.set('2', 2);
tmp.get('2'); // 2
tmp.set(true, 3);
tmp.get(true); // 3
tmp.set(undefined, 4);
tmp.get(undefined); // 4
tmp.set(NaN, 5);
tmp.get(NaN); // 5
var arr = [], obj = {};
tmp.set(arr, 6);
tmp.get(arr); // 6
tmp.set(obj, 7);
tmp.get(obj); // 7
由于 Map
使用单独的接口来存取数据,所以不用担心 key 会和内置属性重名(如上文提到的 __proto__
)。使用 Map
改写一下我们的去重方法:
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = new Map();
for(var i=0; iif(!tmp.get(arr[i])){
tmp.set(arr[i], 1);
ret.push(arr[i]);
}
}
return ret;
}
既然都用到了 ES2015 ,数组这件事情不能再简单一点么?当然可以。
除了 Map
以外, ES2015 还引入了一种叫作 Set
的数据类型。顾名思义, Set
就是集合的意思,它不允许重复元素出现,这一点和数学中对集合的定义还是比较像的。
var s = new Set();
s.add(1);
s.add('1');
s.add(null);
s.add(undefined);
s.add(NaN);
s.add(true);
s.add([]);
s.add({});
如果你重复添加同一个元素的话,Set
中只会存在一个。包括 NaN
也是这样。于是我们想到,这么好的特性,要是能和数组互相转换,不就可以去重了吗?
function unique(arr){
var set = new Set(arr);
return Array.from(set);
}
我们讨论了这么久的事情,居然两行代码搞定了,简直不可思议。
然而,不要只顾着高兴了。有一句话是这么说的“不要因为走得太远而忘了为什么出发”。我们为什么要为数组去重呢?因为我们想得到不重复的元素列表。而既然已经有 Set
了,我们为什么还要舍近求远,使用数组呢?是不是在需要去重的情况下,直接使用 Set
就解决问题了?这个问题值得思考。
最后,用一个测试用例总结一下文中出现的各种去重方法:
var arr = [1,1,'1','1',0,0,'0','0',undefined,undefined,null,null,NaN,NaN,{},{},[],[],/a/,/a/]
console.log(unique(arr));
测试中没有定义对象的比较方法,因此默认情况下,对象不去重是正确的结果,去重是不正确的结果。
方法 | 结果 | 说明 |
---|---|---|
indexOf#1 | NaN被去掉 | |
indexOf#2 | NaN重复 | |
includes | 正确 | |
双重循环#1 | NaN重复 | |
双重循环#2 | NaN重复 | |
对象#1 | 字符串和数字无法区分, 对象、数组、正则表达式被去重 | |
对象#2 | 对象、数组、正则表达式被去重 | |
对象#3 | 对象、数组被去重, 正则表达式被消失 | JSON.stringify(/a/)结果为{}, 和空对象一样 |
Map | 正确 | |
Set | 正确 |
最后的最后:任何脱离场景谈技术都是妄谈,本文也一样。去重这道题,没有正确答案,请根据场景选择合适的去重方法。