也谈JavaScript数组去重

本文同时宣布于个人博客https://www.toobug.net/articl…

JavaScript的数组去重是一个陈词滥调的话题了。随意搜一搜就能够找到异常多差异版本的解法。

昨天在微博上看到一篇文章,也写数组去重,重要推重的要领是将运用数组元素看成对象key往来来往重。我在微博转发了“用对象key去重不是个好办法…”然后作者问什么才是引荐的要领。

细想一下,如许一个看似简朴的需求,假如要做到完整,触及的学问和须要注重的处所实在不少,因而降生此文。

定义反复(相称)

要去重,首先得定义,什么叫作“反复”,即详细到代码而言,两个数据在什么状况下能够算是相称的。这并不是一个很轻易的题目。

关于原始值而言,我们很轻易想到11是相称的,'1''1'也是相称的。那末,1'1'是相称的么?

假如这个题目还好说,只需回复“是”或许“不是”即可。那末下面这些状况就没那末轻易了。

NaN

初看NaN时,很轻易把它当做和nullundefined一样的自力数据范例。但实在,它是数字范例。

// number
console.log(typeof NaN);

依据范例,比较运算中只需有一个值为NaN,则比较效果为false,所以会有下面这些看起来略蛋疼的结论:

// 全都是false
0 < NaN;
0 > NaN;
0 == NaN;
0 === NaN;

以末了一个表达式0 === NaN为例,在范例中有明白规定(http://www.ecma-international…):

  1. If Type(x) is Number, then

    1. If x is NaN, return false.

    2. If y is NaN, return false.

    3. If x is the same Number value as y, return true.

    4. If x is +0 and y is −0, return true.

    5. If x is −0 and y is +0, return true.

    6. 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
};

假设在某些场景下,我们须要比较两次运转的设置项是不是雷同。在重构前,我们离别比较两次运转的prop1prop2即可。而在重构后,我们能够须要比较config对象所代表的设置项是不是一致。在如许的场景下,直接用==或许===来比较对象,获得的并不是我们希冀的效果。

在如许的场景下,我们能够须要自定义一些要领来处置惩罚对象的比较。罕见的多是经由历程JSON.stringify()对对象举行序列化以后再比较字符串,固然这个历程并不是完整牢靠,只是一个思绪。

假如你认为这个场景是无中生有的话,能够再追念一下断言库,同样是基于对象成员,推断效果是不是和预期符合。

实例对象

实例对象重要指经由历程组织函数(类)天生的对象。如许的对象和纯对象一样,直接比较都是不等的,但也会遇到须要推断是不是是统一对象的状况。平常而言,因为这类对象有比较庞杂的内部结构(甚至有一部份数据在原型上),没法直接从外部比较是不是相称。比较靠谱的推断要领是由组织函数(类)来供应静态要领或许实例要领来推断是不是相称。

var a = Klass();
var b = Klass();

Klass.isEqual(a, b);

别的对象

别的对象重要指数组、日期、正则表达式等这类在Object基础上派生出来的对象。这类对象各有各的特别性,平常须要依据场景来组织推断要领,决议两个对象是不是相称。

比方,日期对象,能够须要经由历程Date.prototype.getTime()要领猎取时候戳来推断是不是示意统一时候。正则表达式能够须要经由历程toString()要领猎取到原始字面量来推断是不是是雷同的正则表达式。

==和===

在一些文章中,看到某一些数组去重的要领,在推断元素是不是相称时,运用的是==比较运算符。尽人皆知,这个运算符在比较前会先检察元素范例,当范例不一致时会做隐式范例转换。这实际上是一种异常不严谨的做法。因为没法辨别在做藏匿范例转换后值一样的元素,比方0''falsenullundefined等。

同时,另有能够涌现一些只能黑人问号的效果,比方:

[] == ![]; //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…)。

  1. If Type(x) is different from Type(y), return false.

  2. If Type(x) is Number, then

    1. If x is NaN and y is NaN, return true.

    2. If x is +0 and y is -0, return true.

    3. If x is -0 and y is +0, return true.

    4. If x is the same Number value as y, return true.

    5. Return false.

  3. Return SameValueNonNumber(x, y).

注重2.a,假如xy都是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'1'

  2. 没法处置惩罚庞杂数据范例,比方对象(因为对象作为key会变成[object Object]

  3. 特别数据,比方'__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#1NaN被去掉
indexOf#2NaN反复
includes准确
两重轮回#1NaN反复
两重轮回#2NaN反复
对象#1字符串和数字没法辨别,对象、数组、正则表达式被去重
对象#2对象、数组、正则表达式被去重
对象#3对象、数组被去重,正则表达式被消逝JSON.stringify(/a/)效果为{},和空对象一样
Map准确 
Set准确 

末了的末了:任何离开场景谈手艺都是妄谈,本文也一样。去重这道题,没有准确答案,请依据场景挑选适宜的去重要领。

    原文作者:TooBug
    原文地址: https://segmentfault.com/a/1190000008031081
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