Lodash isEqual 方法源码实现分析

Lodash isEqual 方法源码实现分析

Lodash 的 isEqual 方法用于执行两个值的深度比较,以确定它们是否相等。这个方法能够处理各种 JavaScript 数据类型,包括基本类型、对象、数组、正则表达式、日期对象等,并且能够正确处理循环引用。

1. isEqual 函数入口

isEqual 函数本身非常简洁,它直接调用了内部的 baseIsEqual 函数:

javascript 复制代码
// lodash.js L11599
function isEqual(value, other) {
  return baseIsEqual(value, other);
}

这表明核心的比较逻辑封装在 baseIsEqual 及其调用的更深层次的函数中。

2. baseIsEqual 函数

baseIsEqual 是执行比较的第一个主要关卡。它处理了一些基本情况,并将更复杂的对象和数组的比较委托给 baseIsEqualDeep。

javascript 复制代码
// lodash.js L3309
function baseIsEqual(value, other, bitmask, customizer, stack) {
  // 严格相等检查 (===),处理基本类型和相同对象的引用
  if (value === other) {
    return true;
  }
  // 处理 null 和 undefined,以及非对象类型的值(且不严格相等的情况)
  // 如果 value 或 other 为 null/undefined,或者两者都不是类对象 (object-like),
  // 则只有当它们都是 NaN 时才相等 (value !== value && other !== other 用于判断 NaN)
  if (value == null || other == null || (!isObjectLike(value) && !isObjectLike(other))) {
    return value !== value && other !== other;
  }
  // 对于对象和数组等复杂类型,调用 baseIsEqualDeep 进行深度比较
  return baseIsEqualDeep(value, other, bitmask, customizer, baseIsEqual, stack);
}

关键点:

  • 严格相等 :首先通过 === 检查两个值是否严格相等。如果它们是相同的基本类型值或指向同一个对象,则直接返回 true。
  • Null/Undefined 和非对象处理 :如果任一值为 null 或 undefined,或者两者都不是"类对象"(通过 isObjectLike 判断,通常意味着它们不是对象或函数),则只有当两个值都是 NaN 时才认为它们相等。value !== value 是判断一个值是否为 NaN 的标准方法。
  • 深度比较委托 :对于其他情况(通常是两个都是类对象的情况),它将比较任务委托给 baseIsEqualDeep 函数。bitmask、customizer 和 stack 是用于支持更高级比较特性(如部分比较、自定义比较逻辑和循环引用处理)的参数。

接下来,我们将深入分析 baseIsEqualDeep 的实现。

3. baseIsEqualDeep 函数:核心深度比较逻辑

baseIsEqualDeep 是 isEqual 实现的核心,负责处理对象和数组的深度比较,并能够处理循环引用。

javascript 复制代码
// lodash.js L3333
function baseIsEqualDeep(object, other, bitmask, customizer, equalFunc, stack) {
  var objIsArr = isArray(object),
      othIsArr = isArray(other),
      objTag = objIsArr ? arrayTag : getTag(object),
      othTag = othIsArr ? arrayTag : getTag(other);

  // 将 arguments 对象的标签视为普通对象标签
  objTag = objTag == argsTag ? objectTag : objTag;
  othTag = othTag == argsTag ? objectTag : othTag;

  var objIsObj = objTag == objectTag,
      othIsObj = othTag == objectTag,
      isSameTag = objTag == othTag;

  // 特殊处理 Buffer 类型:如果类型相同且一个是 Buffer,另一个也必须是 Buffer
  // 如果都是 Buffer,则后续按数组方式比较 (objIsArr = true, objIsObj = false)
  if (isSameTag && isBuffer(object)) {
    if (!isBuffer(other)) {
      return false;
    }
    objIsArr = true;
    objIsObj = false;
  }

  // 如果类型相同但不是普通对象 (e.g., Array, Date, RegExp, TypedArray)
  if (isSameTag && !objIsObj) {
    // 初始化用于循环引用检测的 stack
    stack || (stack = new Stack);
    // 如果是数组或 TypedArray,则调用 equalArrays 进行比较
    // 否则,调用 equalByTag 根据具体的标签类型进行比较 (e.g., RegExp, Date)
    return (objIsArr || isTypedArray(object))
      ? equalArrays(object, other, bitmask, customizer, equalFunc, stack)
      : equalByTag(object, other, objTag, bitmask, customizer, equalFunc, stack);
  }

  // 处理 Lodash 包装对象:如果不是部分比较 (partial comparison),
  // 且任一对象是 Lodash 包装对象 (具有 '__wrapped__' 属性),
  // 则解包后再进行比较。
  if (!(bitmask & COMPARE_PARTIAL_FLAG)) {
    var objIsWrapped = objIsObj && hasOwnProperty.call(object, '__wrapped__'),
        othIsWrapped = othIsObj && hasOwnProperty.call(other, '__wrapped__');

    if (objIsWrapped || othIsWrapped) {
      var objUnwrapped = objIsWrapped ? object.value() : object,
          othUnwrapped = othIsWrapped ? other.value() : other;

      stack || (stack = new Stack);
      // 使用 equalFunc (即 baseIsEqual) 递归比较解包后的值
      return equalFunc(objUnwrapped, othUnwrapped, bitmask, customizer, stack);
    }
  }

  // 如果到这里,两个值的类型标签不同,则它们不相等
  if (!isSameTag) {
    return false;
  }

  // 初始化用于循环引用检测的 stack
  stack || (stack = new Stack);
  // 对于普通对象,调用 equalObjects 进行比较
  return equalObjects(object, other, bitmask, customizer, equalFunc, stack);
}

