如何判斷JavaScript中的兩變數是否相等?

Russ_Zhong發表於2018-03-04

1 為什麼要判斷?

可能有些同學看到這個標題就會產生疑惑,為什麼我們要判斷JavaScript中的兩個變數是否相等,JavaScript不是已經提供了雙等號“==”以及三等號“===”給我們使用了嗎?

其實,JavaScript雖然給我們提供了相等運算子,但是還是存在一些缺陷,這些缺陷不符合我們的思維習慣,有可能在使用的時候得到一些意外的結果。為了避免這種情況的出現,我們需要自己函式來實現JavaScript變數之間的對比。

2 JavaScript等號運算子存在哪些缺陷?

2.1 0與-0

在JavaScript中:

0 === 0
//true
+0 === -0
//true
複製程式碼

相等運算子認為+0和-0是相等的,但是我們應當認為兩者是不等的,具體原因原始碼中給出了一個連結:Harmony egal proposal.

2.2 null和undefined

在JavaScript中:

null == undefined
//true
null === undefined
//false
複製程式碼

我們應當認為null不等於undefined,所以在比較null和undefined時,應當返回false。

2.3 NaN

前文有說過,NaN是一個特殊的值,它是JavaScript中唯一一個自身不等於自身的值。

NaN == NaN
//false
NaN === NaN
//false    
複製程式碼

但是我們在對比兩個NaN時,我們應當認為它們是相等的。

2.4 陣列之間的對比

由於在JavaScript中,陣列是一個物件,所以如果兩個變數不是引用的同一個陣列的話,即使兩個陣列一模一樣也不會返回true。

var a = [];
//undefined
var b = [];
//undefined
a=== b
//false
a==b
//false
複製程式碼

但是我們應當認為,兩個元素位置、順序以及值相同的陣列是相等的。

2.5 物件之間的對比

凡是涉及到物件的變數,只要不是引用同一個物件,都會被認為不相等。我們需要做出一些改變,兩個完全一致的物件應當被認為是相等的。

var a  = {};
//undefined
var b = {};
//undefined
a == b
//false
a === b
//false
複製程式碼

這種情況在所有JavaScript內建物件中也適用,比如我們應當認為兩個一樣的RegExp物件是相等的。

2.6 基本資料型別與包裝資料型別之間的對比

在JavaScript中,數值2和Number物件2是不嚴格相等的:

2 == new Number(2);
//true
2 === new Number(2);
//false
複製程式碼

但是我們在對比2和new Number(2)時應當認為兩者相等。

3 underscore的實現方法

我們實現的方法當然還是依賴於JavaScript相等運算子的,只不過針對特例需要有特定的處理。我們在比較之前,首先應該做的就是處理特殊情況。

underscore的程式碼中,沒有直接將邏輯寫在_.isEqual方法中,而是定義了兩個私有方法:eq和deepEq。在GitHub使用者@hanzichi的repo中,我們可以看到1.8.3版本的underscore中並沒有deepEq方法,為什麼後來新增了呢?這是因為underscore的作者把一些特例的處理提取了出來,放到了eq方法中,而更加複雜的物件之間的對比被放到了deepEq中(同時使得deepEq方法更加便於遞迴呼叫)。這樣的做法使得程式碼邏輯更加鮮明,方法的功能也更加單一明確,維護程式碼更加簡潔快速。

eq方法的原始碼:

var eq = function (a, b, aStack, bStack) {
	// Identical objects are equal. `0 === -0`, but they aren't identical.
	// See the [Harmony `egal` proposal](http://wiki.ecmascript.org/doku.php?id=harmony:egal).
	//除了0 === -0這個特例之外,其餘所有a === b的例子都代表它們相等。
	//應當判斷0 !== -0,但是JavaScript中0 === -0。
	//下面這行程式碼就是為了解決這個問題。
	//當a !== 0或者1/a === 1/b時返回true,一旦a === 0並且1/a !== 1/b就返回false。
	//而a === 0且1/a !== 1/b就代表a,b有一個為0,有一個為-0。
	if (a === b) return a !== 0 || 1 / a === 1 / b;
	//一旦a、b不嚴格相等,就進入後續檢測。
	//a == b成立但是a === b不成立的例子中需要排除null和undefined,其餘例子需要後續判斷。
	// `null` or `undefined` only equal to itself (strict comparison).
	//一旦a或者b中有一個為null就代表另一個為undefined,這種情況可以直接排除。
	if (a == null || b == null) return false;
	// `NaN`s are equivalent, but non-reflexive.
	//自身不等於自身的情況,一旦a,b都為NaN,則可以返回true。
	if (a !== a) return b !== b;
	// Exhaust primitive checks
	//如果a,b都不為JavaScript物件,那麼經過以上監測之後還不嚴格相等的話就可以直接斷定a不等於b。
	var type = typeof a;
	if (type !== 'function' && type !== 'object' && typeof b != 'object') return false;
	//如果a,b是JavaScript物件,還需要做後續深入的判斷。
	return deepEq(a, b, aStack, bStack);
};
複製程式碼

