JavaScript 宽松相等 (==) 带来的陷阱:手写实现一个严格相等(===)的类型安全比较函数

各位同仁,各位对JavaScript深感兴趣的开发者们,大家下午好!

今天,我们将深入探讨JavaScript中一个看似简单却又充满陷阱的核心概念:相等性比较。具体来说,我们将聚焦于JavaScript的两种相等运算符:宽松相等 (==) 和严格相等 (===)。我们将揭露宽松相等可能带来的陷阱,并最终手写实现一个我们自己的、兼顾类型安全与深度比较的严格相等函数。

理解JavaScript的相等性机制,是写出健壮、可预测代码的关键。在许多其他编程语言中,相等性通常是直观且一致的。然而,JavaScript的动态类型和隐式类型转换机制,使得它的相等性判断变得异常复杂,尤其是当我们面对 == 这个“双等号”运算符时。

Part 1: 解开谜团:JavaScript宽松相等 (==) 的真面目

JavaScript中的宽松相等运算符 ==,也被称为抽象相等比较算法(Abstract Equality Comparison Algorithm),它的核心特性是允许在比较前进行类型转换(type coercion)。这意味着,如果两个操作数的类型不同,JavaScript会尝试将其中一个或两个转换成一个共同的类型,然后再进行值的比较。正是这种“善意”的类型转换,常常成为代码中难以察觉的bug源头。

1.1 类型转换的规则与陷阱