关键点:

  • 类型识别 :
    • 使用 isArray 判断是否为数组。
    • 使用 getTag 获取对象的内部 [[ToString]] 标签 (如 [object Array], [object Object], [object Date], [object RegExp]) 来判断具体类型。
    • arguments 对象被特殊处理,其标签被视为 [object Object]。
  • Buffer 处理 :如果两个值类型相同且其中一个是 Buffer,那么另一个也必须是 Buffer 才能继续比较。之后,Buffer 会被当作数组(objIsArr = true)进行元素比较。
  • 分支委托 :
    • 非普通对象且类型相同 (isSameTag && !objIsObj):
      • 如果是数组 (objIsArr) 或类型化数组 (isTypedArray(object)),则调用 equalArrays 进行比较。
      • 否则(如 Date, RegExp, Map, Set 等),调用 equalByTag 进行特定类型的比较。
    • Lodash 包装对象 :如果对象是 Lodash 的包装对象 (e.g., _([1, 2])) 并且当前不是部分比较模式,会先获取其原始值 (.value()),然后再进行递归比较。
    • 类型不同 (!isSameTag):如果此时发现两个值的类型标签不同,直接返回 false。
    • 普通对象 :如果以上条件都不满足,并且类型标签相同(通常意味着它们都是普通对象 [object Object]),则调用 equalObjects 进行对象属性的递归比较。
  • 循环引用处理 (Stack) :在进行数组或对象的递归比较之前,会确保 stack 对象已初始化 (stack || (stack = new Stack))。这个 Stack 对象用于跟踪已经比较过的对象对,以防止因循环引用导致的无限递归。equalArrays, equalByTag, 和 equalObjects 内部会使用这个 stack。
  • bitmask 和 customizer :这些参数会一路传递下去,供 equalArrays, equalByTag, equalObjects 以及自定义比较函数使用,以支持部分比较、无序比较和用户自定义的比较逻辑。
  • equalFunc :这个参数通常是 baseIsEqual 自身,用于在需要递归调用时返回到顶层的比较逻辑。

接下来,我们将分析 equalArrays, equalByTag, 和 equalObjects 这三个核心的比较辅助函数,以及 Stack 是如何工作的。

3.1. equalArrays 函数:比较数组和类数组对象

equalArrays 负责比较两个数组(或类数组对象,如 arguments 对象、TypedArray、Buffer)的内容是否相等。

