前言
這是前端面試題系列的第 8 篇,你可能錯過了前面的篇章,可以在這裡找到:
前端面試中經常會問到陣列去重的問題。因為在平時的工作中遇到複雜互動的時候,需要知道該如何解決。另外,我在問應聘者這道題的時候,更多的是想考察 2 個點:對 Array 方法的熟悉程度,還有邏輯演算法能力。一般我會先讓應聘者說出幾種方法,然後隨機抽取他說的一種,具體地寫一下。
這裡有一個通用的面試技巧:自己不熟悉的東西,千萬別說!我就碰到過幾個應聘者,想盡可能地表現自己,就說了不少方法,隨機抽了一個,結果就沒寫出來,很尷尬。
ok,讓我們馬上開始今天的主題。會介紹 10 種不同型別的方法,一些類似的方法我做了合併,寫法從簡到繁,其中還會有 loadsh 原始碼中的方法。
10 種去重方法
假設有一個這樣的陣列: let originalArray = [1, '1', '1', 2, true, 'true', false, false, null, null, {}, {}, 'abc', 'abc', undefined, undefined, NaN, NaN];
。後面的方法中的源陣列,都是指的這個。
1、ES6 的 Set 物件
ES6 提供了新的資料結構 Set。它類似於陣列,但是成員的值都是唯一的,沒有重複的值。Set 本身是一個建構函式,用來生成 Set 資料結構。
let resultArr = Array.from(new Set(originalArray));
// 或者用擴充套件運算子
let resultArr = [...new Set(originalArray)];
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼
Set 並不是真正的陣列,這裡的 Array.from
和 ...
都可以將 Set 資料結構,轉換成最終的結果陣列。
這是最簡單快捷的去重方法,但是細心的同學會發現,這裡的 {}
沒有去重。可是又轉念一想,2 個空物件的地址並不相同,所以這裡並沒有問題,結果 ok。
2、Map 的 has 方法
把源陣列的每一個元素作為 key 存到 Map 中。由於 Map 中不會出現相同的 key 值,所以最終得到的就是去重後的結果。
const resultArr = new Array();
for (let i = 0; i < originalArray.length; i++) {
// 沒有該 key 值
if (!map.has(originalArray[i])) {
map.set(originalArray[i], true);
resultArr.push(originalArray[i]);
}
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼
但是它與 Set 的資料結構比較相似,結果 ok。
3、indexOf 和 includes
建立一個新的空陣列,遍歷源陣列,往這個空陣列裡塞值,每次 push 之前,先判斷是否已有相同的值。
判斷的方法有 2 個:indexOf 和 includes,但它們的結果之間有細微的差別。先看 indexOf。
const resultArr = [];
for (let i = 0; i < originalArray.length; i++) {
if (resultArr.indexOf(originalArray[i]) < 0) {
resultArr.push(originalArray[i]);
}
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼
indexOf 並不沒處理 NaN
。
再來看 includes,它是在 ES7 中正式提出的。
const resultArr = [];
for (let i = 0; i < originalArray.length; i++) {
if (!resultArr.includes(originalArray[i])) {
resultArr.push(originalArray[i]);
}
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼
includes 處理了 NaN
,結果 ok。
4、sort
先將原陣列排序,生成新的陣列,然後遍歷排序後的陣列,相鄰的兩兩進行比較,如果不同則存入新陣列。
const sortedArr = originalArray.sort();
const resultArr = [sortedArr[0]];
for (let i = 1; i < sortedArr.length; i++) {
if (sortedArr[i] !== resultArr[resultArr.length - 1]) {
resultArr.push(sortedArr[i]);
}
}
console.log(resultArr);
// [1, "1", 2, NaN, NaN, {…}, {…}, "abc", false, null, true, "true", undefined]
複製程式碼
從結果可以看出,對源陣列進行了排序。但同樣的沒有處理 NaN
。
5、雙層 for 迴圈 + splice
雙層迴圈,外層遍歷源陣列,內層從 i+1 開始遍歷比較,相同時刪除這個值。
for (let i = 0; i < originalArray.length; i++) {
for (let j = (i + 1); j < originalArray.length; j++) {
// 第一個等於第二個,splice去掉第二個
if (originalArray[i] === originalArray[j]) {
originalArray.splice(j, 1);
j--;
}
}
}
console.log(originalArray);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼
splice 方法會修改源陣列,所以這裡我們並沒有新開空陣列去儲存,最終輸出的是修改之後的源陣列。但同樣的沒有處理 NaN
。
6、原始去重
定義一個新陣列,並存放原陣列的第一個元素,然後將源陣列一一和新陣列的元素對比,若不同則存放在新陣列中。
let resultArr = [originalArray[0]];
for(var i = 1; i < originalArray.length; i++){
var repeat = false;
for(var j=0; j < resultArr.length; j++){
if(originalArray[i] === resultArr[j]){
repeat = true;
break;
}
}
if(!repeat){
resultArr.push(originalArray[i]);
}
}
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN, NaN]
複製程式碼
這是最原始的去重方法,很好理解,但寫法繁瑣。同樣的沒有處理 NaN
。
7、ES5 的 reduce
reduce 是 ES5 中方法,常用於值的累加。它的語法:
arr.reduce(callback[, initialValue])
複製程式碼
reduce 的第一個引數是一個 callback,callback 中的引數分別為: Accumulator(累加器)、currentValue(當前正在處理的元素)、currentIndex(當前正在處理的元素索引,可選)、array(呼叫 reduce 的陣列,可選)。
reduce 的第二個引數,是作為第一次呼叫 callback 函式時的第一個引數的值。如果沒有提供初始值,則將使用陣列中的第一個元素。
利用 reduce 的特性,再結合之前的 includes(也可以用 indexOf),就能得到新的去重方法:
const reducer = (acc, cur) => acc.includes(cur) ? acc : [...acc, cur];
const resultArr = originalArray.reduce(reducer, []);
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼
這裡的 []
就是初始值(initialValue)。acc 是累加器,在這裡的作用是將沒有重複的值塞入新陣列(它一開始是空的)。 reduce 的寫法很簡單,但需要多加理解。它可以處理 NaN
,結果 ok。
8、物件的屬性
每次取出原陣列的元素,然後在物件中訪問這個屬性,如果存在就說明重複。
const resultArr = [];
const obj = {};
for(let i = 0; i < originalArray.length; i++){
if(!obj[originalArray[i]]){
resultArr.push(originalArray[i]);
obj[originalArray[i]] = 1;
}
}
console.log(resultArr);
// [1, 2, true, false, null, {…}, "abc", undefined, NaN]
複製程式碼
但這種方法有缺陷。從結果看,它貌似只關心值,不關注型別。還把 {} 給處理了,但這不是正統的處理辦法,所以 不推薦使用。
9、filter + hasOwnProperty
filter 方法會返回一個新的陣列,新陣列中的元素,通過 hasOwnProperty 來檢查是否為符合條件的元素。
const obj = {};
const resultArr = originalArray.filter(function (item) {
return obj.hasOwnProperty(typeof item + item) ? false : (obj[typeof item + item] = true);
});
console.log(resultArr);
// [1, "1", 2, true, "true", false, null, {…}, "abc", undefined, NaN]
複製程式碼
這 貌似
是目前看來最完美的解決方案了。這裡稍加解釋一下:
- hasOwnProperty 方法會返回一個布林值,指示物件自身屬性中是否具有指定的屬性。
typeof item + item
的寫法,是為了保證值相同,但型別不同的元素被保留下來。例如:第一個元素為 number1,第二第三個元素都是 string1,所以第三個元素就被去除了。obj[typeof item + item] = true
如果 hasOwnProperty 沒有找到該屬性,則往 obj 裡塞鍵值對進去,以此作為下次迴圈的判斷依據。- 如果 hasOwnProperty 沒有檢測到重複的屬性,則告訴 filter 方法可以先積攢著,最後一起輸出。
看似
完美解決了我們源陣列的去重問題,但在實際的開發中,一般不會給兩個空物件給我們去重。所以稍加改變源陣列,給兩個空物件中加入鍵值對。
let originalArray = [1, '1', '1', 2, true, 'true', false, false, null, null, {a: 1}, {a: 2}, 'abc', 'abc', undefined, undefined, NaN, NaN];
複製程式碼
然後再用 filter + hasOwnProperty 去重。
然而,結果竟然把 {a: 2}
給去除了!!!這就不對了。
所以,這種方法有點去重 過頭
了,也是存在問題的。
10、lodash 中的 _.uniq
靈機一動,讓我想到了 lodash 的去重方法 _.uniq,那就嘗試一把:
console.log(_.uniq(originalArray));
// [1, "1", 2, true, "true", false, null, {…}, {…}, "abc", undefined, NaN]
複製程式碼
用法很簡單,可以在實際工作中正確處理去重問題。
然後,我在好奇心促使下,看了它的原始碼,指向了 baseUniq 檔案,它的原始碼如下:
function baseUniq(array, iteratee, comparator) {
let index = -1
let includes = arrayIncludes
let isCommon = true
const { length } = array
const result = []
let seen = result
if (comparator) {
isCommon = false
includes = arrayIncludesWith
}
else if (length >= LARGE_ARRAY_SIZE) {
const set = iteratee ? null : createSet(array)
if (set) {
return setToArray(set)
}
isCommon = false
includes = cacheHas
seen = new SetCache
}
else {
seen = iteratee ? [] : result
}
outer:
while (++index < length) {
let value = array[index]
const computed = iteratee ? iteratee(value) : value
value = (comparator || value !== 0) ? value : 0
if (isCommon && computed === computed) {
let seenIndex = seen.length
while (seenIndex--) {
if (seen[seenIndex] === computed) {
continue outer
}
}
if (iteratee) {
seen.push(computed)
}
result.push(value)
}
else if (!includes(seen, computed, comparator)) {
if (seen !== result) {
seen.push(computed)
}
result.push(value)
}
}
return result
}
複製程式碼
有比較多的干擾項,那是為了相容另外兩個方法,_.uniqBy 和 _.uniqWith。去除掉之後,就會更容易發現它是用 while 做了迴圈。當遇到相同的值得時候,continue outer 再次進入迴圈進行比較,將沒有重複的值塞進 result 裡,最終輸出。
另外,_.uniqBy 方法可以通過指定 key,來專門去重物件列表。
_.uniqBy([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x');
// => [{ 'x': 1 }, { 'x': 2 }]
複製程式碼
_.uniqWith 方法可以完全地給物件中所有的鍵值對,進行比較。
var objects = [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }, { 'x': 1, 'y': 2 }];
_.uniqWith(objects, _.isEqual);
// => [{ 'x': 1, 'y': 2 }, { 'x': 2, 'y': 1 }]
複製程式碼
這兩個方法,都還挺實用的。
總結
從上述的這些方法來看,ES6 開始出現的方法(如 Set、Map、includes),都能完美地解決我們日常開發中的去重需求,關鍵它們還都是原生的,寫法還更簡單。
所以,我們提倡擁抱原生,因為它們真的沒有那麼難以理解,至少在這裡我覺得它比 lodash 裡 _.uniq 的原始碼要好理解得多,關鍵是還能解決問題。
PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。