JavaScript 專題之如何判斷兩個物件相等

冴羽發表於2017-08-09

JavaScript 專題系列第十二篇,講解如何判斷兩個引數是否相等

前言

雖然標題寫的是如何判斷兩個物件相等,但本篇我們不僅僅判斷兩個物件相等,實際上,我們要做到的是如何判斷兩個引數相等,而這必然會涉及到多種型別的判斷。

相等

什麼是相等?在《JavaScript專題之去重》中,我們認為只要 === 的結果為 true,兩者就相等,然而今天我們重新定義相等:

我們認為:

  1. NaN 和 NaN 是相等
  2. [1] 和 [1] 是相等
  3. {value: 1
    } 和 {value: 1
    } 是相等

不僅僅是這些長得一樣的,還有

  1. 1 和 new Number(1) 是相等
  2. '
    Curly'
    和 new String('
    Curly'
    ) 是相等
  3. true 和 new Boolean(true) 是相等

更復雜的我們會在接下來的內容中看到。

目標

我們的目標是寫一個 eq 函式用來判斷兩個引數是否相等,使用效果如下:

function eq(a, b) { 
...
}var a = [1];
var b = [1];
console.log(eq(a, b)) // true複製程式碼

在寫這個看似很簡單的函式之前,我們首先了解在一些簡單的情況下是如何判斷的?

+0 與 -0

如果 a === b 的結果為 true, 那麼 a 和 b 就是相等的嗎?一般情況下,當然是這樣的,但是有一個特殊的例子,就是 +0 和 -0。

JavaScript “處心積慮”的想抹平兩者的差異:

// 表現1console.log(+0 === -0);
// true// 表現2(-0).toString() // '0'(+0).toString() // '0'// 表現3-0 <
+0 // false+0 <
-0 // false複製程式碼

即便如此,兩者依然是不同的:

1 / +0 // Infinity1 / -0 // -Infinity1 / +0 === 1 / -0 // false複製程式碼

也許你會好奇為什麼要有 +0 和 -0 呢?

這是因為 JavaScript 採用了IEEE_754 浮點數表示法(幾乎所有現代程式語言所採用),這是一種二進位制表示法,按照這個標準,最高位是符號位(0 代表正,1 代表負),剩下的用於表示大小。而對於零這個邊界值 ,1000(-0) 和 0000(0)都是表示 0 ,這才有了正負零的區別。

也許你會好奇什麼時候會產生 -0 呢?

Math.round(-0.1) // -0複製程式碼

那麼我們又該如何在 === 結果為 true 的時候,區別 0 和 -0 得出正確的結果呢?我們可以這樣做:

function eq(a, b){ 
if (a === b) return a !== 0 || 1 / a === 1 / b;
return false;

}console.log(eq(0, 0)) // trueconsole.log(eq(0, -0)) // false複製程式碼

NaN

在本篇,我們認為 NaN 和 NaN 是相等的,那又該如何判斷出 NaN 呢?

console.log(NaN === NaN);
// false複製程式碼

利用 NaN 不等於自身的特性,我們可以區別出 NaN,那麼這個 eq 函式又該怎麼寫呢?

function eq(a, b) { 
if (a !== a) return b !== b;

}console.log(eq(NaN, NaN));
// true複製程式碼

eq 函式

現在,我們已經可以去寫 eq 函式的第一版了。

// eq 第一版// 用來過濾掉簡單的型別比較,複雜的物件使用 deepEq 函式進行處理function eq(a, b) { 
// === 結果為 true 的區別出 +0 和 -0 if (a === b) return a !== 0 || 1 / a === 1 / b;
// typeof null 的結果為 object ,這裡做判斷,是為了讓有 null 的情況儘早退出函式 if (a == null || b == null) return false;
// 判斷 NaN if (a !== a) return b !== b;
// 判斷引數 a 型別,如果是基本型別,在這裡可以直接返回 false var type = typeof a;
if (type !== 'function' &
&
type !== 'object' &
&
typeof b != 'object') return false;
// 更復雜的物件使用 deepEq 函式進行深度比較 return deepEq(a, b);

};
複製程式碼

也許你會好奇是不是少了一個 typeof b !== function?