javascript 复制代码
// lodash.js L5675
function equalArrays(array, other, bitmask, customizer, equalFunc, stack) {
  var isPartial = bitmask & COMPARE_PARTIAL_FLAG, // 是否为部分比较
      arrLength = array.length,
      othLength = other.length;

  // 长度检查:如果长度不同,并且不是"部分比较且 other 长度大于 array 长度"的情况,则不等
  if (arrLength != othLength && !(isPartial && othLength > arrLength)) {
    return false;
  }

  // 循环引用检查:从 stack 中获取之前存储的 array 和 other
  // 如果两者都已存在于 stack 中,说明遇到了循环引用。
  // 此时,当且仅当 array 在 stack 中对应 other,并且 other 在 stack 中对应 array 时,才认为它们(在循环的这一点上)是相等的。
  var arrStacked = stack.get(array);
  var othStacked = stack.get(other);
  if (arrStacked && othStacked) {
    return arrStacked == other && othStacked == array;
  }

  var index = -1,
      result = true,
      // 如果是无序比较 (COMPARE_UNORDERED_FLAG),则创建一个 SetCache 用于跟踪 other 数组中已匹配的元素
      seen = (bitmask & COMPARE_UNORDERED_FLAG) ? new SetCache : undefined;

  // 将当前比较的 array 和 other 存入 stack,用于后续的循环引用检测
  stack.set(array, other);
  stack.set(other, array);

  // 遍历 array 的元素 (忽略非索引属性)
  while (++index < arrLength) {
    var arrValue = array[index],
        othValue = other[index]; // 在有序比较时,直接取 other 对应索引的值

    if (customizer) {
      // 如果提供了 customizer 函数,则调用它进行比较
      // 注意 partial 模式下 customizer 的参数顺序
      var compared = isPartial
        ? customizer(othValue, arrValue, index, other, array, stack)
        : customizer(arrValue, othValue, index, array, other, stack);
    }

    if (compared !== undefined) {
      // 如果 customizer 返回了明确的结果 (非 undefined)
      if (compared) {
        continue; // customizer认为相等,继续下一个元素
      }
      result = false; // customizer认为不等,数组不等,跳出循环
      break;
    }

    // 如果没有 customizer 或 customizer 返回 undefined,则进行标准比较
    if (seen) {
      // 无序比较 (COMPARE_UNORDERED_FLAG is set):
      // 尝试在 `other` 数组中找到一个与 `arrValue` 相等的元素,
      // 并且这个元素在 `other` 中的索引尚未被 `seen` (SetCache) 记录过。
      // `arraySome` 会遍历 `other` 数组。
      if (!arraySome(other, function(othElementValue, othElementIndex) {
            // 检查 othElementIndex 是否已在 seen 中,以及 arrValue 是否与 othElementValue 相等
            // 相等判断会优先使用 ===,然后递归调用 equalFunc (即 baseIsEqual)
            if (!cacheHas(seen, othElementIndex) &&
                (arrValue === othElementValue || equalFunc(arrValue, othElementValue, bitmask, customizer, stack))) {
              // 如果找到匹配且未被记录的元素,将其索引加入 seen 并返回 true (表示 arraySome 应该停止并返回 true)
              return seen.push(othElementIndex);
            }
          })) {
        // 如果 arraySome 返回 false (即在 other 中找不到匹配 arrValue 的元素),则数组不等
        result = false;
        break;
      }
    } else if (!(
          // 有序比较 (默认情况):
          // 比较当前索引的 arrValue 和 othValue 是否相等
          // 优先使用 ===,然后递归调用 equalFunc (即 baseIsEqual)
          arrValue === othValue ||
            equalFunc(arrValue, othValue, bitmask, customizer, stack)
        )) {
      result = false; // 如果当前元素不等,则数组不等,跳出循环
      break;
    }
  }

  // 清理 stack 中为当前 array 和 other 设置的记录
  stack['delete'](array);
  stack['delete'](other);

  return result;
}

关键点:

  • 长度检查 :首先比较数组长度。只有在部分比较模式 (isPartial) 且 other 数组长度大于 array 数组长度时,不同长度才可能被接受。否则,长度不同直接返回 false。
  • 循环引用处理 :
    • 在比较元素之前,通过 stack.get(array) 和 stack.get(other) 检查这两个数组是否已经作为一对出现在比较栈中。如果是,说明遇到了循环引用。此时,只有当它们在栈中互相指向对方时,才认为这对循环引用是相等的(例如 a = []; b = []; a.push(b); b.push(a); isEqual(a,b))。
    • 在开始元素比较前,将 array 和 other 互相注册到 stack 中:stack.set(array, other) 和 stack.set(other, array)。
    • 比较完成后,从 stack 中删除这对记录:stack['delete'](array) 和 stack['delete'](other)。
  • 元素比较 :
    • Customizer 优先 :如果提供了 customizer 函数,则首先使用它来比较元素。如果 customizer 返回一个布尔值,则该值决定了当前元素的比较结果。
    • 有序比较 (默认) :如果没有 customizer 或 customizer 返回 undefined,并且不是无序比较模式,则按索引逐个比较元素。比较时先用 ===,如果不等,则递归调用 equalFunc (即 baseIsEqual) 进行深度比较。
    • 无序比较 (COMPARE_UNORDERED_FLAG) :如果设置了无序比较标志,对于 array 中的每个元素 arrValue,它会尝试在 other 数组中找到一个与之相等的元素。这里使用了 SetCache (seen) 来确保 other 数组中的每个元素只被匹配一次。如果 array 中的任何元素在 other 中找不到未被匹配过的相等元素,则认为数组不等。
  • bitmask 的作用 :
    • COMPARE_PARTIAL_FLAG:影响长度检查和 customizer 的调用方式。
    • COMPARE_UNORDERED_FLAG:切换到无序比较逻辑。

