前言
上一篇文章 「前端面試題系列8」陣列去重(10 種濃縮版) 的最後,簡單介紹了 lodash 中的陣列去重方法 _.uniq
,它可以實現我們日常工作中的去重需求,能夠去重 NaN
,並保留 {...}
。
今天要講的,是我從 _.uniq 的原始碼實現檔案 baseUniq.js 中學到的幾個很基礎,卻又容易被忽略的知識點。
三個 API
讓我們先從三個功能相近的 API 講起,他們分別是:_.uniq
、_.uniqBy
、_.uniqWith
。它們三個背後的實現檔案,都指向了 .internal 下的 baseUniq.js。
區別在於 _.uniq 只需傳入一個源陣列 array, _.uniqBy 相較於 _.uniq 要多傳一個迭代器 iteratee,而 _.uniqWith 要多傳一個比較器 comparator。iteratee
和 comparator
的用法,會在後面說到。
以 _.uniqWith 為例,它是這樣呼叫 _.baseUniq 的:
function uniqWith(array, comparator) {
comparator = typeof comparator == 'function' ? comparator : undefined
return (array != null && array.length)
? baseUniq(array, undefined, comparator)
: []
}
複製程式碼
baseUniq 的實現原理
baseUniq 的原始碼並不多,但比較繞。先貼一下的原始碼。
const LARGE_ARRAY_SIZE = 200
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
}
複製程式碼
為了相容剛才說的三個 API,就產生了不少的干擾項。如果先從 _.uniq 入手,去掉 iteratee 和 comparator 的干擾,就會清晰不少。
function baseUniq(array) {
let index = -1
const { length } = array
const result = []
if (length >= 200) {
const set = createSet(array)
return setToArray(set)
}
outer:
while (++index < length) {
const value = array[index]
if (value === value) {
let resultIndex = result.length
while (resultIndex--) {
if (result[resultIndex] === value) {
continue outer
}
}
result.push(value)
} else if (!includes(seen, value)) {
result.push(value)
}
}
return result
}
複製程式碼
這裡有 2 個知識點。
知識點一、NaN === NaN 嗎?
在原始碼中有一個判斷 value === value
,乍一看,會覺得這是句廢話!?!但其實,這是為了過濾 NaN 的情況。
MDN 中對 NaN 的解釋是:它是一個全域性物件的屬性,初始值就是 NaN。它通常都是在計算失敗時,作為 Math 的某個方法的返回值出現的。
判斷一個值是否是 NaN,必須使用 Number.isNaN() 或 isNaN(),在執行自比較之中:NaN,也只有 NaN,比較之中不等於它自己。
NaN === NaN; // false
Number.NaN === NaN; // false
isNaN(NaN); // true
isNaN(Number.NaN); // true
複製程式碼
所以,在原始碼中,當遇到 NaN
的情況時,baseUniq 會轉而去執行 !includes(seen, value)
的判斷,去處理 NaN 。
知識點二、冒號的特殊作用
在原始碼的主體部分,while 語句之前,有一行 outer:
,它是幹什麼用的呢? while 中還有一個 while 的內部,有一行 continue outer
,從語義上理解,好像是繼續執行 outer
,這又是種什麼寫法呢?
outer:
while (++index < length) {
...
while (resultIndex--) {
if (result[resultIndex] === value) {
continue outer
}
}
}
複製程式碼
我們都知道 Javascript 中,常用到冒號的地方有三處,分別是:A ? B : C 三元操作符、switch case 語句中、物件的鍵值對組成。
但其實還有一種並不常見的特殊作用:標籤語句
。在 Javascript 中,任何語句都可以通過在它前面加上標誌符和冒號來標記(identifier: statement
),這樣就可以在任何地方使用該標記,最常用於迴圈語句中。
所以,在原始碼中,outer 只是看著有點不習慣,多看兩遍就好了,語義上還是很好理解的。
_.uniqBy 的 iteratee
_.uniqBy 可根據指定的 key 給一個物件陣列去重,一個官網的例子如下:
// The `_.property` iteratee shorthand.
_.uniqBy([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x');
// => [{ 'x': 1 }, { 'x': 2 }]
複製程式碼
這裡的 'x'
是 _.property('x')
的縮寫,它指的就是 iteratee。
從給出的例子和語義上看,還挺好理解的。但是為什麼 _.property 就能實現物件陣列的去重了呢?它又是如何實現的呢?
@param {Array|string} path The path of the property to get.
@returns {Function} Returns the new accessor function.
function property(path) {
return isKey(path) ? baseProperty(toKey(path)) : basePropertyDeep(path)
}
複製程式碼
從註釋看,property 方法會返回一個 Function
,再看 baseProperty 的實現:
@param {string} key The key of the property to get.
@returns {Function} Returns the new accessor function.
function baseProperty(key) {
return (object) => object == null ? undefined : object[key]
}
複製程式碼
咦?怎麼返回的還是個 Function
?感覺它什麼也沒幹呀,那個引數 object
又是哪裡來的?
知識點三、純函式的概念
純函式,是函數語言程式設計中的概念,它代表這樣一類函式:對於指定輸出,返回指定的結果。不存在副作用。
// 這是一個簡單的純函式
const addByOne = x => x + 1;
複製程式碼
也就是說,純函式的返回值只依賴其引數,函式體內不能存在任何副作用。如果是同樣的引數,則一定能得到一致的返回結果。
function baseProperty(key) {
return (object) => object == null ? undefined : object[key]
}
複製程式碼
baseProperty 返回的就是一個純函式,在符合條件的情況下,輸出 object[key]
。在函數語言程式設計中,函式是“一等公民”,它可以只是根據引數,做簡單的組合操作,再作為別的函式的返回值。
所以,在原始碼中,object 是呼叫 baseProperty 時傳入的物件。 baseProperty 的作用,是返回期望結果為 object[key] 的函式。
_.uniqWith 的 comparator
還是先從官網的小例子說起,它會完全地給物件中所有的鍵值對,進行比較。
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 }]
複製程式碼
而在 baseUniq 的原始碼中,可以看到最終的實現,需要依賴 arrayIncludesWith 方法,以下是它的原始碼:
function arrayIncludesWith(array, target, comparator) {
if (array == null) {
return false
}
for (const value of array) {
if (comparator(target, value)) {
return true
}
}
return false
}
複製程式碼
arrayIncludesWith 沒什麼複雜的。comparator 作為一個引數傳入,將 target
和 array
的每個 value 進行處理。從官網的例子看,_.isEqual 就是 comparator,就是要比較它們是否相等。
接著就追溯到了 _.isEqual 的原始碼,它的實現檔案是 baseIsEqualDeep.js。在裡面看到一個讓我犯迷糊的寫法,這是一個判斷。
/** Used to check objects for own properties. */
const hasOwnProperty = Object.prototype.hasOwnProperty
...
const objIsWrapped = objIsObj && hasOwnProperty.call(object, '__wrapped__')
複製程式碼
hasOwnProperty ?call, 'wrapped' ?
知識點四、物件的 hasOwnProperty
再次查詢到了 MDN 的解釋:所有繼承了 Object 的物件都會繼承到 hasOwnProperty 方法。它可以用來檢測一個物件是否含有特定的自身屬性;會忽略掉那些從原型鏈上繼承到的屬性。
o = new Object();
o.prop = 'exists';
o.hasOwnProperty('prop'); // 返回 true
o.hasOwnProperty('toString'); // 返回 false
o.hasOwnProperty('hasOwnProperty'); // 返回 false
複製程式碼
call 的用法可以參考這篇 細說 call、apply 以及 bind 的區別和用法。
那麼 hasOwnProperty.call(object, '__wrapped__')
的意思就是,判斷 object 這個物件上是否存在 'wrapped' 這個自身屬性。
wrapped 是什麼屬性?這就要說到 lodash 的延遲計算方法 _.chain,它是一種函式式風格,從名字就可以看出,它實現的是一種鏈式的寫法。比如下面這個例子:
var names = _.chain(users)
.map(function(user){
return user.user;
})
.join(" , ")
.value();
複製程式碼
如果你沒有顯樣的呼叫value方法,使其立即執行的話,將會得到如下的LodashWrapper延遲表示式:
LodashWrapper {__wrapped__: LazyWrapper, __actions__: Array[1], __chain__: true, constructor: function, after: function…}
複製程式碼
因為延遲表示式的存在,因此我們可以多次增加方法鏈,但這並不會被執行,所以不會存在效能的問題,最後直到我們需要使用的時候,使用 value()
顯式立即執行即可。
所以,在 baseIsEqualDeep 原始碼中,才需要做 hasOwnProperty 的判斷,然後在需要的情況下,執行 object.value()
。
總結
閱讀原始碼,在一開始會比較困難,因為會遇到一些看不明白的寫法。就像一開始我卡在了 value === value 的寫法,不明白它的用意。一旦知道了是為了過濾 NaN 用的,那後面就會通暢很多了。
所以,閱讀原始碼,是一種很棒的重溫基礎知識的方式。遇到看不明白的點,不要放過,多查多問多看,才能不斷地夯實基礎,讀懂更多的原始碼思想,體會更多的原生精髓。如果我在一開始看到 value === value 時就放棄了,那或許就不會有今天的這篇文章了。
PS:歡迎關注我的公眾號 “超哥前端小棧”,交流更多的想法與技術。