對於原始碼的解讀我已經作為註釋寫在了原始碼中。 那麼根據原始碼,可以將其邏輯抽象出來:

如何判斷JavaScript中的兩變數是否相等?

deepEq的原始碼:

var deepEq = function (a, b, aStack, bStack) {
	// Unwrap any wrapped objects.
	//如果a,b是_的一個例項的話,需要先把他們解包出來再進行比較。
	if (a instanceof _) a = a._wrapped;
	if (b instanceof _) b = b._wrapped;
	// Compare `[[Class]]` names.
	//先根據a,b的Class字串進行比較,如果兩個物件的Class字串都不一樣,
	//那麼直接可以認為兩者不相等。
	var className = toString.call(a);
	if (className !== toString.call(b)) return false;
	//如果兩者的Class字串相等,再進一步進行比較。
	//優先檢測內建物件之間的比較,非內建物件再往後檢測。
	switch (className) {
		// Strings, numbers, regular expressions, dates, and booleans are compared by value.
		//如果a,b為正規表示式,那麼轉化為字串判斷是否相等即可。
		case '[object RegExp]':
		// RegExps are coerced to strings for comparison (Note: '' + /a/i === '/a/i')
		case '[object String]':
			// Primitives and their corresponding object wrappers are equivalent; thus, `"5"` is
			// equivalent to `new String("5")`.
			//如果a, b是字串物件,那麼轉化為字串進行比較。因為一下兩個變數:
			//var x = new String('12');
			//var y = new String('12');
			//x === y是false,x === y也是false,但是我們應該認為x與y是相等的。
			//所以我們需要將其轉化為字串進行比較。
			return '' + a === '' + b;
		case '[object Number]':
			//數字物件轉化為數字進行比較,並且要考慮new Number(NaN) === new Number(NaN)應該要成立的情況。
			// `NaN`s are equivalent, but non-reflexive.
			// Object(NaN) is equivalent to NaN.
			if (+a !== +a) return +b !== +b;
			// An `egal` comparison is performed for other numeric values.
			//排除0 === -0 的情況。
			return +a === 0 ? 1 / +a === 1 / b : +a === +b;
		case '[object Date]':
		//Date型別以及Boolean型別都可以轉換為number型別進行比較。
		//在變數前加一個加號“+”,可以強制轉換為數值型。
		//在Date型變數前加一個加號“+”可以將Date轉化為毫秒形式;Boolean型別同上(轉換為0或者1)。
		case '[object Boolean]':
			// Coerce dates and booleans to numeric primitive values. Dates are compared by their
			// millisecond representations. Note that invalid dates with millisecond representations
			// of `NaN` are not equivalent.
			return +a === +b;
		case '[object Symbol]':
			return SymbolProto.valueOf.call(a) === SymbolProto.valueOf.call(b);
	}

	var areArrays = className === '[object Array]';
	//如果不是陣列物件。
	if (!areArrays) {
		if (typeof a != 'object' || typeof b != 'object') return false;

		// Objects with different constructors are not equivalent, but `Object`s or `Array`s
		// from different frames are.
		//比較兩個非陣列物件的建構函式。
		var aCtor = a.constructor, bCtor = b.constructor;
		if (aCtor !== bCtor && !(_.isFunction(aCtor) && aCtor instanceof aCtor &&
			_.isFunction(bCtor) && bCtor instanceof bCtor)
			&& ('constructor' in a && 'constructor' in b)) {
			return false;
		}
	}
	// Assume equality for cyclic structures. The algorithm for detecting cyclic
	// structures is adapted from ES 5.1 section 15.12.3, abstract operation `JO`.

	// Initializing stack of traversed objects.
	// It's done here since we only need them for objects and arrays comparison.
	//初次呼叫eq函式時,aStack以及bStack均未被傳遞,在迴圈遞迴的時候,會被傳遞進來。
	//aStack和bStack存在的意義在於迴圈引用物件之間的比較。
	aStack = aStack || [];
	bStack = bStack || [];
	var length = aStack.length;
	
	while (length--) {
		// Linear search. Performance is inversely proportional to the number of
		// unique nested structures.
		if (aStack[length] === a) return bStack[length] === b;
	}

	// Add the first object to the stack of traversed objects.
	//初次呼叫eq函式時,就把兩個引數放入到引數堆疊中去,儲存起來方便遞迴呼叫時使用。
	aStack.push(a);
	bStack.push(b);

	// Recursively compare objects and arrays.
	//如果是陣列物件。
	if (areArrays) {
		// Compare array lengths to determine if a deep comparison is necessary.
		length = a.length;
		//長度不等,直接返回false認定為陣列不相等。
		if (length !== b.length) return false;
		// Deep compare the contents, ignoring non-numeric properties.
		while (length--) {
			//遞迴呼叫。
			if (!eq(a[length], b[length], aStack, bStack)) return false;
		}
	} else {
		// Deep compare objects.
		//對比純物件。
		var keys = _.keys(a), key;
		length = keys.length;
		// Ensure that both objects contain the same number of properties before comparing deep equality.
		//對比屬性數量,如果數量不等,直接返回false。
		if (_.keys(b).length !== length) return false;
		while (length--) {
			// Deep compare each member
			key = keys[length];
			if (!(_.has(b, key) && eq(a[key], b[key], aStack, bStack))) return false;
		}
	}
	// Remove the first object from the stack of traversed objects.
	//迴圈遞迴結束,把a,b堆疊中的元素推出。
	aStack.pop();
	bStack.pop();
	return true;
};
複製程式碼