接下来分析 equalByTag,它用于处理除了普通对象和数组之外的其他特定类型的对象比较。

3.2. equalByTag 函数:比较特定类型的对象

equalByTag 用于处理 baseIsEqualDeep 中那些类型相同但非普通对象(也不是数组或 TypedArray)的情况。它根据对象的 [[ToString]] 标签(tag 参数)来执行特定于类型的比较。

javascript 复制代码
// lodash.js L5754
function equalByTag(object, other, tag, bitmask, customizer, equalFunc, stack) {
  switch (tag) {
    case dataViewTag: // For DataView
      // 比较 byteLength 和 byteOffset
      if ((object.byteLength != other.byteLength) ||
          (object.byteOffset != other.byteOffset)) {
        return false;
      }
      // 如果上述相同,则将其内部的 ArrayBuffer 提取出来进行比较
      object = object.buffer;
      other = other.buffer;
      // 注意:这里没有 break,会继续执行 arrayBufferTag 的逻辑

    case arrayBufferTag: // For ArrayBuffer (and DataView's buffer)
      // 比较 byteLength
      // 然后将 ArrayBuffer 转换为 Uint8Array 再进行比较 (使用 equalFunc,通常是 baseIsEqual,最终会调用 equalArrays)
      if ((object.byteLength != other.byteLength) ||
          !equalFunc(new Uint8Array(object), new Uint8Array(other))) {
        return false;
      }
      return true;

    case boolTag:     // For Boolean objects (e.g., new Boolean(true))
    case dateTag:     // For Date objects
    case numberTag:   // For Number objects (e.g., new Number(1))
      // 将布尔值转换为 1 或 0,日期对象转换为毫秒数时间戳。
      // 无效日期会被转换为 NaN。
      // 使用宽松相等 `==` 来比较转换后的原始值 (e.g., +new Date() == +new Date())
      // `eq` 内部也是 `val1 === val2 || (val1 !== val1 && val2 !== val2)`,所以能正确处理 NaN
      return eq(+object, +other);

    case errorTag:    // For Error objects
      // 比较 error 的 name 和 message 属性
      return object.name == other.name && object.message == other.message;

    case regexpTag:   // For RegExp objects
    case stringTag:   // For String objects (e.g., new String('foo'))
      // 将正则表达式和字符串对象都转换为字符串原始值进行比较。
      // `other + ''` 是一种将 `other` 强制转换为字符串的方式。
      return object == (other + '');

    case mapTag:      // For Map objects
      var convert = mapToArray; // mapToArray 将 Map 的键值对转换为 [key, value] 数组
      // 注意:这里没有 break,会继续执行 setTag 的逻辑

    case setTag:      // For Set objects
      var isPartial = bitmask & COMPARE_PARTIAL_FLAG;
      convert || (convert = setToArray); // setToArray 将 Set 的值转换为数组

      // 比较大小 (size),除非是部分比较
      if (object.size != other.size && !isPartial) {
        return false;
      }
      // 处理循环引用:检查 object 是否已在 stack 中
      var stacked = stack.get(object);
      if (stacked) {
        // 如果在 stack 中,只有当它指向 other 时才认为相等
        return stacked == other;
      }
      // 对于 Map 和 Set,比较时总是认为是无序的 (COMPARE_UNORDERED_FLAG)
      bitmask |= COMPARE_UNORDERED_FLAG;

      // 将 Map/Set 转换为数组,然后使用 equalArrays 进行比较
      stack.set(object, other); // 存入 stack 以处理内部可能的循环引用
      var result = equalArrays(convert(object), convert(other), bitmask, customizer, equalFunc, stack);
      stack['delete'](object); // 从 stack 中移除
      return result;

    case symbolTag:   // For Symbol objects
      // 如果支持 Symbol.prototype.valueOf (symbolValueOf),则比较它们的原始 Symbol 值
      if (symbolValueOf) {
        return symbolValueOf.call(object) == symbolValueOf.call(other);
      }
      // 如果不支持 (极旧环境),则无法可靠比较,返回 false
  }
  return false; // 对于未处理的标签类型,返回 false
}

