本文同时发布于我的博客https://www.toobug.net/articl...javascript
JavaScript的数组去重是一个老生常谈的话题了。随便搜一搜就能找到很是多不一样版本的解法。html
昨天在微博上看到一篇文章,也写数组去重,主要推崇的方法是将利用数组元素看成对象key来去重。我在微博转发了“用对象key去重不是个好办法…”而后做者问什么才是推荐的方法。java
细想一下,这样一个看似简单的需求,若是要作到完备,涉及的知识和须要注意的地方着实很多,因而诞生此文。git
要去重,首先得定义,什么叫做“重复”,即具体到代码而言,两个数据在什么状况下能够算是相等的。这并非一个很容易的问题。程序员
对于原始值而言,咱们很容易想到1
和1
是相等的,'1'
和'1'
也是相等的。那么,1
和'1'
是相等的么?github
若是这个问题还好说,只要回答“是”或者“不是”便可。那么下面这些状况就没那么容易了。正则表达式
初看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()
方法:
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()
是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]
),因此相等性能够本身针对上文说到的各类状况加以特殊处理。惟一比较受诟病的是使用了双重循环,时间复杂度比较高,性能通常。
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; }
能够看到,使用对象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; }
既然都用到了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 | 正确 |
最后的最后:任何脱离场景谈技术都是妄谈,本文也同样。去重这道题,没有正确答案,请根据场景选择合适的去重方法。