對於原始碼的解讀我已經作為註釋寫在了原始碼中。 那麼根據原始碼,可以將其邏輯抽象出來:

  • 1 使用Object.prototype.toString方法獲取兩引數型別,如果兩引數的原始資料型別都不同,那麼可以認為兩個引數不相等。

  • 2 如果進入了第二步,那麼說明兩個引數的原始型別相同。針對獲取到的字串進行分類,如果是除Object和Array之外的型別,進行處理。

    • RegExp以及String物件轉化為字串進行比較。
    • Number型別的話,需要先使用+運算子強制轉化為基本資料型別中的數值型,然後處理特例。比如NaN === NaN,0 !== -0.
    • Date以及Boolean物件轉化為數字型別進行對比。(+運算子強制轉換,Date轉化為13位的毫秒形式,Boolean轉化為0或1)
    • Symbol型別使用Symbol.prototype.valueOf獲取字串,然後進行對比(即認為傳遞給Symbol函式相同字串所獲取到的Symbol物件應該相等)。
  • 3 經過以上比較,所剩型別基本只剩Array和基本物件了。如果不是陣列物件,那麼建構函式不同的物件可以被認為是不相等的物件。

  • 4 初始化物件棧aStack以及bStack,因為初次呼叫deepEq函式時不會傳遞這兩個引數,所以需要手動初始化。因為之後比較的陣列物件以及基本物件需要用到物件棧,所以現在應該把當前的a,b推入到兩個棧中。

  • 5 針對陣列,先比較長度,長度不等則陣列不等。長度相等再遞迴呼叫deepGet比較陣列的每一項,有一項不等則返回false。

  • 6 基本物件型別比較,先使用_.keys獲取物件的所有鍵。鍵數量不同的兩物件不同,如果鍵數目相等,再遞迴呼叫deepEq比較每一個鍵的屬性,有一個鍵值不等則返回false。

  • 7 經過所有檢測如果都沒有返回false的話,可以認為兩引數相等,返回true。在返回之前會把棧中的資料推出一個。

4 underscore的精髓

4.1 將RegExp物件和String物件用相同方法處理

有同學可能會疑惑:/[a-z]/gi/[a-z]ig/在意義上是一樣的,但是轉化為字串之後比較會不會是不相等的?

這是一個非常好的問題,同時也是underscore處理的巧妙之所在。在JavaScript中,RegExp物件重寫了toString方法,所以在強制將RegExp物件轉化為字串時,flags會按規定順序排列,所以將之前兩個RegExp物件轉化為字串,都會得到/[a-z]/gi。這就是underscore可以放心大膽的將RegExp物件轉化為字串處理的原因。

4.2 Date物件和Boolean物件使用相同方法處理