关键点:

  • Switch based on Tag :函数的核心是一个 switch 语句,根据传入的 tag(对象的 [[ToString]] 标签)来执行不同的比较逻辑。
  • DataView 和 ArrayBuffer :
    • DataView:首先比较 byteLength 和 byteOffset。如果相同,则取出其底层的 ArrayBuffer,然后逻辑会 fall-through 到 arrayBufferTag 的处理。
    • ArrayBuffer:比较 byteLength。如果相同,则将两个 ArrayBuffer 都包装成 Uint8Array,然后递归调用 equalFunc (即 baseIsEqual,最终会走到 equalArrays) 进行字节级别的比较。
  • Boolean, Date, Number Objects :这些包装对象通过一元加号 + 被转换为它们的原始值(数字或 NaN),然后使用 eq 函数进行比较。eq(a, b) 等价于 a === b || (a !== a && b !== b),可以正确处理 NaN。
  • Error Objects :比较 name 和 message 属性是否相等。
  • RegExp 和 String Objects :将它们都转换为字符串原始值,然后使用 == 进行比较。
  • Map 和 Set :
    • 首先检查 size 是否相等(除非是部分比较)。
    • 处理循环引用:通过 stack.get(object) 检查当前 Map 或 Set 是否已在比较栈中。如果是,则只有当栈中记录的对应值是 other 时才认为它们相等。
    • 将 Map 或 Set 的内容转换为数组(Map 转为 [[key, value], ...] 数组,Set 转为 [value, ...] 数组)。
    • 在转换后的数组上调用 equalArrays 进行比较。此时,bitmask 会强制加入 COMPARE_UNORDERED_FLAG,因为 Map 和 Set 的元素顺序通常不重要(对于 isEqual 而言,Lodash 将它们视为无序集合进行比较)。
    • 在递归调用 equalArrays 前后,会通过 stack.set 和 stack.delete 管理当前 Map/Set 的循环引用跟踪。
  • Symbol Objects :如果环境支持 Symbol.prototype.valueOf,则调用它获取原始 Symbol 值进行比较。否则,认为它们不等。
  • Fall-through :dataViewTag 的处理会自然地落到 arrayBufferTag。mapTag 的处理会自然地落到 setTag 的共享逻辑部分(主要是循环引用检查和转换为数组后调用 equalArrays 的部分)。

接下来分析 equalObjects,它用于比较普通对象的属性。

3.3. equalObjects 函数:比较普通对象

equalObjects 负责比较两个普通对象(plain objects or objects with [[ToString]] tag of [object Object])的属性是否相等。

