JavaScript的陣列去重是一個老生常談的話題了。隨便搜一搜就能找到非常多不同版本的解法。

昨天在微博上看到一篇文章,也寫陣列去重,主要推崇的方法是將利用陣列元素當作物件key來去重。我在微博轉發了“用物件key去重不是個好辦法…”然後作者問什麼才是推薦的方法。

細想一下,這樣一個看似簡單的需求,如果要做到完備,涉及的知識和需要注意的地方著實不少,於是誕生此文。

定義重複(相等)

要去重,首先得定義,什麼叫作“重複”,即具體到程式碼而言,兩個資料在什麼情況下可以算是相等的。這並不是一個很容易的問題。

對於原始值而言,我們很容易想到11是相等的,'1''1'也是相等的。那麼,1'1'是相等的麼?

如果這個問題還好說,只要回答“是”或者“不是”即可。那麼下面這些情況就沒那麼容易了。

NaN

初看NaN時,很容易把它當成和nullundefined一樣的獨立資料型別。但其實,它是數字型別。

根據規範,比較運算中只要有一個值為NaN,則比較結果為false,所以會有下面這些看起來略蛋疼的結論:

以最後一個表示式0 === NaN為例,在規範中有明確規定(http://www.ecma-international.org/ecma-262/6.0/#sec-strict-equality-comparison):

  1. 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()

原始值和包裝物件

看完NaN是不是頭都大了。好了,我們來輕鬆一下,看一看原始值和包裝物件這一對冤家。

如果你研究過'a'.trim()這樣的程式碼的話,不知道是否產生過這樣的疑問:'a'明明是一個原始值(字串),它為什麼可以直接呼叫.trim()方法呢?當然,很可能你已經知道答案:因為JS在執行這樣的程式碼的時候會對原始值做一次包裝,讓'a'變成一個字串物件,然後執行這個物件的方法,執行完之後再把這個包裝物件脫掉。可以用下面的程式碼來理解:

這段程式碼只是輔助我們理解的。但包裝物件這個概念在JS中卻是真實存在的。

a即是一個包裝物件,它和b一樣,代表一個字串。它們都可以使用字串的各種方法(比如trim()),也可以參與字串運算(+號連線等)。

但他們有一個關鍵的區別:型別不同!

在做字串比較的時候,型別的不同會導致結果有一些出乎意料:

同樣是表示字串a的變數,在使用嚴格比較時竟然不是相等的,在直覺上這是一件比較難接受的事情,在各種開發場景下,也非常容易忽略這些細節。

物件和物件

在涉及比較的時候,還會碰到物件。具體而言,大致可以分為三種情況:純物件、例項物件、其它型別的物件。

純物件

純物件(plain object)具體指什麼並不是非常明確,為減少不必要的爭議,下文中使用純物件指代由字面量生成的、成員中不含函式和日期、正規表示式等型別的物件。

如果直接拿兩個物件進行比較,不管是==還是===,毫無疑問都是不相等的。但是在實際使用時,這樣的規則是否一定滿足我們的需求?舉個例子,我們的應用中有兩個配置項:

假設在某些場景下,我們需要比較兩次執行的配置項是否相同。在重構前,我們分別比較兩次執行的prop1prop2即可。而在重構後,我們可能需要比較config物件所代表的配置項是否一致。在這樣的場景下,直接用==或者===來比較物件,得到的並不是我們期望的結果。

在這樣的場景下,我們可能需要自定義一些方法來處理物件的比較。常見的可能是通過JSON.stringify()對物件進行序列化之後再比較字串,當然這個過程並非完全可靠,只是一個思路。

如果你覺得這個場景是無中生有的話,可以再回想一下斷言庫,同樣是基於物件成員,判斷結果是否和預期相符。

例項物件

例項物件主要指通過建構函式(類)生成的物件。這樣的物件和純物件一樣,直接比較都是不等的,但也會碰到需要判斷是否是同一物件的情況。一般而言,因為這種物件有比較複雜的內部結構(甚至有一部分資料在原型上),無法直接從外部比較是否相等。比較靠譜的判斷方法是由建構函式(類)來提供靜態方法或者例項方法來判斷是否相等。

其它物件

其它物件主要指陣列、日期、正規表示式等這類在Object基礎上派生出來的物件。這類物件各有各的特殊性,一般需要根據場景來構造判斷方法,決定兩個物件是否相等。

比如,日期物件,可能需要通過Date.prototype.getTime()方法獲取時間戳來判斷是否表示同一時刻。正規表示式可能需要通過toString()方法獲取到原始字面量來判斷是否是相同的正規表示式。

==和===

在一些文章中,看到某一些陣列去重的方法,在判斷元素是否相等時,使用的是==比較運算子。眾所周知,這個運算子在比較前會先檢視元素型別,當型別不一致時會做隱式型別轉換。這其實是一種非常不嚴謹的做法。因為無法區分在做隱匿型別轉換後值一樣的元素,例如0''falsenullundefined等。

同時,還有可能出現一些只能黑人問號的結果,例如:

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”比較(https://tc39.github.io/ecma262/2016/#sec-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,如果xy都是NaN,則返回true!也就是includes()是可以正確判斷是否包含了NaN的。我們寫一段程式碼驗證一下:

可以看到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()介面。

由於Map使用單獨的介面來存取資料,所以不用擔心key會和內建屬性重名(如上文提到的__proto__)。使用Map改寫一下我們的去重方法:

Set

既然都用到了ES2015,陣列這件事情不能再簡單一點麼?當然可以。

除了Map以外,ES2015還引入了一種叫作Set的資料型別。顧名思義,Set就是集合的意思,它不允許重複元素出現,這一點和數學中對集合的定義還是比較像的。

如果你重複新增同一個元素的話,Set中只會存在一個。包括NaN也是這樣。於是我們想到,這麼好的特性,要是能和陣列互相轉換,不就可以去重了嗎?

我們討論了這麼久的事情,居然兩行程式碼搞定了,簡直不可思議。

然而,不要只顧著高興了。有一句話是這麼說的“不要因為走得太遠而忘了為什麼出發”。我們為什麼要為陣列去重呢?因為我們想得到不重複的元素列表。而既然已經有Set了,我們為什麼還要捨近求遠,使用陣列呢?是不是在需要去重的情況下,直接使用Set就解決問題了?這個問題值得思考。

小結

最後,用一個測試用例總結一下文中出現的各種去重方法:

測試中沒有定義物件的比較方法,因此預設情況下,物件不去重是正確的結果,去重是不正確的結果。

方法 結果 說明
indexOf#1 NaN被去掉
indexOf#2 NaN重複
includes 正確
雙重迴圈#1 NaN重複
雙重迴圈#2 NaN重複
物件#1 字串和數字無法區分,物件、陣列、正規表示式被去重
物件#2 物件、陣列、正規表示式被去重
物件#3 物件、陣列被去重,正規表示式被消失 JSON.stringify(/a/)結果為{},和空物件一樣
Map 正確
Set 正確

最後的最後:任何脫離場景談技術都是妄談,本文也一樣。去重這道題,沒有正確答案,請根據場景選擇合適的去重方法。