underscore選擇將Date物件和Boolean物件都轉化為數值進行處理,這避免了紛繁複雜的型別轉換,簡單粗暴。而且作者沒有使用強制轉換方法進行轉換,而是隻使用了一個“+”符號,就強制將Date物件和Boolean物件轉換成了數值型資料。

4.3 使用物件棧儲存當前比較物件的上下文

很多童鞋在閱讀原始碼時,可能會很疑惑aStack以及bStack的作用在哪裡。aStack和bStack用於儲存當前比較物件的上下文,這使得我們在比較某個物件的子屬性時,還可以獲取到其自身。這樣做的好處就在於我們可以比較迴圈引用的物件。

var a = {
    name: 'test'
};
a['test1'] = a;
var b = {
    name: 'test'
};
b['test1'] = b;
_.isEqual(a, b);
//true
複製程式碼

underscore使用aStack和bStack作比較的程式碼:

aStack = aStack || [];
bStack = bStack || [];
var length = aStack.length;
while (length--) {
    // Linear search. Performance is inversely proportional to the number of
    // unique nested structures.
    if (aStack[length] === a) return bStack[length] === b;
}
複製程式碼

上面的測試程式碼中,a、b物件的test1屬性都引用了它們自身,這樣的物件在比較時會消耗不必要的時間,因為只要a和b的test1屬性都等於其某個父物件,那麼可以認為a和b相等,因為這個被遞迴的方法返回之後,還要繼續比較它們對應的那個父物件,父物件相等,則引用的物件屬性必相等,這樣的處理方法節省了很多的時間,也提高了underscore的效能。

4.4 優先順序分明,有的放矢

underscore的處理具有很強的優先順序,比如在比較陣列物件時,先比較陣列的長度,陣列長度不相同則陣列必定不相等;比如在比較基本物件時,優先比較物件鍵的數目,鍵數目不等則物件必定不等;比如在比較兩個物件引數之前,優先對比Object.prototype.toString返回的字串,如果基本型別不同,那麼兩個物件必定不相等。

這樣的主次分明的對比,大大提高了underscore的工作效率。所以說每一個小小的細節,都可以體現出作者的處心積慮。閱讀原始碼,能夠使我們學習到太多的東西。

5 underscore的缺陷之處

我們可以在其他方法中看到underscore對ES6中新特徵的支援,比如_.is[Type]方法已經支援檢測Map(_.isMap)和Set(_.isSet)等型別了。但是_.isEqual卻沒有對Set和Map結構的支援。如果我們使用_.isEqual比較兩個Map或者兩個Set,總是會得到true的結果,因為它們可以通過所有的檢測。

在underscore的官方GitHub repo上,我看到有同學已經提交了PR新增了_.isEqual對Set和Map的支援。

我們可以看一下原始碼:

var size = a.size;
// Ensure that both objects are of the same size before comparing deep equality.
if (b.size !== size) return false;
while (size--) {
    // Deep compare the keys of each member, using SameValueZero (isEq) for the keys
    if (!(isEq(a.keys().next().value, b.keys().next().value, aStack, bStack))) return false;
    // If the objects are maps deep compare the values. Value equality does not use SameValueZero.
    if (className === '[object Map]') {
        if (!(eq(a.values().next().value, b.values().next().value, aStack, bStack))) return false;
    }
}
複製程式碼

可以看到其思路如下:

  • 1 比較兩引數的長度(或者說是鍵值對數),長度不一者即為不等,返回false。
  • 2 如果長度相等,就逐一遞迴比較它們的每一項,有任意一項不等者就返回false。
  • 3 全部通過則可以認為是相等的,返回true。

這段程式碼有一個很巧妙的地方在於它沒有區分到底是Map物件還是Set物件,先直接使用a.keys().next().value以及b.keys().next().value獲取Set的元素值或者Map的鍵。後面再進行型別判斷,如果是Map物件的話,再使用a.values().next().value以及b.values().next().value獲取Map的鍵值,Map物件還需要比較其鍵值是否相等。

個人認為,這段程式碼也有其侷限性,因為Set和Map可以認為是一個資料集,這區別於陣列物件。我們可以說[1,2,3]不等於[2,1,3],因為其相同元素的位置不同;但是我認為new Set([1,2,3])應該認為等於new Set([2,1,3]),因為Set是無序的,它內部的元素具有單一性。

獲取更多underscore原始碼解讀:GitHub

相關文章