For AI agents: the complete documentation index is available at /tc39-atlas/llms.txt, the full documentation bundle is available at /tc39-atlas/llms-full.txt, and this page is available as Markdown at /tc39-atlas/proposals/object-shallowequal.md.
  • 简体中文
  • Object.shallowEqual ?

    中文标题:对象浅等价

    提案概览
    提案速览

    该提案旨在添加Object.shallowEqual,通过对内部对象表示进行类似memcmp的比较来快速进行浅对象相等性检查,主要为了优化React等框架中的记忆化。但是,它仅规定了返回true的条件,并明确允许实现始终返回false。该提案已提交给TC39,但因担心锁定VM实现细节而被撤回。

    Note

    以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。

    ECMAScript 浅对象等价性测试

    TL;DR: Object.shallowEqual(a, b) 对内部对象表示进行一次 memcmp 比较,如果相等则返回 true。对象表示的确切语义未定义,因此合法的实现可以始终返回 false

    问题

    依赖不可变数据结构的编程范式使用记忆化作为纯函数的优化技术。像 React 这样的库以及其他库目前严重依赖对象比较来判断是否可以提前退出计算。记忆化的结果之后可以被复用。

    记忆化
    function eq(a, b) {
      return Object.is(a, b);
    }
    
    function memoize(fn) {
      let lastArg, lastResult;
      return function(arg) {
        if (lastArg !== undefined && eq(lastArg, arg)) {
          return lastResult;
        }
        lastArg = arg;
        lastResult = fn(arg);
        return lastResult;
      };
    }
    
    function calc(obj) {
      return obj.x + obj.y;
    }
    
    let memoizedCalc = memoize(calc);
    let obj = { x: 1, y: 2 };
    
    let res1 = memoizedCalc(obj); // 慢
    
    let res2 = memoizedCalc(obj); // 快
    
    let res3 = memoizedCalc({ x: 3, y: 4 }); // 慢

    引用相等性测试的问题在于,对象经常使用相同的嵌套数据被重新创建:

    function transform([x, y]) {
      return { x: x / 2, y: y / 2 };
    }
    
    let arr = [2, 4];
    
    let res1 = memoizedCalc(transform(arr)); // 慢
    
    let res2 = memoizedCalc(transform(arr)); // 慢

    这是 React 中非常常见的模式。

    浅等价

    为了避免这个问题,库实现了对象的浅比较,通过比较一层深度的值:

    function eq(a, b) {
      if (Object.is(a, b)) {
        return true;
      }
    
      if (typeof a !== 'object' || b === null ||
          typeof a !== 'object' || b === null) {
        return false;
      }
    
      if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) {
        return false;
      }
    
      const keysA = Object.keys(a);
      const keysB = Object.keys(b);
    
      if (keysA.length !== keysB.length) {
        return false;
      }
    
      for (let i = 0; i < keysA.length; i++) {
        if (
          !Object.prototype.hasOwnProperty.call(b, keysA[i]) ||
          !Object.is(a[keysA[i]], b[keysA[i]])
        ) {
          return false;
        }
      }
    
      return true;
    }

    如果您提前知道特定的对象签名,可以为其创建优化路径,但这些通用变体不会被虚拟机优化,并且需要大量的内省到内部隐藏类表示中以在相应的类中查找键。

    与此同时,在原生架构上,等效的操作根据架构和优化可能只需几条 CPU 指令。

    深等价

    本提案并未为深度相等性提供完整的解决方案。然而,当对象的值相等时,用户空间中的任何严格的深度相等性实现目前都会受到影响,因为没有快速退出的方法。

    这样的实现可以从叶子对象情况或深度数据结构部分被复用时的快速路径中受益。

    实现

    预期虚拟机有一个大致模拟如下内存布局的实现:

    [
      hidden class pointer,
      maybe a prototype pointer,
      expando pointer,
      numeric fields pointer,
      inline slots...
    ]

    在上述示例中,这种对象表示可以有效地使用 memcmp 进行比较:

    bool eq(JSObject *a, JSObject *b) {
      if (a->hiddenClass !== b->hiddenClass) {
        return false;
      }
      size_t instanceSize = a->hiddenClass->instanceSize;
      return !memcmp(a, b, instanceSize);
    }
    注意事项

    JavaScript 对象模型并不像 C 结构体那样简单。上述实现并不严格等同于用户空间的浅比较。具体来说,它在返回 true 的情况下更为严格。

    JavaScript 虚拟机中的字符串由指向其他字符串段或绳索的指针表示。这意味着两个语义上相等但不同的字符串指针可能由两个不同的指针表示,并且比较会返回 false

    类似地,某些浮点数范围表示为装箱值。这意味着相同语义等价数字可能由两个不同的指针表示。

    并非所有字段都是与对象相同分配中的内联槽。有时,扩展属性和数值字段是单独分配的。这意味着简单的 memcmp 不会包含这些字段。

    字段的枚举顺序及其配置通常与对象本身分开存储。两个不同的配置会有不同的指针。也可能存在两个语义等价但以不同方式构造的对象,因此它们不共享相同的配置对象。它们也会返回 false

    然而,尽管存在这些注意事项,这种启发式方法仍然有用,因为大多数时候它已经足够了。如果比较返回 false,库可以选择继续深入或直接重新执行计算。拥有一个大型对象,执行下一部分计算比执行更昂贵的比较更快,这并不罕见。

    由于这些原因,本提案不会规定实现必须返回 true 的严格要求。始终返回 false 是完全合规的。实现可以执行更昂贵的比较以便在更多情况下返回 true,但那些语义是严格定义的。

    函数对象还包含指向源代码和闭包的指针。两个具有相同源代码和相同闭包作用域的函数可能被认为是等价的,即使目前这是不可观察的。本规范允许实现在这种情况下返回 true,但并不要求这样做。

    规范

    这将规定一个名为 Object.shallowEqual 的新方法,它接受两个参数(xy)。实现可以始终从此函数返回 false。它从不要求返回 true

    如果两个参数的类型不相同,则此函数返回 false

    仅当以下条件按类型满足时,实现才可返回 true

    • Undefined: SameValue(x, y)
    • Null: SameValue(x, y)
    • Boolean: SameValue(x, y)
    • Number: SameValue(x, y)
    • String: SameValue(x, y) (建议字符串rope仅对内部指针进行浅比较,而不是深度比较。)
    • Symbol: SameValue(x, y)
    • 宿主对象(由 JS 环境提供):实现相关
    • Object: 如果每个对象的 [[Prototype]] 是相同的 Object 值,所有自有属性具有所有相同的属性名,且属性的 [[Value]], [[Get]], [[Set]], [[Writable]], [[Enumerable]][[Configurable]] 内部槽根据 SameValue 定义的语义都具有相同的值。属性的枚举顺序不必相同。
    • Function: 与 Object 相同的规则适用。此外,两个函数必须对其所有内部槽具有相同的值,如 9.2 所定义。这些目前包括:[[Environment]], [[FormalParameters]], [[FunctionKind]], [[ECMAScriptCode]], [[ConstructorKind]], [[Realm]], [[ScriptOrModule]], [[ThisMode]], [[Strict]], [[HomeObject]]。 (TODO: 如果虚拟机开始以无法安全知道这些槽之一是否实际相同的方式优化此类共享函数,那么放宽此要求以包含其他等效函数是合理的。)

    未定义行为的风险

    本提案涉及的最大风险是 Web 上的代码开始依赖特定的数据结构和比较实现。例如

    let a = { 0: 1, x: 2 };
    a.y = 3;
    let b = { 0: 1, x: 2 };
    b.y = 3;
    if (Object.shallowEqual(a, b)) {
      throw new Error();
    }

    当前虚拟机可能会将数值属性和扩展属性视为单独的数据结构。但是,如果这种情况发生变化,shallowEqual 调用可能开始返回 true,因为它们是等价的。这将破坏该代码。代码也可能依赖相反的情况。

    为了减轻这种风险,我们建议浏览器使用一种技术来最小化对未定义行为的依赖。例如,对于特定用户群体,始终返回 false,或者始终进行更深的比较。

    可能的问题

    目前尚不清楚这如何与扁平化原型链一起工作。例如,如果字段被扁平化到对象槽中,是否有可能知道原型的对象引用身份?关于此类实验性工作的实现细节需要进行更多研究。

    当这实际上是一个高级用户功能时,名称 Object.shallowEqual 可能过于突出。它可能与提供更强保证的用户空间实现混淆。

    安全考虑

    此功能暴露了一些新能力。无法区分由rope组成的字符串和非rope组成的字符串。可以判断数字是否被装箱,这可能会提供一些关于您运行架构的线索。比较函数也可能暴露其作用域是否被共享。

    这打开了一个潜在的通信渠道。然而,我们相信这不会打开任何新的可利用表面,这些表面尚未被利用。例如,通过时序攻击。

    如果有担忧,更安全的环境(如 CSP 或 SES)可以选择始终返回 false

    为什么不内置记忆化功能?

    一些语言具有将纯函数作为内置功能进行记忆化的能力。这需要规定纯函数究竟是什么,这对 JavaScript 来说是一个很大的要求。特别是考虑到没有引用透明数据结构(如丰富值类型)并且每个有用的操作都在可变原型上。

    将其留给用户空间更容易,那里的规则可以更宽松。

    这种方法另一个问题是当前框架依赖这些测试来有条件地执行副作用。因此,您必须知道比较是否失败。例如,React 对 DOM 进行变更,但_仅_当返回值发生变化时。因此,底层能力无论如何都需要暴露。

    本提案的状态

    本提案已提交给 TC39,但收到了许多担忧。主要围绕 Web 浏览器虚拟机未来更改其实现细节的能力。它们将被锁定在特定设计中。该提案已被撤回。

    在其他虚拟机环境中(如原生应用或基于 WebAssembly 的虚拟机)暴露这些钩子仍然很好。