javascript 复制代码
// lodash.js L5832
function equalObjects(object, other, bitmask, customizer, equalFunc, stack) {
  var isPartial = bitmask & COMPARE_PARTIAL_FLAG, // 是否为部分比较
      objProps = getAllKeys(object), // 获取 object 自身的可枚举属性名和 Symbol (包括原型链上的吗?getAllKeys 通常是自身的)
      objLength = objProps.length,
      othProps = getAllKeys(other),  // 获取 other 自身的可枚举属性名和 Symbol
      othLength = othProps.length;

  // 属性数量检查:如果属性数量不同,并且不是部分比较模式,则不等
  if (objLength != othLength && !isPartial) {
    return false;
  }

  var index = objLength;
  // 检查 object 的每个属性是否存在于 other 中
  // 在部分比较模式下,只需检查 object 的属性是否在 other 中 (key in other)
  // 在完全比较模式下,检查 other 是否具有 object 的自身属性 (hasOwnProperty)
  while (index--) {
    var key = objProps[index];
    if (!(isPartial ? key in other : hasOwnProperty.call(other, key))) {
      return false;
    }
  }

  // 循环引用检查:与 equalArrays 中的逻辑类似
  var objStacked = stack.get(object);
  var othStacked = stack.get(other);
  if (objStacked && othStacked) {
    return objStacked == other && othStacked == object;
  }

  var result = true;
  // 将当前比较的 object 和 other 存入 stack
  stack.set(object, other);
  stack.set(other, object);

  var skipCtor = isPartial; // 在部分比较模式下,跳过构造函数检查
  index = -1; // 重置 index 用于遍历 objProps

  // 遍历 object 的所有属性进行比较
  while (++index < objLength) {
    key = objProps[index];
    var objValue = object[key],
        othValue = other[key];

    if (customizer) {
      // 如果提供了 customizer,则调用它
      var compared = isPartial
        ? customizer(othValue, objValue, key, other, object, stack)
        : customizer(objValue, othValue, key, object, other, stack);
    }
    // 如果 customizer 未返回明确结果,或者没有 customizer
    // 则递归比较属性值:先用 ===,然后用 equalFunc (baseIsEqual)
    if (!(compared === undefined
          ? (objValue === othValue || equalFunc(objValue, othValue, bitmask, customizer, stack))
          : compared
        )) {
      result = false; // 如果任何属性值不等,则对象不等
      break;
    }
    // 如果不是部分比较,并且当前键是 'constructor',则标记 skipCtor 为 true
    // 这是为了后续的构造函数检查,如果用户明确比较了 'constructor' 属性,则跳过默认的构造函数检查
    skipCtor || (skipCtor = key == 'constructor');
  }

  if (result && !skipCtor) {
    // 如果所有属性都相等,并且没有跳过构造函数检查 (即 'constructor' 属性未被用户自定义比较)
    var objCtor = object.constructor,
        othCtor = other.constructor;

    // 额外的构造函数检查:
    // 如果两个对象的构造函数不同,并且它们都不是 Object 的直接实例 (通过检查 'constructor' in object/other 判断,
    // 并且构造函数本身不是 Function 的实例,这部分逻辑有点复杂,主要是为了排除 Object.create(null) 或字面量对象的情况),
    // 则认为对象不等。这个检查主要针对自定义类的实例。
    if (objCtor != othCtor &&
        ('constructor' in object && 'constructor' in other) &&
        !(typeof objCtor == 'function' && objCtor instanceof objCtor &&
          typeof othCtor == 'function' && othCtor instanceof othCtor)) {
      result = false;
    }
  }

  // 清理 stack
  stack['delete'](object);
  stack['delete'](other);

  return result;
}

getAllKeys 函数 (L5915) 通常会返回对象自身的可枚举属性名和 Symbol 属性。

关键点:

  • 属性数量和存在性检查 :
    • 首先,如果不是部分比较 (isPartial),会检查两个对象的属性数量是否相同。如果不同,直接返回 false。
    • 然后,遍历 object 的所有属性,检查这些属性是否存在于 other 对象中。在完全比较模式下,使用 hasOwnProperty 确保是 other 自身的属性;在部分比较模式下,仅使用 in 操作符(允许原型链上的属性)。如果 object 的任何属性在 other 中不存在(根据模式),则返回 false。
  • 循环引用处理 :与 equalArrays 中的机制相同,使用 stack 来检测和处理循环引用。
  • 属性值比较 :
    • 遍历 object 的所有属性。
    • Customizer 优先 :如果提供了 customizer,则用它比较属性值。
    • 递归比较 :如果没有 customizer 或它返回 undefined,则先用 === 比较属性值。如果它们不严格相等,则递归调用 equalFunc (即 baseIsEqual) 对属性值进行深度比较。
  • 构造函数检查 :
    • 在所有属性值都相等后(result 仍为 true),并且没有因为用户自定义比较 constructor 属性而跳过此检查 (!skipCtor),会进行一个额外的构造函数检查。
    • 如果两个对象的 constructor 属性不同,并且这两个对象都有 constructor 属性,且它们的构造函数不是简单的 Object (通过一个略显复杂的 instanceof 检查来判断,主要是为了确保它们是自定义类的实例),那么这两个对象被认为是不等的。这个检查的目的是确保由不同类创建的实例,即使属性相同,也被视为不等。
  • getAllKeys:这个辅助函数用于获取对象的所有自身属性键(包括字符串键和 Symbol 键)。

4. 循环引用处理:Stack 数据结构

Lodash 使用一个名为 Stack 的内部数据结构来跟踪在深度比较过程中遇到的对象对,以防止因循环引用导致的无限递归。

javascript 复制代码
// lodash.js L2322 (Stack constructor and methods)
function Stack(entries) {
  var data = this.__data__ = new ListCache(entries); // 内部使用 ListCache
  this.size = data.size;
}

function stackClear() { /* ... */ }
function stackDelete(key) { /* ... */ }
function stackGet(key) { /* ... */ }
function stackHas(key) { /* ... */ }
function stackSet(key, value) { /* ... */ }