試想如果我們新增上了這句,當 a 是基本型別,而 b 是函式的時候,就會進入 deepEq 函式,而去掉這一句,就會進入直接進入 false,實際上 基本型別和函式肯定是不會相等的,所以這樣做程式碼又少,又可以讓一種情況更早退出。

String 物件

現在我們開始寫 deepEq 函式,一個要處理的重大難題就是 '
Curly'
和 new String('
Curly'
) 如何判斷成相等?

兩者的型別都不一樣吶!不信我們看 typeof 的操作結果:

console.log(typeof 'Curly');
// stringconsole.log(typeof new String('Curly'));
// object複製程式碼

可是我們在《JavaScript專題之型別判斷上》中還學習過更多的方法判斷型別,比如 Object.prototype.toString:

var toString = Object.prototype.toString;
toString.call('Curly');
// "[object String]"toString.call(new String('Curly'));
// "[object String]"複製程式碼

神奇的是使用 toString 方法兩者判斷的結果卻是一致的,可是就算知道了這一點,還是不知道如何判斷字串和字串包裝物件是相等的呢?

那我們利用隱式型別轉換呢?

console.log('Curly' + '' === new String('Curly') + '');
// true複製程式碼

看來我們已經有了思路:如果 a 和 b 的 Object.prototype.toString的結果一致,並且都是"
[object String]"
,那我們就使用 '
'
+ a === '
'
+ b 進行判斷。

可是不止有 String 物件吶,Boolean、Number、RegExp、Date呢?

更多物件

跟 String 同樣的思路,利用隱式型別轉換。

Boolean

var a = true;
var b = new Boolean(true);
console.log(+a === +b) // true複製程式碼

Date

var a = new Date(2009, 9, 25);
var b = new Date(2009, 9, 25);
console.log(+a === +b) // true複製程式碼

RegExp

var a = /a/i;
var b = new RegExp(/a/i);
console.log('' + a === '' + b) // true複製程式碼

Number

var a = 1;
var b = new Number(1);
console.log(+a === +b) // true複製程式碼

嗯哼?你確定 Number 能這麼簡單的判斷?

var a = Number(NaN);
var b = Number(NaN);
console.log(+a === +b);
// false複製程式碼

可是 a 和 b 應該被判斷成 true 的吶~

那麼我們就改成這樣:

var a = Number(NaN);
var b = Number(NaN);
function eq() {
// 判斷 Number(NaN) Object(NaN) 等情況 if (+a !== +a) return +b !== +b;
// 其他判斷 ...
}console.log(eq(a, b));
// true複製程式碼

deepEq 函式

現在我們可以寫一點 deepEq 函式了。

var toString = Object.prototype.toString;
function deepEq(a, b) {
var className = toString.call(a);
if (className !== toString.call(b)) return false;
switch (className) {
case '[object RegExp]': case '[object String]': return '' + a === '' + b;
case '[object Number]': if (+a !== +a) return +b !== +b;
return +a === 0 ? 1 / +a === 1 / b : +a === +b;
case '[object Date]': case '[object Boolean]': return +a === +b;

} // 其他判斷
}複製程式碼

建構函式例項

我們看個例子:

function Person() { 
this.name = name;

}function Animal() {
this.name = name
}var person = new Person('Kevin');
var animal = new Animal('Kevin');
eq(person, animal) // ???複製程式碼

雖然 personanimal 都是 {name: '
Kevin'

},但是 personanimal 屬於不同建構函式的例項,為了做出區分,我們認為是不同的物件。

如果兩個物件所屬的建構函式物件不同,兩個物件就一定不相等嗎?

並不一定,我們再舉個例子:

var attrs = Object.create(null);
attrs.name = "Bob";
eq(attrs, {name: "Bob"
});
// ???複製程式碼

儘管 attrs 沒有原型,{name: "
Bob"

} 的建構函式是 Object,但是在實際應用中,只要他們有著相同的鍵值對,我們依然認為是相等。

從函式設計的角度來看,我們不應該讓他們相等,但是從實踐的角度,我們讓他們相等,所以相等就是一件如此隨意的事情嗎?!對啊,我也在想:undersocre,你怎麼能如此隨意呢!!!