宽松相等运算符的类型转换规则相当复杂,且在某些情况下会产生反直觉的结果。以下是一些主要的规则和示例:

  • 如果类型相同

    • 数值:值相等则为真 (5 == 5true)。
    • 字符串:字符序列相同则为真 ("hello" == "hello"true)。
    • 布尔值:同为 true 或同为 false 则为真 (true == truetrue)。
    • nullundefined:它们自身与自身相等 (null == nulltrue, undefined == undefinedtrue)。
    • 对象:只有当它们引用同一个对象时才相等 (obj1 == obj1true, obj1 == obj2 如果 obj1obj2 引用不同的对象,则为 false)。
    • NaNNaN 与任何值都不相等,包括它自身 (NaN == NaNfalse)。
  • 如果类型不同:这是 == 最复杂的部分。

    1. nullundefined
      null == undefinedtrue。这是JavaScript中一个非常特殊的规则,它们之间互相宽松相等,但与其他任何值(包括 0, "", false)都不宽松相等。

      console.log(null == undefined); // true
      console.log(null == 0);         // false
      console.log(undefined == "");   // false
    2. 数值与字符串
      如果一个是数值,另一个是字符串,字符串会被尝试转换为数值。

      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 不与任何值相等)
    3. 布尔值与非布尔值
      如果一个是布尔值,另一个不是,布尔值会被转换为数值 (true 转换为 1false 转换为 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)
    4. 对象与原始值
      如果一个是对象,另一个是原始值,对象会被尝试转换为原始值(通过 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()(返回 "")。所以 [] == 0true
      {} 在转换为原始值时,先尝试 valueOf()(返回自身),再尝试 toString()(返回 "[object Object]")。所以 {} 与数值的比较通常不相等,除非与 "[object Object]" 字符串比较。

    5. 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 StringNumber 10 == "10" true
String Number StringNumber "10" == 10 true
Boolean 任意类型 BooleanNumber (true -> 1, false -> 0) true == "1" true
任意类型 Boolean BooleanNumber 0 == false true
Object Primitive ObjectPrimitive (通过 valueOf()toString()) [] == 0 true
Primitive Object ObjectPrimitive 0 == [] true
NaN 任意类型 永远返回 false NaN == NaN false
Symbol 任意类型 (非 Symbol) 永远返回 false Symbol() == "foo" false

1.3 为什么 == 如此危险?

  • 不可预测性:它的行为取决于操作数的类型,而这些类型在动态语言中可能在运行时发生变化。
  • 隐藏错误:意想不到的类型转换可能掩盖真正的逻辑错误,使得调试变得困难。
  • 代码可读性差:阅读使用 == 的代码时,需要记住复杂的类型转换规则,降低了代码的可理解性。
  • 安全漏洞:在处理用户输入或外部数据时,== 的宽松性可能导致意想不到的比较结果,从而产生安全漏洞。例如," " == 0true,可能绕过某些简单的验证逻辑。

鉴于上述种种原因,业界普遍推荐尽可能避免使用 == 运算符,除非你对它的所有类型转换规则了如指掌,并且有明确的、需要利用其隐式转换的理由(这种情况非常罕见)。

Part 2: 严格与清晰:JavaScript严格相等 (===) 的原则

== 的复杂性形成鲜明对比的是,JavaScript的严格相等运算符 ===,也被称为严格相等比较算法(Strict Equality Comparison Algorithm),其规则要简单得多,也更具预测性。它的核心原则是:不进行类型转换

2.1 严格相等的核心规则

  1. 类型优先:如果两个操作数的类型不同,=== 会立即返回 false。这是它与 == 最根本的区别。
  2. 值比较:只有当两个操作数的类型完全相同时,=== 才会比较它们的值。

    • 原始值(string, number, boolean, symbol, bigint, undefined, null

      • 如果类型和值都相同,则为 true
      • NaN 特例NaN === NaN 仍然是 falseNaN 是唯一一个不与自身严格相等的值。
      • +0-0 特例+0 === -0true。在数值上,它们被认为是相等的。
      • null === undefinedfalse,因为它们的类型不同。
    • 对象(包括函数、数组等)

      • 只有当它们引用内存中的同一个对象时,才严格相等。这被称为“引用相等”。
      • 即使两个不同的对象具有完全相同的属性和值,它们也不是严格相等的。

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

这个函数的目标是:

  1. 无类型转换:严格遵循 === 的“不转换类型”原则。
  2. 深度比较:对于对象和数组,递归地比较它们的所有属性和元素。
  3. 类型安全:确保在比较过程中,只有相同类型的值才可能相等。
  4. 特殊值处理:正确处理 NaN+0/-0nullundefined
  5. 特殊对象处理:处理 DateRegExpMapSetTypedArray 等内置对象。
  6. 循环引用检测:防止在递归比较时陷入无限循环。

我们将分步骤构建这个函数。

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 对象的 sourceflags

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 算法的,这比 === 更宽松(例如 NaNNaN 相等)。如果我们的 deepStrictEquals 目标是完全模拟 === 的行为,那么我们需要考虑 Map 内部键的比较逻辑。然而,为了保持深度比较的“类型安全严格性”,我们坚持使用 deepStrictEquals 来比较键。

一个更高效但更复杂的 compareMaps 实现可能需要先将 Map 转换为可排序的数组结构(例如 [[key, value], ...]),然后比较。但对于任意类型的键,排序也可能是一个挑战。

更实际的折衷方案是:

  1. 首先检查 map1.size === map2.size
  2. 遍历 map1 的键值对 [k1, v1]
  3. 对于每个 k1,尝试使用 map2.get(k1) 获取对应值 v2
  4. 然后深度比较 v1v2
  5. 关键问题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 对象。通常比较它们的 namemessage 属性。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

  1. 单元测试与快照测试:在测试框架(如 Jest)中,经常需要比较复杂的数据结构是否符合预期。deepStrictEqualsexpect().toEqual() 背后逻辑的良好实现基础,确保测试的准确性。
  2. 状态管理:在React、Vue等前端框架中,我们经常需要判断组件的props或state是否发生变化,以决定是否重新渲染(例如使用 shouldComponentUpdateReact.memo)。对于包含对象的props或state,浅层比较(===)是不够的,deepStrictEquals 可以提供更精确的控制。
  3. 数据去重与缓存:当需要确保数据集中没有重复的复杂对象时,或者在实现记忆化(memoization)功能时,deepStrictEquals 可以帮助我们判断两个数据是否“逻辑上”相同。
  4. 配置比较:比较两个配置对象是否一致,以决定是否需要重新加载资源或应用新的设置。
  5. ORM/数据库交互:比较从数据库获取的对象与内存中的对象是否一致,以判断是否需要进行更新操作。

4.2 性能考量与权衡

我们的 deepStrictEquals 函数是递归的,并且对于 MapSet 采用了嵌套循环的比较方式。这意味着:

  • 深度:对象的嵌套深度会直接影响递归栈的深度。过深的嵌套可能导致栈溢出(Stack Overflow)。
  • 广度与元素数量:对象属性的数量、数组元素的数量、Map/Set 的尺寸会影响循环迭代的次数。对于 MapSet,如果键或值本身也是复杂对象,其性能开销会显著增加。
  • 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中的相等性问题。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注