各位同仁,各位对JavaScript深感兴趣的开发者们,大家下午好!
今天,我们将深入探讨JavaScript中一个看似简单却又充满陷阱的核心概念:相等性比较。具体来说,我们将聚焦于JavaScript的两种相等运算符:宽松相等 (==) 和严格相等 (===)。我们将揭露宽松相等可能带来的陷阱,并最终手写实现一个我们自己的、兼顾类型安全与深度比较的严格相等函数。
理解JavaScript的相等性机制,是写出健壮、可预测代码的关键。在许多其他编程语言中,相等性通常是直观且一致的。然而,JavaScript的动态类型和隐式类型转换机制,使得它的相等性判断变得异常复杂,尤其是当我们面对 == 这个“双等号”运算符时。
Part 1: 解开谜团:JavaScript宽松相等 (==) 的真面目
JavaScript中的宽松相等运算符 ==,也被称为抽象相等比较算法(Abstract Equality Comparison Algorithm),它的核心特性是允许在比较前进行类型转换(type coercion)。这意味着,如果两个操作数的类型不同,JavaScript会尝试将其中一个或两个转换成一个共同的类型,然后再进行值的比较。正是这种“善意”的类型转换,常常成为代码中难以察觉的bug源头。
1.1 类型转换的规则与陷阱
宽松相等运算符的类型转换规则相当复杂,且在某些情况下会产生反直觉的结果。以下是一些主要的规则和示例:
-
如果类型相同:
- 数值:值相等则为真 (
5 == 5是true)。 - 字符串:字符序列相同则为真 (
"hello" == "hello"是true)。 - 布尔值:同为
true或同为false则为真 (true == true是true)。 null和undefined:它们自身与自身相等 (null == null是true,undefined == undefined是true)。- 对象:只有当它们引用同一个对象时才相等 (
obj1 == obj1是true,obj1 == obj2如果obj1和obj2引用不同的对象,则为false)。 NaN:NaN与任何值都不相等,包括它自身 (NaN == NaN是false)。
- 数值:值相等则为真 (
-
如果类型不同:这是
==最复杂的部分。-
null与undefined:
null == undefined为true。这是JavaScript中一个非常特殊的规则,它们之间互相宽松相等,但与其他任何值(包括0,"",false)都不宽松相等。console.log(null == undefined); // true console.log(null == 0); // false console.log(undefined == ""); // false -
数值与字符串:
如果一个是数值,另一个是字符串,字符串会被尝试转换为数值。console.log(10 == "10"); // true (字符串 "10" 转换为数值 10) console.log(0 == ""); // true (字符串 "" 转换为数值 0) console.log(0 == " "); // true (字符串 " " 转换为数值 0) console.log(1 == "01"); // true (字符串 "01" 转换为数值 1) console.log(42 == "forty-two"); // false (字符串 "forty-two" 转换为 NaN,NaN 不与任何值相等) -
布尔值与非布尔值:
如果一个是布尔值,另一个不是,布尔值会被转换为数值 (true转换为1,false转换为0),然后进行比较。console.log(true == 1); // true (true 转换为 1) console.log(false == 0); // true (false 转换为 0) console.log(true == "1"); // true (true 转换为 1,"1" 转换为 1) console.log(false == ""); // true (false 转换为 0,"" 转换为 0) console.log(false == " "); // true (false 转换为 0," " 转换为 0) console.log(true == "hello"); // false (true 转换为 1,"hello" 转换为 NaN) -
对象与原始值:
如果一个是对象,另一个是原始值,对象会被尝试转换为原始值(通过valueOf()或toString()方法),然后进行比较。const obj = { valueOf: function() { return 10; }, toString: function() { return "20"; } }; console.log(obj == 10); // true (obj.valueOf() 返回 10) console.log(obj == "20"); // true (obj.toString() 返回 "20",然后 "20" 转换为 20) console.log([] == 0); // true ([] 转换为 "","" 转换为 0) console.log([] == false); // true ([] 转换为 "","" 转换为 0,false 转换为 0) console.log({} == "[object Object]"); // true ({} 转换为 "[object Object]")特别值得注意的是空数组
[]和空对象{}的行为:
[]在转换为原始值时,先尝试valueOf()(返回自身),再尝试toString()(返回"")。所以[] == 0是true。
{}在转换为原始值时,先尝试valueOf()(返回自身),再尝试toString()(返回"[object Object]")。所以{}与数值的比较通常不相等,除非与"[object Object]"字符串比较。 -
Symbol 与其他类型:
Symbol 无法被隐式转换为数值或字符串,所以 Symbol 只能与其自身宽松相等。const sym = Symbol('foo'); console.log(sym == sym); // true console.log(sym == 'foo'); // false console.log(sym == String(sym)); // false
-
1.2 宽松相等规则概览表
为了更好地理解这些规则,我们可以将其简化为以下表格:
| 操作数1类型 | 操作数2类型 | 转换规则 | 示例 | 结果 |
|---|---|---|---|---|
null |
undefined |
无转换,直接返回 true |
null == undefined |
true |
Number |
String |
String 转 Number |
10 == "10" |
true |
String |
Number |
String 转 Number |
"10" == 10 |
true |
Boolean |
任意类型 | Boolean 转 Number (true -> 1, false -> 0) |
true == "1" |
true |
| 任意类型 | Boolean |
Boolean 转 Number |
0 == false |
true |
Object |
Primitive |
Object 转 Primitive (通过 valueOf() 或 toString()) |
[] == 0 |
true |
Primitive |
Object |
Object 转 Primitive |
0 == [] |
true |
NaN |
任意类型 | 永远返回 false |
NaN == NaN |
false |
Symbol |
任意类型 (非 Symbol) |
永远返回 false |
Symbol() == "foo" |
false |
1.3 为什么 == 如此危险?
- 不可预测性:它的行为取决于操作数的类型,而这些类型在动态语言中可能在运行时发生变化。
- 隐藏错误:意想不到的类型转换可能掩盖真正的逻辑错误,使得调试变得困难。
- 代码可读性差:阅读使用
==的代码时,需要记住复杂的类型转换规则,降低了代码的可理解性。 - 安全漏洞:在处理用户输入或外部数据时,
==的宽松性可能导致意想不到的比较结果,从而产生安全漏洞。例如," " == 0为true,可能绕过某些简单的验证逻辑。
鉴于上述种种原因,业界普遍推荐尽可能避免使用 == 运算符,除非你对它的所有类型转换规则了如指掌,并且有明确的、需要利用其隐式转换的理由(这种情况非常罕见)。
Part 2: 严格与清晰:JavaScript严格相等 (===) 的原则
与 == 的复杂性形成鲜明对比的是,JavaScript的严格相等运算符 ===,也被称为严格相等比较算法(Strict Equality Comparison Algorithm),其规则要简单得多,也更具预测性。它的核心原则是:不进行类型转换。
2.1 严格相等的核心规则
- 类型优先:如果两个操作数的类型不同,
===会立即返回false。这是它与==最根本的区别。 -
值比较:只有当两个操作数的类型完全相同时,
===才会比较它们的值。-
原始值(
string,number,boolean,symbol,bigint,undefined,null):- 如果类型和值都相同,则为
true。 NaN特例:NaN === NaN仍然是false。NaN是唯一一个不与自身严格相等的值。+0和-0特例:+0 === -0为true。在数值上,它们被认为是相等的。null === undefined为false,因为它们的类型不同。
- 如果类型和值都相同,则为
-
对象(包括函数、数组等):
- 只有当它们引用内存中的同一个对象时,才严格相等。这被称为“引用相等”。
- 即使两个不同的对象具有完全相同的属性和值,它们也不是严格相等的。
-
2.2 严格相等规则概览表
| 操作数1类型 | 操作数2类型 | 比较规则 | 示例 | 结果 |
|---|---|---|---|---|
| 不同类型 | 不同类型 | 直接返回 false |
10 === "10" |
false |
Number |
Number |
值相等 | 5 === 5 |
true |
Number |
Number |
NaN 特例 |
NaN === NaN |
false |
Number |
Number |
+0/-0 特例 |
+0 === -0 |
true |
String |
String |
字符序列相等 | "hello" === "hello" |
true |
Boolean |
Boolean |
值相等 | true === true |
true |
null |
null |
null 与自身严格相等 |
null === null |
true |
undefined |
undefined |
undefined 与自身严格相等 |
undefined === undefined |
true |
Object |
Object |
引用相等 | const a={}; const b={}; a===b |
false |
Object |
Object |
引用相等 | const c={}; c===c |
true |
Symbol |
Symbol |
引用相等 (每个 Symbol 都是唯一的) | Symbol('foo') === Symbol('foo') |
false |
2.3 === 的局限性
尽管 === 解决了 == 的大部分问题,提供了可靠且可预测的比较行为,但它仍然存在一些局限性,尤其是在处理复杂数据结构时:
-
无法进行深度比较:
===只进行浅层比较。对于对象和数组,它只检查它们是否引用了内存中的同一个地址。这意味着,即使两个不同的数组或对象具有完全相同的元素或属性,===也会认为它们不相等。const arr1 = [1, 2, { a: 3 }]; const arr2 = [1, 2, { a: 3 }]; console.log(arr1 === arr2); // false (即使内容相同,但它们是两个不同的数组实例) const obj1 = { x: 1, y: { z: 2 } }; const obj2 = { x: 1, y: { z: 2 } }; console.log(obj1 === obj2); // false (同理,两个不同的对象实例)在许多实际应用场景中,我们可能需要判断两个对象或数组的“内容”是否相等,而不仅仅是它们的引用。这就是我们接下来要解决的问题。
Part 3: 打造我们的守护者 – 一个类型安全的深度严格相等函数
现在,我们面临一个挑战:如何实现一个函数,它既能像 === 那样严格遵守类型,不进行隐式类型转换,又能深入比较复杂数据结构(如对象和数组)的内容,而不是仅仅比较它们的引用?我们称之为 deepStrictEquals。
这个函数的目标是:
- 无类型转换:严格遵循
===的“不转换类型”原则。 - 深度比较:对于对象和数组,递归地比较它们的所有属性和元素。
- 类型安全:确保在比较过程中,只有相同类型的值才可能相等。
- 特殊值处理:正确处理
NaN、+0/-0、null、undefined。 - 特殊对象处理:处理
Date、RegExp、Map、Set、TypedArray等内置对象。 - 循环引用检测:防止在递归比较时陷入无限循环。
我们将分步骤构建这个函数。
3.1 核心辅助函数:获取精确类型和判断原始值
在JavaScript中,判断一个变量的精确类型并非易事。typeof 运算符对原始值非常有用,但对对象类型却会统一返回 "object"(null 也会返回 "object",这是历史遗留问题)。因此,我们需要一个更精确的方法来获取对象的内部 [[Class]] 属性。
/**
* 获取值的精确类型字符串。
* @param {*} value - 任意值。
* @returns {string} - 类型字符串,例如 'Object', 'Array', 'Date', 'RegExp', 'Number', 'String' 等。
*/
function getPreciseType(value) {
if (value === null) {
return 'Null';
}
const type = typeof value;
if (type === 'object' || type === 'function') {
// 对于对象和函数,使用 Object.prototype.toString 来获取更精确的类型
return Object.prototype.toString.call(value).slice(8, -1);
}
// 对于原始类型,typeof 返回的结果已经足够精确
return type.charAt(0).toUpperCase() + type.slice(1);
}
/**
* 判断一个值是否为原始值。
* @param {*} value - 任意值。
* @returns {boolean} - 如果是原始值则返回 true。
*/
function isPrimitive(value) {
const type = typeof value;
return value === null || (type !== 'object' && type !== 'function');
}
3.2 deepStrictEquals 函数骨架
我们将从最基础的比较规则开始,逐步添加对复杂类型的支持。
/**
* 执行两个值之间的类型安全的深度严格相等比较。
* @param {*} a - 第一个值。
* @param {*} b - 第二个值。
* @param {WeakSet} [visited] - 用于检测循环引用的Set。
* @returns {boolean} - 如果两个值深度严格相等则返回 true,否则返回 false。
*/
function deepStrictEquals(a, b, visited = new WeakSet()) {
// 1. 快速路径:如果引用相同,它们必然相等
if (a === b) {
// 特殊处理 +0 和 -0,在 === 下它们是相等的,符合预期。
// 但是对于 NaN,a === b 已经是 false,所以这里不会影响 NaN 的处理。
return true;
}
// 2. NaN 处理:NaN === NaN 为 false,但我们希望 deepStrictEquals(NaN, NaN) 为 true
if (Number.isNaN(a) && Number.isNaN(b)) {
return true;
}
// 3. 原始值比较:如果不是 === 相等,且至少一个是原始值,那么它们不相等
// (因为 === 已经处理了相同类型的原始值相等的情况,这里只剩下不同类型或 NaN 的情况)
if (isPrimitive(a) || isPrimitive(b)) {
// 如果上面 NaN 的特殊处理没有匹配,那么不同的原始值或 NaN 与其他值的比较都为 false
// 例如:1 === "1" -> false, 1 === NaN -> false
return false;
}
// 4. 类型检查:确保是相同类型的对象
const typeA = getPreciseType(a);
const typeB = getPreciseType(b);
if (typeA !== typeB) {
return false;
}
// 5. 循环引用检测:防止无限递归
// 只有当 a 和 b 都是对象时才需要检测
if (visited.has(a) && visited.has(b)) {
// 如果两个对象都在已访问集合中,并且它们是同一个引用,则认为它们相等
// 如果是不同的引用,但都已访问,这通常意味着我们已经比较过它们内部的某些部分
// 但为了严谨,对于循环引用,只要两个引用都已访问,我们通常认为它们是相等的
// 否则,我们会陷入无限循环。
return true;
}
visited.add(a);
visited.add(b);
// 6. 根据精确类型进行深度比较
switch (typeA) {
case 'Array':
return compareArrays(a, b, visited);
case 'Object':
return compareObjects(a, b, visited);
case 'Date':
return compareDates(a, b);
case 'RegExp':
return compareRegExps(a, b);
case 'Map':
return compareMaps(a, b, visited);
case 'Set':
return compareSets(a, b, visited);
case 'ArrayBuffer':
case 'DataView':
return compareArrayBuffersAndDataViews(a, b);
case 'Int8Array':
case 'Uint8Array':
case 'Uint8ClampedArray':
case 'Int16Array':
case 'Uint16Array':
case 'Int32Array':
case 'Uint32Array':
case 'Float32Array':
case 'Float64Array':
case 'BigInt64Array':
case 'BigUint64Array':
return compareTypedArrays(a, b);
case 'Error':
return compareErrors(a, b);
case 'Function':
// 函数通常只比较引用,或者极端情况下比较它们的 toString() 结果
// 但 toString() 结果可能因环境、格式化而异,不可靠。
// 严格来说,不同引用的函数就是不相等的。
return false; // 如果 a === b 没通过,则不同引用,不相等
case 'Symbol':
// Symbol 也是引用相等,每个 Symbol 都是唯一的。
return false; // 如果 a === b 没通过,则不同引用,不相等
default:
// 对于其他未知或未处理的对象类型,我们默认它们不相等
// 除非它们通过了 a === b 的快速检查
return false;
}
}
3.3 实现各种比较辅助函数
现在我们来实现 deepStrictEquals 中调用的各种 compare... 函数。
3.3.1 compareArrays
比较两个数组的长度和每个元素。
function compareArrays(arr1, arr2, visited) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (!deepStrictEquals(arr1[i], arr2[i], visited)) {
return false;
}
}
return true;
}
3.3.2 compareObjects
比较两个普通对象的属性。需要考虑属性名、属性值,以及原型链(这里我们只比较自身可枚举属性)。
function compareObjects(obj1, obj2, visited) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
// 检查原型是否一致(或者我们只关注自身属性)
// 这里我们选择忽略原型链,只比较自身可枚举属性。
// 如果需要比较原型,则需要更复杂的逻辑,例如 Object.getPrototypeOf(obj1) === Object.getPrototypeOf(obj2)
// 并且可能需要递归比较原型链,这会大大增加复杂性。
// 对于大多数深度比较场景,只比较自身属性是更常见的需求。
for (const key of keys1) {
if (!Object.prototype.hasOwnProperty.call(obj2, key) || !deepStrictEquals(obj1[key], obj2[key], visited)) {
return false;
}
}
return true;
}
3.3.3 compareDates
比较两个 Date 对象的时间戳。
function compareDates(d1, d2) {
return d1.getTime() === d2.getTime();
}
3.3.4 compareRegExps
比较两个 RegExp 对象的 source 和 flags。
function compareRegExps(r1, r2) {
return r1.source === r2.source && r1.flags === r2.flags;
}
3.3.5 compareMaps
比较两个 Map 对象的尺寸和每个键值对。由于 Map 的键可以是任意类型,我们需要递归比较键和值。
function compareMaps(map1, map2, visited) {
if (map1.size !== map2.size) {
return false;
}
for (const [key1, value1] of map1.entries()) {
// Map.prototype.has() 内部使用 SameValueZero 算法,与 === 略有不同
// 但对于我们的深度比较,需要确保键本身也是深度相等的
// 这需要更复杂的查找逻辑,如果键是对象,简单的 map2.has(key1) 不够
// 因此我们遍历 map1 的所有键值对,并在 map2 中查找深度相等的键。
let found = false;
for (const [key2, value2] of map2.entries()) {
if (deepStrictEquals(key1, key2, visited)) {
if (deepStrictEquals(value1, value2, visited)) {
found = true;
break;
}
}
}
if (!found) {
return false;
}
}
return true;
}
compareMaps 的优化和思考: 上述 compareMaps 实现存在性能问题,因为它对 map2 进行了内层循环查找。对于 Map,键的比较是基于 SameValueZero 算法的,这比 === 更宽松(例如 NaN 与 NaN 相等)。如果我们的 deepStrictEquals 目标是完全模拟 === 的行为,那么我们需要考虑 Map 内部键的比较逻辑。然而,为了保持深度比较的“类型安全严格性”,我们坚持使用 deepStrictEquals 来比较键。
一个更高效但更复杂的 compareMaps 实现可能需要先将 Map 转换为可排序的数组结构(例如 [[key, value], ...]),然后比较。但对于任意类型的键,排序也可能是一个挑战。
更实际的折衷方案是:
- 首先检查
map1.size === map2.size。 - 遍历
map1的键值对[k1, v1]。 - 对于每个
k1,尝试使用map2.get(k1)获取对应值v2。 - 然后深度比较
v1和v2。 - 关键问题:
map2.get(k1)依赖于SameValueZero来查找k1。如果k1是一个对象,且map2中存在一个 内容相等但引用不同 的键,map2.get(k1)将失败。
因此,我们必须回退到双重遍历或更复杂的哈希/序列化方案。
为了本讲座的范围和可读性,我们保留双重遍历的逻辑,但请注意其性能影响。在实际生产中,可能需要根据具体场景和键的类型进行优化。
3.3.6 compareSets
比较两个 Set 对象的尺寸和每个元素。与 Map 类似,Set 的元素比较也基于 SameValueZero,但我们的目标是深度严格相等。
function compareSets(set1, set2, visited) {
if (set1.size !== set2.size) {
return false;
}
for (const item1 of set1.values()) {
let found = false;
// 与 Map 类似,如果 Set 包含对象,简单的 set2.has(item1) 不够
// 需要遍历 set2 并深度比较每个元素
for (const item2 of set2.values()) {
if (deepStrictEquals(item1, item2, visited)) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
compareSets 的优化和思考: 与 compareMaps 类似,compareSets 也存在性能问题。在实际应用中,如果 Set 只包含原始值,可以先将 Set 转换为数组并排序,然后比较。但对于包含对象的 Set,排序和深度比较仍然是一个挑战。
3.3.7 compareArrayBuffersAndDataViews
ArrayBuffer 是原始二进制数据缓冲区,DataView 是对其进行操作的视图。我们可以通过比较它们的字节内容来判断是否相等。
function compareArrayBuffersAndDataViews(a, b) {
// 确保它们都是 ArrayBuffer 或 DataView
const isAB1 = getPreciseType(a) === 'ArrayBuffer';
const isAB2 = getPreciseType(b) === 'ArrayBuffer';
const isDV1 = getPreciseType(a) === 'DataView';
const isDV2 = getPreciseType(b) === 'DataView';
// 如果类型不完全匹配 (例如 ArrayBuffer 和 DataView 互比),则不相等
if (!(isAB1 && isAB2) && !(isDV1 && isDV2)) {
return false;
}
const view1 = isAB1 ? new Uint8Array(a) : new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
const view2 = isAB2 ? new Uint8Array(b) : new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
if (view1.byteLength !== view2.byteLength) {
return false;
}
for (let i = 0; i < view1.byteLength; i++) {
if (view1[i] !== view2[i]) {
return false;
}
}
return true;
}
3.3.8 compareTypedArrays
比较各种 TypedArray(如 Uint8Array, Int32Array 等)。它们本质上是 ArrayBuffer 的视图,可以直接比较它们的元素。
function compareTypedArrays(arr1, arr2) {
if (arr1.byteLength !== arr2.byteLength) {
return false;
}
// 比较 TypedArray 的元素
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
}
3.3.9 compareErrors
比较两个 Error 对象。通常比较它们的 name 和 message 属性。stack 属性通常因环境而异,不建议作为相等判断依据。
function compareErrors(err1, err2) {
return err1.name === err2.name && err1.message === err2.message;
// 不比较 stack 属性,因为它可能因环境、浏览器、Node.js 版本等而异,不适合严格比较
// 如果需要,可以添加对其他自定义属性的比较
}
3.4 整合后的 deepStrictEquals 函数
现在,我们将所有部分组合起来,形成一个完整的 deepStrictEquals 函数。
/**
* 获取值的精确类型字符串。
* @param {*} value - 任意值。
* @returns {string} - 类型字符串,例如 'Object', 'Array', 'Date', 'RegExp', 'Number', 'String' 等。
*/
function getPreciseType(value) {
if (value === null) {
return 'Null';
}
const type = typeof value;
if (type === 'object' || type === 'function') {
return Object.prototype.toString.call(value).slice(8, -1);
}
return type.charAt(0).toUpperCase() + type.slice(1);
}
/**
* 判断一个值是否为原始值。
* @param {*} value - 任意值。
* @returns {boolean} - 如果是原始值则返回 true。
*/
function isPrimitive(value) {
const type = typeof value;
return value === null || (type !== 'object' && type !== 'function');
}
function compareArrays(arr1, arr2, visited) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (!deepStrictEquals(arr1[i], arr2[i], visited)) {
return false;
}
}
return true;
}
function compareObjects(obj1, obj2, visited) {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
// 检查原型是否一致,如果需要更严格的比较
// if (Object.getPrototypeOf(obj1) !== Object.getPrototypeOf(obj2)) {
// return false;
// }
for (const key of keys1) {
if (!Object.prototype.hasOwnProperty.call(obj2, key) || !deepStrictEquals(obj1[key], obj2[key], visited)) {
return false;
}
}
return true;
}
function compareDates(d1, d2) {
return d1.getTime() === d2.getTime();
}
function compareRegExps(r1, r2) {
return r1.source === r2.source && r1.flags === r2.flags;
}
function compareMaps(map1, map2, visited) {
if (map1.size !== map2.size) {
return false;
}
// 遍历 map1 的所有键值对
for (const [key1, value1] of map1.entries()) {
let found = false;
// 对于 map2,我们需要找到一个深度相等的键,并比较其值
for (const [key2, value2] of map2.entries()) {
if (deepStrictEquals(key1, key2, visited)) { // 深度比较键
if (deepStrictEquals(value1, value2, visited)) { // 深度比较值
found = true;
break;
}
}
}
if (!found) {
return false;
}
}
return true;
}
function compareSets(set1, set2, visited) {
if (set1.size !== set2.size) {
return false;
}
for (const item1 of set1.values()) {
let found = false;
for (const item2 of set2.values()) {
if (deepStrictEquals(item1, item2, visited)) {
found = true;
break;
}
}
if (!found) {
return false;
}
}
return true;
}
function compareArrayBuffersAndDataViews(a, b) {
const isAB1 = getPreciseType(a) === 'ArrayBuffer';
const isAB2 = getPreciseType(b) === 'ArrayBuffer';
const isDV1 = getPreciseType(a) === 'DataView';
const isDV2 = getPreciseType(b) === 'DataView';
if (!(isAB1 && isAB2) && !(isDV1 && isDV2)) {
return false;
}
const view1 = isAB1 ? new Uint8Array(a) : new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
const view2 = isAB2 ? new Uint8Array(b) : new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
if (view1.byteLength !== view2.byteLength) {
return false;
}
for (let i = 0; i < view1.byteLength; i++) {
if (view1[i] !== view2[i]) {
return false;
}
}
return true;
}
function compareTypedArrays(arr1, arr2) {
if (arr1.byteLength !== arr2.byteLength) {
return false;
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
}
function compareErrors(err1, err2) {
return err1.name === err2.name && err1.message === err2.message;
}
/**
* 执行两个值之间的类型安全的深度严格相等比较。
* @param {*} a - 第一个值。
* @param {*} b - 第二个值。
* @param {WeakSet} [visited] - 用于检测循环引用的Set,外部调用时无需提供。
* @returns {boolean} - 如果两个值深度严格相等则返回 true,否则返回 false。
*/
function deepStrictEquals(a, b, visited = new WeakSet()) {
// 1. 快速路径:如果引用相同,或者原始值且 === 相等
if (a === b) {
return true;
}
// 2. NaN 处理:Number.isNaN(a) 和 Number.isNaN(b) 都为 true 时,我们认为它们相等
if (Number.isNaN(a) && Number.isNaN(b)) {
return true;
}
// 3. 原始值处理:如果上面 NaN 处理没匹配,且至少一个是原始值,那么它们不相等
// 因为 === 已经处理了相同类型的原始值相等的情况,这里只剩下不同类型或 NaN 与其他值的比较
if (isPrimitive(a) || isPrimitive(b)) {
return false;
}
// 4. 类型检查:确保是相同类型的对象
const typeA = getPreciseType(a);
const typeB = getPreciseType(b);
if (typeA !== typeB) {
return false;
}
// 5. 循环引用检测:防止无限递归
// 只有当 a 和 b 都是对象时才需要检测。
// 如果 a 或 b 已经被访问过,并且它们在当前路径上,我们认为它们相等以避免死循环。
// 这是一种折衷,因为我们无法真正比较循环引用内部的“相等性”,只能避免崩溃。
// 这里的逻辑是:如果a和b都是对象,并且它们都已经在 visited 中,意味着我们已经开始比较它们
// 如果它们是同一个引用,那么在 a === b 时已经返回 true 了。
// 如果是不同的引用但都已访问,这意味着在某个递归路径上已经遇到它们,为了防止无限递归,我们返回 true。
// 这可能导致在某些复杂循环引用场景下(例如 A -> B -> A 和 C -> D -> C, 其中 A!=C)的误判,
// 但对于 A -> B -> A 和 A' -> B' -> A' 这种结构,它能正确处理。
if (visited.has(a) && visited.has(b)) {
return true;
}
visited.add(a);
visited.add(b);
// 6. 根据精确类型进行深度比较
switch (typeA) {
case 'Array':
return compareArrays(a, b, visited);
case 'Object':
return compareObjects(a, b, visited);
case 'Date':
return compareDates(a, b);
case 'RegExp':
return compareRegExps(a, b);
case 'Map':
return compareMaps(a, b, visited);
case 'Set':
return compareSets(a, b, visited);
case 'ArrayBuffer':
case 'DataView':
return compareArrayBuffersAndDataViews(a, b);
case 'Int8Array':
case 'Uint8Array':
case 'Uint8ClampedArray':
case 'Int16Array':
case 'Uint16Array':
case 'Int32Array':
case 'Uint32Array':
case 'Float32Array':
case 'Float64Array':
case 'BigInt64Array':
case 'BigUint64Array':
return compareTypedArrays(a, b);
case 'Error':
return compareErrors(a, b);
case 'Function':
// 函数只有引用相等才严格相等,否则认为不同。
// 它们的内部实现通常无法比较,toString() 也不可靠。
return false;
case 'Symbol':
// Symbol 也是引用相等,每个 Symbol 都是唯一的。
return false;
case 'Promise':
// Promise 实例无法比较其内部状态,只能引用相等。
return false;
// 其他未处理的类型,默认不相等 (因为 a === b 已经排除了引用相等的情况)
default:
return false;
}
}
3.5 测试我们的 deepStrictEquals 函数
让我们用一系列测试用例来验证我们的函数。
console.log('--- deepStrictEquals Tests ---');
// 1. 原始值比较 (严格模式,无类型转换)
console.log('1. Primitives:');
console.log('deepStrictEquals(1, 1)', deepStrictEquals(1, 1)); // true
console.log('deepStrictEquals(1, "1")', deepStrictEquals(1, "1")); // false (类型不同)
console.log('deepStrictEquals(true, 1)', deepStrictEquals(true, 1)); // false (类型不同)
console.log('deepStrictEquals(null, undefined)', deepStrictEquals(null, undefined)); // false (类型不同)
console.log('deepStrictEquals(null, null)', deepStrictEquals(null, null)); // true
console.log('deepStrictEquals(undefined, undefined)', deepStrictEquals(undefined, undefined)); // true
console.log('deepStrictEquals(NaN, NaN)', deepStrictEquals(NaN, NaN)); // true (特殊处理)
console.log('deepStrictEquals(0, -0)', deepStrictEquals(0, -0)); // true (=== 行为)
console.log('deepStrictEquals(Symbol("a"), Symbol("a"))', deepStrictEquals(Symbol("a"), Symbol("a"))); // false (Symbol是唯一的,引用不同)
const sym1 = Symbol('b');
console.log('deepStrictEquals(sym1, sym1)', deepStrictEquals(sym1, sym1)); // true (引用相同)
console.log('deepStrictEquals(123n, 123n)', deepStrictEquals(123n, 123n)); // true
console.log('deepStrictEquals(123n, 123)', deepStrictEquals(123n, 123)); // false (类型不同)
// 2. 数组比较 (深度)
console.log('n2. Arrays:');
const arr1 = [1, 2, { a: 3 }];
const arr2 = [1, 2, { a: 3 }];
const arr3 = [1, 2, { a: 4 }];
const arr4 = [1, 2, 3];
console.log('deepStrictEquals(arr1, arr2)', deepStrictEquals(arr1, arr2)); // true
console.log('deepStrictEquals(arr1, arr3)', deepStrictEquals(arr1, arr3)); // false
console.log('deepStrictEquals(arr1, arr4)', deepStrictEquals(arr1, arr4)); // false (类型不同)
console.log('deepStrictEquals([], [])', deepStrictEquals([], [])); // true
console.log('deepStrictEquals([1], [1, 2])', deepStrictEquals([1], [1, 2])); // false (长度不同)
// 3. 对象比较 (深度)
console.log('n3. Objects:');
const obj1 = { x: 1, y: { z: 2 } };
const obj2 = { x: 1, y: { z: 2 } };
const obj3 = { x: 1, y: { z: 3 } };
const obj4 = { x: 1, z: { z: 2 } };
console.log('deepStrictEquals(obj1, obj2)', deepStrictEquals(obj1, obj2)); // true
console.log('deepStrictEquals(obj1, obj3)', deepStrictEquals(obj1, obj3)); // false
console.log('deepStrictEquals(obj1, obj4)', deepStrictEquals(obj1, obj4)); // false (键不同)
console.log('deepStrictEquals({}, {})', deepStrictEquals({}, {})); // true
console.log('deepStrictEquals({a: undefined}, {})', deepStrictEquals({a: undefined}, {})); // false (键不同)
console.log('deepStrictEquals({a: 1, b: undefined}, {a:1})', deepStrictEquals({a: 1, b: undefined}, {a:1})); // false (键不同)
// 4. 混合类型对象/数组
console.log('n4. Mixed Types:');
const mixed1 = { a: 1, b: [2, { c: 'hello' }], d: new Date('2023-01-01') };
const mixed2 = { a: 1, b: [2, { c: 'hello' }], d: new Date('2023-01-01') };
const mixed3 = { a: 1, b: [2, { c: 'world' }], d: new Date('2023-01-01') };
console.log('deepStrictEquals(mixed1, mixed2)', deepStrictEquals(mixed1, mixed2)); // true
console.log('deepStrictEquals(mixed1, mixed3)', deepStrictEquals(mixed1, mixed3)); // false
// 5. 特殊对象类型
console.log('n5. Special Object Types:');
const d1 = new Date('2023-01-01T00:00:00.000Z');
const d2 = new Date('2023-01-01T00:00:00.000Z');
const d3 = new Date('2023-01-02T00:00:00.000Z');
console.log('deepStrictEquals(d1, d2)', deepStrictEquals(d1, d2)); // true
console.log('deepStrictEquals(d1, d3)', deepStrictEquals(d1, d3)); // false
console.log('deepStrictEquals(d1, "2023-01-01T00:00:00.000Z")', deepStrictEquals(d1, "2023-01-01T00:00:00.000Z")); // false (类型不同)
const r1 = /abc/gi;
const r2 = /abc/gi;
const r3 = /xyz/gi;
const r4 = /abc/i;
console.log('deepStrictEquals(r1, r2)', deepStrictEquals(r1, r2)); // true
console.log('deepStrictEquals(r1, r3)', deepStrictEquals(r1, r3)); // false
console.log('deepStrictEquals(r1, r4)', deepStrictEquals(r1, r4)); // false (flags不同)
const m1 = new Map([[1, 'a'], [{ x: 1 }, 'b']]);
const m2 = new Map([[1, 'a'], [{ x: 1 }, 'b']]);
const m3 = new Map([[1, 'a'], [{ x: 2 }, 'b']]);
console.log('deepStrictEquals(m1, m2)', deepStrictEquals(m1, m2)); // true
console.log('deepStrictEquals(m1, m3)', deepStrictEquals(m1, m3)); // false
console.log('deepStrictEquals(new Map(), new Map())', deepStrictEquals(new Map(), new Map())); // true
console.log('deepStrictEquals(new Map([[1,1]]), new Map([[1,2]]))', deepStrictEquals(new Map([[1,1]]), new Map([[1,2]]))); // false
const s1 = new Set([1, { a: 2 }]);
const s2 = new Set([1, { a: 2 }]);
const s3 = new Set([1, { a: 3 }]);
console.log('deepStrictEquals(s1, s2)', deepStrictEquals(s1, s2)); // true
console.log('deepStrictEquals(s1, s3)', deepStrictEquals(s1, s3)); // false
console.log('deepStrictEquals(new Set(), new Set())', deepStrictEquals(new Set(), new Set())); // true
const ab1 = new ArrayBuffer(8); new Uint8Array(ab1).fill(5);
const ab2 = new ArrayBuffer(8); new Uint8Array(ab2).fill(5);
const ab3 = new ArrayBuffer(8); new Uint8Array(ab3).fill(6);
console.log('deepStrictEquals(ab1, ab2)', deepStrictEquals(ab1, ab2)); // true
console.log('deepStrictEquals(ab1, ab3)', deepStrictEquals(ab1, ab3)); // false
const dv1 = new DataView(new ArrayBuffer(4)); dv1.setInt32(0, 123);
const dv2 = new DataView(new ArrayBuffer(4)); dv2.setInt32(0, 123);
const dv3 = new DataView(new ArrayBuffer(4)); dv3.setInt32(0, 456);
console.log('deepStrictEquals(dv1, dv2)', deepStrictEquals(dv1, dv2)); // true
console.log('deepStrictEquals(dv1, dv3)', deepStrictEquals(dv1, dv3)); // false
const ta1 = new Uint8Array([1,2,3]);
const ta2 = new Uint8Array([1,2,3]);
const ta3 = new Int8Array([1,2,3]); // Different type
console.log('deepStrictEquals(ta1, ta2)', deepStrictEquals(ta1, ta2)); // true
console.log('deepStrictEquals(ta1, ta3)', deepStrictEquals(ta1, ta3)); // false (类型不同)
const err1 = new Error('test'); err1.name = 'MyError';
const err2 = new Error('test'); err2.name = 'MyError';
const err3 = new Error('different'); err3.name = 'MyError';
console.log('deepStrictEquals(err1, err2)', deepStrictEquals(err1, err2)); // true
console.log('deepStrictEquals(err1, err3)', deepStrictEquals(err1, err3)); // false
// 6. 函数和 Symbol (引用比较)
console.log('n6. Functions and Symbols:');
const func1 = () => {};
const func2 = () => {};
console.log('deepStrictEquals(func1, func1)', deepStrictEquals(func1, func1)); // true
console.log('deepStrictEquals(func1, func2)', deepStrictEquals(func1, func2)); // false (不同引用)
const s_a = Symbol('test');
const s_b = Symbol('test');
console.log('deepStrictEquals(s_a, s_a)', deepStrictEquals(s_a, s_a)); // true
console.log('deepStrictEquals(s_a, s_b)', deepStrictEquals(s_a, s_b)); // false (不同引用)
// 7. 循环引用
console.log('n7. Circular References:');
const circularObj1 = {};
const circularObj2 = {};
circularObj1.a = circularObj1;
circularObj2.a = circularObj2;
console.log('deepStrictEquals(circularObj1, circularObj2)', deepStrictEquals(circularObj1, circularObj2)); // true
const circularArr1 = [];
const circularArr2 = [];
circularArr1.push(circularArr1);
circularArr2.push(circularArr2);
console.log('deepStrictEquals(circularArr1, circularArr2)', deepStrictEquals(circularArr1, circularArr2)); // true
const complexCircular1 = { a: 1 };
const complexCircular2 = { a: 1 };
complexCircular1.self = complexCircular1;
complexCircular2.self = complexCircular2;
console.log('deepStrictEquals(complexCircular1, complexCircular2)', deepStrictEquals(complexCircular1, complexCircular2)); // true
const complexCircularDiff1 = { a: 1, b: {} };
const complexCircularDiff2 = { a: 1, b: {} };
complexCircularDiff1.b.parent = complexCircularDiff1;
complexCircularDiff2.b.parent = complexCircularDiff2;
console.log('deepStrictEquals(complexCircularDiff1, complexCircularDiff2)', deepStrictEquals(complexCircularDiff1, complexCircularDiff2)); // true
// 不同的循环结构,即使内容相同,但引用路径不同,我们的函数会认为相等
// 因为visited集合只记录是否“访问过”,而非“匹配的引用路径”
const c1 = {};
const c2 = {};
const c1_a = { value: 1 };
const c2_a = { value: 1 };
c1.prop = c1_a;
c1_a.parent = c1;
c2.prop = c2_a;
c2_a.parent = c2;
console.log('deepStrictEquals(c1, c2)', deepStrictEquals(c1, c2)); // true (会正确处理)
const c3 = {};
const c4 = {};
c3.a = c3;
c4.a = c4;
c3.b = 1;
c4.b = 2;
console.log('deepStrictEquals(c3, c4)', deepStrictEquals(c3, c4)); // false (因为 b 属性不同)
// 不同类型的循环引用
const circularObj_A = {};
const circularObj_B = {};
circularObj_A.ref = circularObj_A;
circularObj_B.ref = circularObj_B;
const otherObj = {};
console.log('deepStrictEquals(circularObj_A, otherObj)', deepStrictEquals(circularObj_A, otherObj)); // false
Part 4: 使用、益处与最佳实践
我们已经成功构建了一个功能强大的 deepStrictEquals 函数。那么,在实际开发中,它有什么用途,以及我们应该如何使用它呢?
4.1 何时使用 deepStrictEquals
- 单元测试与快照测试:在测试框架(如 Jest)中,经常需要比较复杂的数据结构是否符合预期。
deepStrictEquals是expect().toEqual()背后逻辑的良好实现基础,确保测试的准确性。 - 状态管理:在React、Vue等前端框架中,我们经常需要判断组件的props或state是否发生变化,以决定是否重新渲染(例如使用
shouldComponentUpdate或React.memo)。对于包含对象的props或state,浅层比较(===)是不够的,deepStrictEquals可以提供更精确的控制。 - 数据去重与缓存:当需要确保数据集中没有重复的复杂对象时,或者在实现记忆化(memoization)功能时,
deepStrictEquals可以帮助我们判断两个数据是否“逻辑上”相同。 - 配置比较:比较两个配置对象是否一致,以决定是否需要重新加载资源或应用新的设置。
- ORM/数据库交互:比较从数据库获取的对象与内存中的对象是否一致,以判断是否需要进行更新操作。
4.2 性能考量与权衡
我们的 deepStrictEquals 函数是递归的,并且对于 Map 和 Set 采用了嵌套循环的比较方式。这意味着:
- 深度:对象的嵌套深度会直接影响递归栈的深度。过深的嵌套可能导致栈溢出(Stack Overflow)。
- 广度与元素数量:对象属性的数量、数组元素的数量、
Map/Set的尺寸会影响循环迭代的次数。对于Map和Set,如果键或值本身也是复杂对象,其性能开销会显著增加。 - CPU消耗:相比于简单的
===运算,深度比较涉及到大量的类型检查、属性遍历和递归调用,因此会消耗更多的CPU时间。
最佳实践:
- 谨慎使用:不要在性能敏感的热路径上无差别地使用深度比较。
- 优化数据结构:如果可能,尽量扁平化数据结构,减少嵌套。
- 选择性比较:在某些情况下,你可能只需要比较对象中的特定属性,而不是整个对象。
- 使用第三方库:成熟的库(如 Lodash 的
isEqual或 Ramda 的equals)通常会包含更多的优化和更全面的边缘情况处理,并且经过了严格的测试。它们可能是更好的选择,尤其是在对性能有高要求时。我们的手写实现旨在理解原理,而非直接用于所有生产环境。
4.3 与TypeScript的协同
虽然JavaScript本身是动态类型的,但我们通过 getPreciseType 这样的辅助函数,在运行时进行了严格的类型判断。这与TypeScript的静态类型检查理念不谋而合。在TypeScript项目中,我们可以为 deepStrictEquals 提供类型定义:
// deepStrictEquals.d.ts
declare function deepStrictEquals(a: any, b: any, visited?: WeakSet<any>): boolean;
// 或者更严格的
declare function deepStrictEquals<T>(a: T, b: any, visited?: WeakSet<any>): boolean;
// 但考虑到 a 和 b 的类型可能不同,any 可能是更实用的选择。
这样,在使用 deepStrictEquals 时,TypeScript 编译器可以提供更好的类型推断和错误提示,进一步增强代码的类型安全性。这个函数本身强制了运行时类型一致性,是类型安全实践的有力补充。
结语
从JavaScript宽松的 == 运算符所带来的隐患,到严格的 === 运算符提供的明确性,再到我们亲手实现的、兼顾类型安全与深度内容比较的 deepStrictEquals 函数,我们经历了一段深入探索JavaScript相等性机制的旅程。
理解这些比较算法的细微差别,对于编写可维护、可预测且无bug的JavaScript代码至关重要。通过深入的类型检查和递归比较,我们能够构建出满足复杂应用场景需求的相等性判断逻辑。希望这次讲座能帮助大家在未来的开发工作中,更加自信和熟练地处理JavaScript中的相等性问题。