JavaScript 中数组去重的方法有很多,但是每种方法绕不过的就是判断元素的相等。由于 JS 动态数据类型与隐式转换的关系,判断相等时会有一些特性的不同。有时候生硬的去记忆效果不好,不如从数组去重的例子中学习会有更好的理解。
JavaScript 数据类型有:字符串(string),数值(number),布尔(boolean),undefined,null,引用类型,es6中还有 symbol。
判断相等的方法:==
,===
,Object.is,SameValueZero
以下方法统一测试数组:
var array = [-0,-0,0,0,+0,+0,false,"0","0",false,undefined,null,true,"true",NaN,NaN,'NaN',{},{}];
方法一:indexOf
function unique(arr){
var array = [];
for(var i = 0;i<arr.length;i++){
if(array.indexOf(arr[i])==-1){
array.push(arr[i])
}
}
return array;
}
unique(array);
//output:[-0, false, "0", undefined, null, true, "true", NaN, NaN, "NaN", {…}, {…}]
indexOf 按严格相等来查找元素在数组中的索引,也就是说与===
是一样的。从以上输出的结果来看,严格相等不会区分0与+0,-0;不会有隐式转换等。
+0 === 0; //true
-0 === 0; //true
NaN === NaN; //false
这里需要注意的是判断对象的相等,在 JS 中,并不是判断判断对象内的属性值,而是判断在等式的两边是否是同一个对象引用,也可以说是判断引用地址是否相同。
{}==={}; //false
如果我一定要去除数组中重复的空对象,有没有办法呢?也是有办法的,后面我会解释。
因为 indexOf 是 es5 才出现的,更早版本的浏览器是不支持的。所以使用的更早的或最多是下面这种方法。
方法二:===
function unique(arr){
for(var i=0;i<arr.length;i++){
for(var j=i+1;j<arr.length;j++){
if(arr[i]===arr[j]){
arr.splice(j,1);
j--;
}
}
}
return arr;
}
//output:[-0, false, "0", undefined, null, true, "true", NaN, NaN, "NaN", {…}, {…}]
这里 splice 方法要比 indexOf 实现的要早,但他并不是关键。用不用 splice 方法无关紧要,你可以在函数内部再创建一个空数组,在判断是否相等后将唯一值放入你创建的数组中。这里采用这种方法是为了减少循环次数。这里的===
能不能换成==
呢?答案是不能的。
0 == false; //true
undefined == null; //true
==
是存在隐式转换的,js 是动态类型语言,隐式转换给 js 语言带来了很大的灵活性,但有的时候不注意也会带来很多的麻烦。对于隐式转换是一个需要去探讨的知识点,因为往往有 “when?” 和 “how?” 两大疑问,即什么时候需要转换,到底是怎么转换的?(此处只讨论相等,不讨论隐式转换)只有你在确定了要判断值的数据类型时才去用==
,比如上面的 indexOf ,确定了输出结果一定为一个 number 类型的值。
关于“0”和“NaN”
+0和-0是否相等?在有理数的四则运算中区分+0和-0是没有必要的,但在微积分的计算中是需要区分的。在计算机内部的机器码表示上,+0和-0也是不同的,因为机器码的符号位、反码和补码的缘故。如果你不需要区分+0与-0,可以使用===
,也可以用 Object.is() 区分。
+0===-0; //true
Object.is(0,-0); //false 这里默认你输入的0就是+0
NaN 的数据类型是 number,虽然他是“not a number”的缩写,表示的是值不为一个数。
typeof NaN; //number
那为什么 NaN 和自身不相等呢?
NaN === NaN; //false
这是因为 NaN 在机器码中并不是一个确定的值,它是一类二进制码的统称。在IEEE 754 双精度表示中,每一个 number都一样表示为:
s表示符号位,M表示有效数字,E表示指数位。
当 E 位全为1,M位不全为0时,表示 NaN。
以上双精度浮点数的表述是简化了的,详细的内容可以网上查阅 IEEE 754的标准文档。
方法三:Object.is
如果想要去重 NaN,可以使用 Object.is();
Object.is(NaN,NaN); //true
所以可以把方法改写为:
function unique(arr){
for(var i=0;i<arr.length;i++){
for(var j=i+1;j<arr.length;j++){
if(Object.is(arr[i],arr[j])){
arr.splice(j,1);
j--;
}
}
}
return arr;
}
//output:[-0, 0, false, "0", undefined, null, true, "true", NaN, "NaN", {…}, {…}]
注意 Object.is 只在较高版本的浏览器中使用。
方法四:hasOwnProperty
前面说了0和NaN,还没有处理数组中的对象类型的数据。这里指的是引用类型的数据,包括 Array、Object,es6出现的 Map、Set 等。上面测试用例中的空对象是引用对象的一个代表。在 JS 中判断引用对象是否相等实际上是判断引用地址是否相同,也就是判断等式两边的操作数是否引用的内存中同一对象。
{} === {}; //false
var o1 = {};
var o2 = o1;
o1 === o2; //true
如果我的关注点不在对象本身,而在对象内存储的信息,比方说:
[1,2,3] ?== [1,2,3];
{a:1,b:2} ?== {a:1,b:2}
你可能会在有些博客上看到用这种方法去重空对象:
function unique1(arr){
var obj = {};
return arr.filter(function(item){
return obj.hasOwnProperty(typeof item + item)?false:(obj[typeof item + item]=true);
})
}
这种方法就是将数组中的值转换为字符串,再作为 key 传入到对象中,用 hasOwnproperty 判断存在否。因为 hasOwnproperty 方法判断的都是字符串,倒是不用繁琐的考虑数据类型带来的问题。但是这种方法是有问题的,并不推荐使用。问题就出在+
号操作符所带来的隐式转换。
typeof item +item;
这个用法很巧妙
typeof NaN + NaN; //"numberNaN"
typeof 'NaN' + 'NaN'; //"stringNaN"
typeof [1,2,3] + [1,2,3]; //"object1,2,3"
typeof {} + {}; //"object[object Object]"
当数组中出现多个 object 时,值都会是"object[object Object]",这样只有第一个会保留,后面的的会去除,显然这种行为是错误的。
所以+
操作符数组中只有基本数据类型和数组是适用的,但是有普通对象或者 es6 出现的 map、set 是是不适用的。为什么数组和 map、set 类型的隐式转换不同?主要是因为重写的 toString 方法的不同。
JSON.stringify 方法能解决一些问题:
function unique2(arr){
var obj = {};
return arr.filter(function(item){
return obj.hasOwnProperty(JSON.stringify(item))?false:(obj[JSON.stringify(item)]=true);
})
}
//test:arr = [{a:1,b:2},{a:1,b:2},{},{}]
//output:[{a:1,b:2},{}]
JSON.stringify 似乎解决了我们想要的对象去重,但是还是有问题,比如 NaN,会被转换成 null,对象中的循环引用等,还有一堆其他需要注意的规则。
unique1 和 unique2 好像都不能完美的解决问题。当数组中不包含对象时,用 unique1,去重对象,可以用JSON.stringify。
其实后来我想想,去重空对象真的有意义吗?
或者说空对象真的是空吗?
var o1 = {};
var o2 = Object.create(null);
JSON.tringify(o1) === JSON.stringify(o2); //true
o2 是没有原型的空对象,o1是有原型的空对象,两个都是空对象,他们相等吗?
方法五:Map
function unique(arr){
var map = new Map;
return arr.filter(function(item){
if(map.has(item)){
return false;
}else{
map.set(item,true);
return true;
}
})
}
//output:[-0, false, "0", undefined, null, true, "true", NaN, "NaN", {…}, {…}]
Map、Set 和 Array 的 includes 方法都是基于 sameValueZero 算法的。
sameValueZero:
NaN
是与NaN
相等的(虽然NaN !== NaN
),剩下所有其它的值是根据===
运算符的结果判断是否相等。
方法六:Set
[...new Set(array)];
简单方便。JavaScript 数组去重之所以有这么一大堆特殊情况,好像复杂许多,也就是 JS 动态语言的特性造成的。如果像 Java 那样支持类型化数组和泛型,那估计就没这么多麻烦事了。当然,JavaScript 的优点也是他这样的灵活性,编程时应尽量规避他的那些不好的特性,避免去踩那些坑。
参考文章
-
JavaScript数组去重(12种方法,史上最全)https://segmentfault.com/a/1190000016418021
-
Javascript中number类型的二进制表示 https://www.jianshu.com/p/ab2bc4d7e001
-
JavaScript 中的相等性判断 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Equality_comparisons_and_sameness#零值相等