JavaScript的陣列去重是一個老生常談的話題了。隨便搜一搜就能找到非常多不同版本的解法。
昨天在微博上看到一篇文章,也寫陣列去重,主要推崇的方法是將利用陣列元素當作物件key來去重。我在微博轉發了“用物件key去重不是個好辦法…”然後作者問什麼才是推薦的方法。
細想一下,這樣一個看似簡單的需求,如果要做到完備,涉及的知識和需要注意的地方著實不少,於是誕生此文。
定義重複(相等)
要去重,首先得定義,什麼叫作“重複”,即具體到程式碼而言,兩個資料在什麼情況下可以算是相等的。這並不是一個很容易的問題。
對於原始值而言,我們很容易想到1
和1
是相等的,'1'
和'1'
也是相等的。那麼,1
和'1'
是相等的麼?
如果這個問題還好說,只要回答“是”或者“不是”即可。那麼下面這些情況就沒那麼容易了。
NaN
初看NaN
時,很容易把它當成和null
、undefined
一樣的獨立資料型別。但其實,它是數字型別。
1 2 |
// number console.log(typeof NaN); |
根據規範,比較運算中只要有一個值為NaN,則比較結果為false
,所以會有下面這些看起來略蛋疼的結論:
1 2 3 4 5 |
// 全都是false 0 < NaN; 0 > NaN; 0 == NaN; 0 === NaN; |
以最後一個表示式0 === NaN
為例,在規範中有明確規定(http://www.ecma-international.org/ecma-262/6.0/#sec-strict-equality-comparison):
- 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()
:
1 2 3 4 5 |
var a = NaN; var b = NaN; // true console.log(isNaN(a) && isNaN(b)); |
原始值和包裝物件
看完NaN
是不是頭都大了。好了,我們來輕鬆一下,看一看原始值和包裝物件這一對冤家。
如果你研究過'a'.trim()
這樣的程式碼的話,不知道是否產生過這樣的疑問:'a'
明明是一個原始值(字串),它為什麼可以直接呼叫.trim()
方法呢?當然,很可能你已經知道答案:因為JS在執行這樣的程式碼的時候會對原始值做一次包裝,讓'a'
變成一個字串物件,然後執行這個物件的方法,執行完之後再把這個包裝物件脫掉。可以用下面的程式碼來理解:
1 2 3 |
// 'a'.trim(); var tmp = new String('a'); tmp.trim(); |
這段程式碼只是輔助我們理解的。但包裝物件這個概念在JS中卻是真實存在的。
1 2 |
var a = new String('a'); var b = 'b'; |
a
即是一個包裝物件,它和b
一樣,代表一個字串。它們都可以使用字串的各種方法(比如trim()
),也可以參與字串運算(+
號連線等)。
但他們有一個關鍵的區別:型別不同!
1 2 |
typeof a; // object typeof b; // string |
在做字串比較的時候,型別的不同會導致結果有一些出乎意料:
1 2 3 4 5 6 7 8 9 |
var a1 = 'a'; var a2 = new String('a'); var a3 = new String('a'); a1 == a2; // true a1 == a3; // true a2 == a3; // true a1 === a2; // false a1 === a3; // false a2 === a3; // false |
同樣是表示字串a
的變數,在使用嚴格比較時竟然不是相等的,在直覺上這是一件比較難接受的事情,在各種開發場景下,也非常容易忽略這些細節。
物件和物件
在涉及比較的時候,還會碰到物件。具體而言,大致可以分為三種情況:純物件、例項物件、其它型別的物件。
純物件
純物件(plain object)具體指什麼並不是非常明確,為減少不必要的爭議,下文中使用純物件指代由字面量生成的、成員中不含函式和日期、正規表示式等型別的物件。
如果直接拿兩個物件進行比較,不管是==
還是===
,毫無疑問都是不相等的。但是在實際使用時,這樣的規則是否一定滿足我們的需求?舉個例子,我們的應用中有兩個配置項:
1 2 3 4 5 6 7 8 |
// 原來有兩個屬性 // var prop1 = 1; // var prop2 = 2; // 重構程式碼時兩個屬性被放到同一個物件中 var config = { prop1: 1, prop2: 2 }; |
假設在某些場景下,我們需要比較兩次執行的配置項是否相同。在重構前,我們分別比較兩次執行的prop1
和prop2
即可。而在重構後,我們可能需要比較config
物件所代表的配置項是否一致。在這樣的場景下,直接用==
或者===
來比較物件,得到的並不是我們期望的結果。
在這樣的場景下,我們可能需要自定義一些方法來處理物件的比較。常見的可能是通過JSON.stringify()
對物件進行序列化之後再比較字串,當然這個過程並非完全可靠,只是一個思路。
如果你覺得這個場景是無中生有的話,可以再回想一下斷言庫,同樣是基於物件成員,判斷結果是否和預期相符。
例項物件
例項物件主要指通過建構函式(類)生成的物件。這樣的物件和純物件一樣,直接比較都是不等的,但也會碰到需要判斷是否是同一物件的情況。一般而言,因為這種物件有比較複雜的內部結構(甚至有一部分資料在原型上),無法直接從外部比較是否相等。比較靠譜的判斷方法是由建構函式(類)來提供靜態方法或者例項方法來判斷是否相等。
1 2 3 |
var a = Klass(); var b = Klass(); Klass.isEqual(a, b); |
其它物件
其它物件主要指陣列、日期、正規表示式等這類在Object
基礎上派生出來的物件。這類物件各有各的特殊性,一般需要根據場景來構造判斷方法,決定兩個物件是否相等。
比如,日期物件,可能需要通過Date.prototype.getTime()
方法獲取時間戳來判斷是否表示同一時刻。正規表示式可能需要通過toString()
方法獲取到原始字面量來判斷是否是相同的正規表示式。
==和===
在一些文章中,看到某一些陣列去重的方法,在判斷元素是否相等時,使用的是==
比較運算子。眾所周知,這個運算子在比較前會先檢視元素型別,當型別不一致時會做隱式型別轉換。這其實是一種非常不嚴謹的做法。因為無法區分在做隱匿型別轉換後值一樣的元素,例如0
、''
、false
、null
、undefined
等。
同時,還有可能出現一些只能黑人問號的結果,例如:
1 |
[] == ![]; //true |
Array.prototype.indexOf()
在一些版本的去重中,用到了Array.prototype.indexOf()
方法:
1 2 3 4 5 6 7 |
function unique(arr) { return arr.filter(function(item, index){ // indexOf返回第一個索引值, // 如果當前索引不是第一個索引,說明是重複值 return arr.indexOf(item) === index; }); } |
1 2 3 4 5 6 7 8 9 |
function unique(arr) { var ret = []; arr.forEach(function(item){ if(ret.indexOf(item) === -1){ ret.push(item); } }); return ret; } |
既然==
和===
在元素相等的比較中是有巨大差別的,那麼indexOf
的情況又如何呢?大部分的文章都沒有提及這點,於是只好求助規範。通過規範(http://www.ecma-international.org/ecma-262/6.0/#sec-array.prototype.indexof),我們知道了indexOf()
使用的是嚴格比較,也就是===
。
再次強調:按照前文所述,
===
不能處理NaN
的相等性判斷。
Array.prototype.includes()
Array.prototype.includes()
是ES2016中新增的方法,用於判斷陣列中是否包含某個元素,所以上面使用indexOf()
方法的第二個版本可以改寫成如下版本:
1 2 3 4 5 6 7 8 9 |
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/ecma262/2016/#sec-samevaluezero)。
- If Type(x) is different from Type(y), return false.
- 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.- Return SameValueNonNumber(x, y).
注意2.a
,如果x
和y
都是NaN
,則返回true
!也就是includes()
是可以正確判斷是否包含了NaN
的。我們寫一段程式碼驗證一下:
1 2 3 |
var arr = [1, 2, NaN]; arr.indexOf(NaN); // -1 arr.includes(NaN); // true |
可以看到indexOf()
和includes()
對待NaN
的行為是完全不一樣的。
一些方案
從上面的一大段文字中,我們可以看到,要判斷兩個元素是否相等(重複)並不是一件簡單的事情。在瞭解了這個背景後,我們來看一些前面沒有涉及到的去重方案。
遍歷
雙重遍歷是最容易想到的去重方案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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; } |
雙重遍歷還有一個優化版本,但是原理和複雜度幾乎完全一樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
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來去重
1 2 3 4 5 6 7 8 9 10 11 12 |
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中來解決:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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()
進行序列化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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()
介面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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
改寫一下我們的去重方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
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
就是集合的意思,它不允許重複元素出現,這一點和數學中對集合的定義還是比較像的。
1 2 3 4 5 6 7 8 9 |
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
也是這樣。於是我們想到,這麼好的特性,要是能和陣列互相轉換,不就可以去重了嗎?
1 2 3 4 |
function unique(arr){ var set = new Set(arr); return Array.from(set); } |
我們討論了這麼久的事情,居然兩行程式碼搞定了,簡直不可思議。
然而,不要只顧著高興了。有一句話是這麼說的“不要因為走得太遠而忘了為什麼出發”。我們為什麼要為陣列去重呢?因為我們想得到不重複的元素列表。而既然已經有Set
了,我們為什麼還要捨近求遠,使用陣列呢?是不是在需要去重的情況下,直接使用Set
就解決問題了?這個問題值得思考。
小結
最後,用一個測試用例總結一下文中出現的各種去重方法:
1 2 |
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 | 正確 |
最後的最後:任何脫離場景談技術都是妄談,本文也一樣。去重這道題,沒有正確答案,請根據場景選擇合適的去重方法。