Stack.prototype.clear = stackClear;
Stack.prototype['delete'] = stackDelete;
Stack.prototype.get = stackGet;
Stack.prototype.has = stackHas;
Stack.prototype.set = stackSet;

Stack 内部主要依赖 ListCache (L2035) 和 MapCache (L2167)。

  • ListCache:一个简单的缓存,用于存储少量键值对。它内部使用一个数组,并通过线性搜索进行查找、添加和删除。适用于缓存大小较小的情况。
  • MapCache:当缓存的条目数量超过一个阈值 (LARGE_ARRAY_SIZE,默认为 200) 时,ListCache 可能会被转换为 MapCache(如果可用)。MapCache 使用 JavaScript 内置的 Map 对象(如果可用)或一个哈希表实现(Hash L1937, assocIndexOf L1870)来提供更高效的查找,适用于存储大量键值对。

工作机制:

  1. 当 baseIsEqualDeep 开始比较两个对象(或数组)objA 和 objB 时,它会(如果 stack 不存在则创建)一个 Stack 实例。
  2. 在 equalArrays 或 equalObjects (以及 equalByTag 中处理 Map/Set 的部分) 中:
    • 检查是否存在 :首先调用 stack.get(objA) 和 stack.get(objB)。如果 stack.get(objA) 返回 objB 并且 stack.get(objB) 返回 objA,这意味着 objA 和 objB 已经作为一对被比较过并且形成了循环。在这种情况下,它们被认为是相等的(因为它们在循环点上互相引用)。如果 stack.get(objA) 返回了某个值但不是 objB(或者反过来),这通常意味着一个对象在不同的比较路径中与不同的对象配对,这可能表示不等(具体取决于实现细节,但 Lodash 的逻辑是如果它们在栈中互相指向,则视为相等)。
    • 存入栈中 :在递归比较其内部元素或属性之前,会调用 stack.set(objA, objB) 和 stack.set(objB, objA)。这标记了 objA 和 objB 正在被比较。
    • 移出栈中 :在对 objA 和 objB 的比较完成后(无论结果是相等还是不等),会调用 stack.delete(objA) 和 stack.delete(objB) 将它们从栈中移除。这允许这些对象在其他非循环的比较路径中被重新比较。

这种机制确保了如果比较过程中再次遇到已经处于当前比较路径上的同一对对象,比较会终止并返回 true(假设它们互相引用),从而避免了无限循环。

5. SetCache 数据结构 (用于无序数组比较)

在 equalArrays 中,当进行无序比较时 (bitmask & COMPARE_UNORDERED_FLAG),会使用 SetCache 来跟踪 other 数组中哪些元素已经被匹配过。

javascript 复制代码
// lodash.js L2271 (SetCache constructor and methods)
function SetCache(values) {
  var index = -1,
      length = values == null ? 0 : values.length;
  this.__data__ = new MapCache; // 内部使用 MapCache
  while (++index < length) {
    this.add(values[index]);
  }
}

function setCacheAdd(value) {
  this.__data__.set(value, HASH_UNDEFINED); // 值为一个特殊的 HASH_UNDEFINED 标记
  return this;
}

function setCacheHas(value) {
  return this.__data__.has(value);
}

SetCache.prototype.add = SetCache.prototype.push = setCacheAdd;
SetCache.prototype.has = setCacheHas;
  • SetCache 内部使用 MapCache 来存储值。它只关心键(即数组中的元素),值本身并不重要,所以使用了一个常量 HASH_UNDEFINED 作为所有键的值。
  • add(value) (或 push(value)): 将值添加到缓存中。
  • has(value): 检查值是否存在于缓存中。

在 equalArrays 的无序比较逻辑中,当 array 中的一个元素 arrValue 与 other 数组中的一个元素 othElementValue 匹配成功后,othElementIndex (或 othElementValue,取决于具体实现,Lodash v4 中是 othIndex) 会被添加到 seen (一个 SetCache 实例) 中。这确保了 other 数组中的同一个元素不会被用来匹配 array 中的多个元素。

6. bitmask 和 customizer 参数

