本文同时宣布于个人博客https://www.toobug.net/articl…
JavaScript的数组去重是一个陈词滥调的话题了。随意搜一搜就能够找到异常多差异版本的解法。
昨天在微博上看到一篇文章,也写数组去重,重要推重的要领是将运用数组元素看成对象key往来来往重。我在微博转发了“用对象key去重不是个好办法…”然后作者问什么才是引荐的要领。
细想一下,如许一个看似简朴的需求,假如要做到完整,触及的学问和须要注重的处所实在不少,因而降生此文。
定义反复(相称)
要去重,首先得定义,什么叫作“反复”,即详细到代码而言,两个数据在什么状况下能够算是相称的。这并不是一个很轻易的题目。
关于原始值而言,我们很轻易想到1
和1
是相称的,'1'
和'1'
也是相称的。那末,1
和'1'
是相称的么?
假如这个题目还好说,只需回复“是”或许“不是”即可。那末下面这些状况就没那末轻易了。
NaN
初看NaN
时,很轻易把它当做和null
、undefined
一样的自力数据范例。但实在,它是数字范例。
// number
console.log(typeof NaN);
依据范例,比较运算中只需有一个值为NaN,则比较效果为false
,所以会有下面这些看起来略蛋疼的结论:
// 全都是false
0 < NaN;
0 > NaN;
0 == NaN;
0 === NaN;
以末了一个表达式0 === NaN
为例,在范例中有明白规定(http://www.ecma-international…):
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;
// true
console.log(isNaN(a) && isNaN(b));
原始值和包装对象
看完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()
在一些版本的去重中,用到了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…),我们晓得了indexOf()
运用的是严厉比较,也就是===
。
再次强调:根据前文所述,
===
不能处置惩罚NaN
的相称性推断。
Array.prototype.includes()
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/ecma26…)。
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; i<len; i++) {
isRepeat = false;
for(var j=i+1; j<len; j++) {
if(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; i<len; i++){
for(var j=i+1; j<len; j++){
if(arr[i] === arr[j]){
j = ++i;
}
}
ret.push(arr[i]);
}
return ret;
}
这类计划没什么大题目,用于去重的比较部份也是本身编写完成(arr[i] === arr[j]
),所以相称机能够本身针对上文说到的种种状况加以特别处置惩罚。唯一比较受诟病的是运用了两重轮回,时候庞杂度比较高,机能平常。
运用对象key往来来往重
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = {};
for(var i=0; i<len; i++){
if(!tmp[arr[i]]){
tmp[arr[i]] = 1;
ret.push(arr[i]);
}
}
return ret;
}
这类要领是运用了对象(tmp
)的key不能够反复的特征来举行去重。但因为对象key只能为字符串,因而这类去重要领有很多局限性:
没法辨别隐式范例转换成字符串后一样的值,比方
1
和'1'
没法处置惩罚庞杂数据范例,比方对象(因为对象作为key会变成
[object Object]
)特别数据,比方
'__proto__'
会挂掉,因为tmp
对象的__proto__
属性没法被重写
关于第一点,有人提出能够为对象的key增添一个范例,或许将范例放到对象的value中来处理:
function unique(arr) {
var ret = [];
var len = arr.length;
var tmp = {};
var tmpKey;
for(var i=0; i<len; i++){
tmpKey = typeof 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; i<len; i++){
tmpKey = typeof arr[i] + JSON.stringify(arr[i]);
if(!tmp[tmpKey]){
tmp[tmpKey] = 1;
ret.push(arr[i]);
}
}
return ret;
}
Map Key
能够看到,运用对象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; i<len; i++){
if(!tmp.get(arr[i])){
tmp.set(arr[i], 1);
ret.push(arr[i]);
}
}
return ret;
}
Set
既然都用到了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 | 准确 |
末了的末了:任何离开场景谈手艺都是妄谈,本文也一样。去重这道题,没有准确答案,请依据场景挑选适宜的去重要领。