哎,吐槽完了,我們還是要接著寫這個相等函式,我們可以先做個判斷,對於不同建構函式下的例項直接返回 false。

function isFunction(obj) { 
return toString.call(obj) === '[object Function]'
}function deepEq(a, b) {
// 接著上面的內容 var areArrays = className === '[object Array]';
// 不是陣列 if (!areArrays) {
// 過濾掉兩個函式的情況 if (typeof a != 'object' || typeof b != 'object') return false;
var aCtor = a.constructor, bCtor = b.constructor;
// aCtor 和 bCtor 必須都存在並且都不是 Object 建構函式的情況下,aCtor 不等於 bCtor, 那這兩個物件就真的不相等啦 if (aCtor == bCtor &
&
!(isFunction(aCtor) &
&
aCtor instanceof aCtor &
&
isFunction(bCtor) &
&
bCtor instanceof bCtor) &
&
('constructor' in a &
&
'constructor' in b)) {
return false;

}
} // 下面還有好多判斷
}複製程式碼

陣列相等

現在終於可以進入我們期待已久的陣列和物件的判斷,不過其實這個很簡單,就是遞迴遍歷一遍……

function deepEq(a, b) { 
// 再接著上面的內容 if (areArrays) {
length = a.length;
if (length !== b.length) return false;
while (length--) {
if (!eq(a[length], b[length])) return false;

}
} else {
var keys = Object.keys(a), key;
length = keys.length;
if (Object.keys(b).length !== length) return false;
while (length--) {
key = keys[length];
if (!(b.hasOwnProperty(key) &
&
eq(a[key], b[key]))) return false;

}
} return true;

}複製程式碼

迴圈引用

如果覺得這就結束了,簡直是太天真,因為最難的部分才終於要開始,這個問題就是迴圈引用!

舉個簡單的例子:

a = {abc: null
};
b = {abc: null
};
a.abc = a;
b.abc = b;
eq(a, b)複製程式碼

再複雜一點的,比如:

a = {foo: {b: {foo: {c: {foo: null
}
}
}
}
};
b = {foo: {b: {foo: {c: {foo: null
}
}
}
}
};
a.foo.b.foo.c.foo = a;
b.foo.b.foo.c.foo = b;
eq(a, b)複製程式碼

為了給大家演示下迴圈引用,大家可以把下面這段已經精簡過的程式碼複製到瀏覽器中嘗試:

// demovar a, b;
a = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
b = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
a.foo.b.foo.c.foo = a;
b.foo.b.foo.c.foo = b;
function eq(a, b, aStack, bStack) {
if (typeof a == 'number') {
return a === b;

} return deepEq(a, b)
}function deepEq(a, b) {
var keys = Object.keys(a);
var length = keys.length;
var key;
while (length--) {
key = keys[length] // 這是為了讓你看到程式碼其實一直在執行 console.log(a[key], b[key]) if (!eq(a[key], b[key])) return false;

} return true;

}eq(a, b)複製程式碼

嗯,以上的程式碼是死迴圈。

那麼,我們又該如何解決這個問題呢?underscore 的思路是 eq 的時候,多傳遞兩個引數為 aStack 和 bStack,用來儲存 a 和 b 遞迴比較過程中的 a 和 b 的值,咋說的這麼繞口呢?
我們直接看個精簡的例子:

var a, b;
a = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
b = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
a.foo.b.foo.c.foo = a;
b.foo.b.foo.c.foo = b;
function eq(a, b, aStack, bStack) {
if (typeof a == 'number') {
return a === b;

} return deepEq(a, b, aStack, bStack)
}function deepEq(a, b, aStack, bStack) {
aStack = aStack || [];
bStack = bStack || [];
var length = aStack.length;
while (length--) {
if (aStack[length] === a) {
return bStack[length] === b;

}
} aStack.push(a);
bStack.push(b);
var keys = Object.keys(a);
var length = keys.length;
var key;
while (length--) {
key = keys[length] console.log(a[key], b[key], aStack, bStack) if (!eq(a[key], b[key], aStack, bStack)) return false;

} // aStack.pop();
// bStack.pop();
return true;

}console.log(eq(a, b))複製程式碼

