JavaScript的數組去重是一個老生常談的話題了。隨便搜一搜就能找到非常多不同版本的解法。
細想一下,這樣一個看似簡單的需求,如果要做到完備,涉及的知識和需要注意的地方著實不少。
定義重複(相等)
要去重,首先得定義,什麼叫作「重複」,即具體到代碼而言,兩個數據在什麼情況下可以算是相等的。這並不是一個很容易的問題。
對於原始值而言,我們很容易想到1和1是相等的,'1'和'1'也是相等的。那麼,1和'1'是相等的麼?
如果這個問題還好說,只要回答「是」或者「不是」即可。那麼下面這些情況就沒那麼容易了。
NaN
初看NaN時,很容易把它當成和null、undefined一樣的獨立數據類型。但其實,它是數字類型。
// numberconsole.log(typeof NaN);
根據規範,比較運算中只要有一個值為NaN,則比較結果為false,所以會有下面這些看起來略蛋疼的結論:
// 全都是false0 < NaN;0 > NaN;0 == NaN;0 === NaN;
以最後一個表達式0 === NaN為例,在規範中有明確規定(<http://www.ecma-international.org/ecma-262/6.0/#sec-strict-equality-comparison>):
4. If Type(x) is Number, then
a. If x is NaN, return false.
b. If y is NaN, return false.
c. If x is the same Number value as y, return true.
d. If x is +0 and y is −0, return true.
e. If x is −0 and y is +0, return true.
f. Return false.
這代表著任何涉及到NaN的情況都不能簡單地使用比較運算來判定是否相等。比較科學的方法只能是使用isNaN():
var a = NaN;var b = NaN;// trueconsole.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; // objecttypeof b; // string
在做字符串比較的時候,類型的不同會導致結果有一些出乎意料:
var a1 = 'a';var a2 = new String('a');var a3 = new String('a');a1 == a2; // truea1 == a3; // truea2 == a3; // falsea1 === a2; // falsea1 === a3; // falsea2 === 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()方法:
既然==和===在元素相等的比較中是有巨大差別的,那麼indexOf的情況又如何呢?大部分的文章都沒有提及這點,於是只好求助規範。通過規範(<http://www.ecma-international.org/ecma-262/6.0/#sec-array.prototype.indexof>),我們知道了indexOf()使用的是嚴格比較,也就是===。
再次強調:按照前文所述,===不能處理NaN的相等性判斷。
Array.prototype.includes()
Array.prototype.includes()是ES2016中新增的方法,用於判斷數組中是否包含某個元素,所以上面使用indexOf()方法的第二個版本可以改寫成如下版本:
那麼,你猜猜,includes()又是用什麼方法來比較的呢?如果想當然的話,會覺得肯定跟indexOf()一樣嘍。但是,程式設計師的世界裡最怕想當然。翻一翻規範,發現它其實是使用的另一種比較方法,叫作「SameValueZero」比較。
1. If Type(x) is different from Type(y), return false.
2. If Type(x) is Number, then
a. If x is NaN and y is NaN, return true.
b. If x is +0 and y is -0, return true.
c. If x is -0 and y is +0, return true.
d. If x is the same Number value as y, return true.
e. Return false.
3. Return SameValueNonNumber(x, y).
注意2.a,如果x和y都是NaN,則返回true!也就是includes()是可以正確判斷是否包含了NaN的。我們寫一段代碼驗證一下:
var arr = [1, 2, NaN];arr.indexOf(NaN); // -1arr.includes(NaN); // true
可以看到indexOf()和includes()對待NaN的行為是完全不一樣的。
一些方案
從上面的一大段文字中,我們可以看到,要判斷兩個元素是否相等(重複)並不是一件簡單的事情。在了解了這個背景後,我們來看一些前面沒有涉及到的去重方案。
遍歷
雙重遍歷是最容易想到的去重方案:
雙重遍歷還有一個優化版本,但是原理和複雜度幾乎完全一樣:
這種方案沒什麼大問題,用於去重的比較部分也是自己編寫實現(arr[i] === arr[j]),所以相等性可以自己針對上文說到的各種情況加以特殊處理。唯一比較受詬病的是使用了雙重循環,時間複雜度比較高,性能一般。
使用對象key來去重
這種方法是利用了對象(tmp)的key不可以重複的特性來進行去重。但由於對象key只能為字符串,因此這種去重方法有許多局限性:
1. 無法區分隱式類型轉換成字符串後一樣的值,比如1和'1'
2. 無法處理複雜數據類型,比如對象(因為對象作為key會變成[object Object])
3. 特殊數據,比如'__proto__'會掛掉,因為tmp對象的__proto__屬性無法被重寫
對於第一點,有人提出可以為對象的key增加一個類型,或者將類型放到對象的value中來解決:
該方案也同時解決第三個問題。
而第二個問題,如果像上文所說,在允許對對象進行自定義的比較規則,也可以將對象序列化之後作為key來使用。這裡為簡單起見,使用JSON.stringify()進行序列化。
Map Key
可以看到,使用對象key來處理數組去重的問題,其實是一件比較麻煩的事情,處理不好很容易導致結果不正確。而這些問題的根本原因就是因為key在使用時有限制。
那麼,能不能有一種key使用沒有限制的對象呢?答案是——真的有!那就是ES2015中的Map。
Map是一種新的數據類型,可以把它想像成key類型沒有限制的對象。此外,它的存取使用單獨的get()、set()接口。
var tmp = new Map();tmp.set(1, 1);tmp.get(1); // 1tmp.set('2', 2);tmp.get('2'); // 2tmp.set(true, 3);tmp.get(true); // 3tmp.set(undefined, 4);tmp.get(undefined); // 4tmp.set(NaN, 5);tmp.get(NaN); // 5var arr = [], obj = {};tmp.set(arr, 6);tmp.get(arr); // 6tmp.set(obj, 7);tmp.get(obj); // 7
由於Map使用單獨的接口來存取數據,所以不用擔心key會和內置屬性重名(如上文提到的__proto__)。使用Map改寫一下我們的去重方法:
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就解決問題了?這個問題值得思考。
小結
最後,用一個測試用例總結一下文中出現的各種去重方法:
測試中沒有定義對象的比較方法,因此默認情況下,對象不去重是正確的結果,去重是不正確的結果。
最後的最後:任何脫離場景談技術都是妄談,本文也一樣。去重這道題,沒有正確答案,請根據場景選擇合適的去重方法。