这两个参数贯穿了 isEqual, baseIsEqual, baseIsEqualDeep, equalArrays, equalByTag, 和 equalObjects 的调用链,用于提供更灵活的比较行为。

  • bitmask :一个数字,其位用于表示不同的比较标志。
    • COMPARE_PARTIAL_FLAG (值为 1):启用"部分比较"模式。在此模式下:
      • 对于对象:isEqual({ 'a': 1 }, { 'a': 1, 'b': 2 }) 在部分比较下可能为 true(如果 object 是第一个参数),因为它只检查第一个对象的属性是否存在于第二个对象中且值相等。
      • 对于数组:长度比较会更宽松,customizer 的参数顺序可能会调整。
      • isMatch 函数内部会使用这个标志。
    • COMPARE_UNORDERED_FLAG (值为 2):启用"无序比较"模式,主要用于数组。在此模式下,数组元素的顺序不重要,只要所有元素都存在于另一个数组中(考虑数量)。Map 和 Set 的比较内部也会强制使用此标志。
  • customizer :一个可选的回调函数,用户可以提供它来自定义特定值对的比较逻辑。
    • customizer 函数被调用时会接收参数如 (objValue, othValue, keyOrIndex, object, other, stack)。
    • 如果 customizer 返回 true,则认为这对值相等。
    • 如果 customizer 返回 false,则认为这对值不等。
    • 如果 customizer 返回 undefined,则 isEqual 会回退到其默认的比较逻辑来处理这对值。
    • isEqualWith 函数就是 isEqual 的一个版本,它明确接受一个 customizer 参数。

7. 总结:isEqual 实现策略

Lodash 的 isEqual 方法采用了一个分层、递归的策略来实现深度比较:

  1. 入口与基本情况 (isEqual -> baseIsEqual) :
    • 快速路径:通过 === 检查严格相等。
    • 处理 null, undefined, 和非对象类型(包括 NaN)。
  2. 核心深度比较 (baseIsEqualDeep) :
    • 类型检测 :使用 isArray, getTag, isBuffer, isTypedArray 等确定值的具体类型。
    • 分支委托 :
      • 数组/TypedArray -> equalArrays
      • Buffer -> 特殊处理后转 equalArrays
      • Date, RegExp, Boolean, Number, String, Error, Symbol 对象 -> equalByTag (进行特定于类型的原始值或属性比较)
      • Map, Set -> equalByTag (转换为数组后,使用 equalArrays 进行无序比较)
      • 普通对象 -> equalObjects
      • Lodash 包装对象 -> 解包后递归调用 baseIsEqual。
  3. 递归比较与循环处理 :
    • equalArrays 和 equalObjects (以及 equalByTag 中的 Map/Set 逻辑) 会递归调用 baseIsEqual (通过 equalFunc 参数) 来比较嵌套的元素或属性值。
    • Stack 数据结构用于在递归过程中跟踪已比较的对象对,以正确处理循环引用并防止无限递归。
  4. 特定集合类型的处理 :
    • 数组 (equalArrays) :支持有序比较(默认)和无序比较(通过 COMPARE_UNORDERED_FLAG 和 SetCache)。
    • 对象 (equalObjects):比较对象的属性数量和每个属性的值。包含一个特殊的构造函数检查。
  5. 灵活性 :
    • 通过 bitmask 支持部分比较和无序比较等模式。
    • 通过 customizer 函数允许用户提供自定义的比较逻辑。

这种设计使得 isEqual 非常健壮,能够准确处理 JavaScript 中广泛的数据类型和复杂结构,同时通过 Stack 机制有效地解决了循环引用的问题。

相关推荐
学逆向的1 小时前
C 突破编译器限制
c语言·开发语言·网络安全
jimy11 小时前
C++ 虚函数表:基类和派生类共用一张 vtable 吗?
开发语言·c++
爱吃提升2 小时前
Dev-C++ 5.11 C++断点调试完整教程
开发语言·c++
夜晚回家2 小时前
Python 原始字符串详解 | r 前缀到底让什么不再转义
开发语言·python
陈年老古董2 小时前
PyQt5 入门指南:环境搭建、PyCharm 外部工具配置与登录界面实战
开发语言·qt·pycharm
看浪的路人2 小时前
第7讲:大模型后端服务化——Go 实现高并发 LLM Proxy / Router / Fallback
开发语言·后端·golang
iCxhust2 小时前
Matlab求解发电机的极限切除角
开发语言·算法·matlab·电力系统
zhanghaha13142 小时前
Qt 类完整例程【逐词拆解 + 逐步骤执行保姆教程】
开发语言·qt
code2cat3 小时前
Java进阶篇之AtomicReferenceFieldUpdater:用不可变快照替换整份状态
java·开发语言·cas
Nathan__273 小时前
PHP面试题——情景应用
开发语言·php