之所以註釋掉 aStack.pop()bStack.pop()這兩句,是為了方便大家檢視 aStack bStack的值。

最終的 eq 函式

最終的程式碼如下:

var toString = Object.prototype.toString;
function isFunction(obj) {
return toString.call(obj) === '[object Function]'
}function eq(a, b, aStack, bStack) {
// === 結果為 true 的區別出 +0 和 -0 if (a === b) return a !== 0 || 1 / a === 1 / b;
// typeof null 的結果為 object ,這裡做判斷,是為了讓有 null 的情況儘早退出函式 if (a == null || b == null) return false;
// 判斷 NaN if (a !== a) return b !== b;
// 判斷引數 a 型別,如果是基本型別,在這裡可以直接返回 false var type = typeof a;
if (type !== 'function' &
&
type !== 'object' &
&
typeof b != 'object') return false;
// 更復雜的物件使用 deepEq 函式進行深度比較 return deepEq(a, b, aStack, bStack);

};
function deepEq(a, b, aStack, bStack) {
// a 和 b 的內部屬性 [[class]] 相同時 返回 true var className = toString.call(a);
if (className !== toString.call(b)) return false;
switch (className) {
case '[object RegExp]': case '[object String]': return '' + a === '' + b;
case '[object Number]': if (+a !== +a) return +b !== +b;
return +a === 0 ? 1 / +a === 1 / b : +a === +b;
case '[object Date]': case '[object Boolean]': return +a === +b;

} var areArrays = className === '[object Array]';
// 不是陣列 if (!areArrays) {
// 過濾掉兩個函式的情況 if (typeof a != 'object' || typeof b != 'object') return false;
var aCtor = a.constructor, bCtor = b.constructor;
// aCtor 和 bCtor 必須都存在並且都不是 Object 建構函式的情況下,aCtor 不等於 bCtor, 那這兩個物件就真的不相等啦 if (aCtor == bCtor &
&
!(isFunction(aCtor) &
&
aCtor instanceof aCtor &
&
isFunction(bCtor) &
&
bCtor instanceof bCtor) &
&
('constructor' in a &
&
'constructor' in b)) {
return false;

}
} aStack = aStack || [];
bStack = bStack || [];
var length = aStack.length;
// 檢查是否有迴圈引用的部分 while (length--) {
if (aStack[length] === a) {
return bStack[length] === b;

}
} aStack.push(a);
bStack.push(b);
// 陣列判斷 if (areArrays) {
length = a.length;
if (length !== b.length) return false;
while (length--) {
if (!eq(a[length], b[length], aStack, bStack)) return false;

}
} // 物件判斷 else {
var keys = Object.keys(a), key;
length = keys.length;
if (Object.keys(b).length !== length) return false;
while (length--) {
key = keys[length];
if (!(b.hasOwnProperty(key) &
&
eq(a[key], b[key], aStack, bStack))) return false;

}
} aStack.pop();
bStack.pop();
return true;

}console.log(eq(0, 0)) // trueconsole.log(eq(0, -0)) // falseconsole.log(eq(NaN, NaN));
// trueconsole.log(eq(Number(NaN), Number(NaN)));
// trueconsole.log(eq('Curly', new String('Curly')));
// trueconsole.log(eq([1], [1]));
// trueconsole.log(eq({
value: 1
}, {
value: 1
}));
// truevar a, b;
a = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
b = {
foo: {
b: {
foo: {
c: {
foo: null
}
}
}
}
};
a.foo.b.foo.c.foo = a;
b.foo.b.foo.c.foo = b;
console.log(eq(a, b)) // true複製程式碼

真讓人感嘆一句:eq 不愧是 underscore 中實現程式碼行數最多的函式了!

專題系列

JavaScript專題系列目錄地址:github.com/mqyqingfeng…

JavaScript專題系列預計寫二十篇左右,主要研究日常開發中一些功能點的實現,比如防抖、節流、去重、型別判斷、拷貝、最值、扁平、柯里、遞迴、亂序、排序等,特點是研(chao)究(xi) underscore 和 jQuery 的實現方式。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

來源:https://juejin.im/post/598a701b6fb9a03c5b04bb14

相關文章