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/stage/1/proposal-comparisons.md.
  • 简体中文
  • Comparisons S1

    中文标题:比较

    提案概览
    提案速览

    该提案引入了一个标准化的深度比较 API,以解决缺乏内置支持来确定值(尤其是对象)之间相等性和差异的问题。它提供了一个 compare 函数,可以返回布尔值(快速模式)或偏差迭代器(完整模式),并具有可配置选项,用于处理构造函数、原型和描述符等边缘情况。该提案是基础性的,旨在为未来的兄弟提案(如 Inspector 和 Modes)提供支持。

    Note

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

    提案:比较

    champions:

    作者:

    阶段

    当前:1

    问题

    确定 A 和 B 是否以及如何彼此偏离——这是一种非常常见的需求,但目前仅针对非常狭窄的情况(原始值,以及在某种程度上可被 JSON.stringify 的数据结构)得到了解决。这个问题有两个部分:(深度)相等性和细节。

    动机

    促进关于深度相等性的决策,而用户通常对此并不了解。

    遍历对象是困难且无趣的;确定相等性可能很困难,需要大量的专业知识,而绝大多数用户并不具备。这些复杂性给用户带来了重大障碍和风险。

    相等性(当前)

    原始值大多是简单的:Object.is 提供了最严格的比较(SameValue),而 ===(IsStrictlyEqual)仅略宽松,无法区分符号相反的零,也无法将 NaN 视为相等。

     'foo' === 'bar'
        1  ===  2
      true === false

    但对于对象来说,“相似”的含义并不直接。人类认为这些是“相等的”(但语言并不这么认为):

    const a = { a: 1 };
    const b = { a: 1 };
    const a = [1];
    const b = [1];
    const a = new String('foo');
    const b = new String('foo');

    关于比较对象的细微差别,生态系统中存在一些差异。

    更重要的是细节:仅仅知道 A 和 B 不同,而不具体知道它们_如何_不同,几乎是无用的。

    令人烦恼:

    if (A !== B) throw new Error('A does not equal B');
    // Error: A does not equal B

    更好

    if (A !== B) throw new Error(`${A} does not equal ${B}`);
    // Error: 1 does not equal 2

    但脆弱

    if (A !== B) throw new Error(`${A} does not equal ${B}`);
    // Error: [object Object] does not equal [object Object]

    用例

    生产:HTTP PATCH 的增量

    许多客户端应用操作数据,有时是很大的数据。这可能是通过 <form>、文本编辑器或其他方式。由于前后已知,只需要增量(通过 http patch 发送)。

    <Form onSubmit={submitPatch}>
    
    function submitPatch(prev, next) {
      const patch = composeDelta(prev, next);
    
      fetch(…, {
        body: JSON.stringify(patch),
        method: 'PATCH',
      });
    }

    生产:日志记录

    log('坏数据', compare(initiallyGood, nowBad));

    生产:状态管理

    在诸如 React 等框架中,状态通常基于派生数据,其更新不一定包含实质性变化:

    setState((prev) => ({
      ...prev,
      x: x / 2,
    }));

    目前这留给用户来防御,因为对于库来说检查太困难且昂贵。

    React 以前尝试过这个(参见 先前工作 → 浅相等)。

    生产:验证

    来自不受控制来源的输入:

    try {
      assert.is(
        total += value,
        NaN,
      );
    } catch (err) {
      toast(…);
    }

    生产:虚拟 DOM

    {items.map(({ id, label }) => (
      <button onClick={() => remove(id)}>
        {label}
      </button>
    ))}

    测试

    assert.equal(
      { foo: 1         },
      { foo: 1, bar: 2 },
    );

    明确不在范围内

    • 这不是测试运行器(describeit 等)。
    • 这不是测试工具套件(mockstub 等)。

    解决方案(草图)

    比较

    一个深度比较值的函数。

    function compare(
      expected: any,
      actual: any,
      options: CompareOptions,
    ): (true | Iterator<Deviation>) | undefined;

    CompareOptions

    type CompareOptions = {
      mode?:
        | 'fast' // (默认) 返回 => boolean
        | 'full' // 返回 => Iterator<Deviation>
      ,
      reasons?: Partial<{
        constructor: boolean,     // 默认: `false`
        descriptors: boolean,     // 默认: `false`
        promise: 'ref' | 'value', // 默认: `'value'`
        prototype: boolean,       // 默认: `false`
        weak: 'ref' | 'value',    // 默认: `'value'`
      }>,
    };
    mode
    比较如何报告结果
    mode fast
    当存在偏差时返回 true,当不存在偏差时返回 undefined
    mode full
    返回一个包含所有偏差的 DeviationsIterator,当不存在偏差时返回 undefined
    reasons
    确定差异时是否以及如何处理更特殊的情况。
    reasons.constructor false | true
    是否考虑构造函数。这影响,除其他外,Box 原始值(new Boolean(true)true)和 TypedArraysnew Int8Array([1,2])new Uint8Array([1,2])),其中的差异是吹毛求疵的。
    reasons.descriptors false | true
    是否考虑属性描述符(configurable、getter 与 value、writeable)。
    reasons.promise 'ref' | 'value'
    如何确定 promise 的相等性。
    reasons.prototype false | true
    是否考虑原型。
    reasons.weak 'ref' | 'value'
    如何确定 Weak 对象(WeakMapWeakRefWeakSet)的相等性。

    偏差

    type Deviations = Iterator<
      (string | Symbol)[], // ['foo', '1', Symbol('zed')]
      {
        actual:
          | bigint
          | boolean
          | null
          | number
          | string
          | symbol
          | undefined
        ,
        expected:
          | bigint
          | boolean
          | null
          | number
          | string
          | symbol
          | undefined
        ,
        reason: {
          constructor?: boolean,
          descriptor?: boolean,
          enumerability: boolean,
          equality: boolean,
          missing: boolean,
          prototype?: boolean,
          reference: boolean,
          type: boolean,
        },
      },
    >;
    path
    一个数组,包含键的有序列表(第一个是最外层,最后一个是当前层),表示到达偏差的路径(['foo', 1, Symbol('zed')])。当比较非对象(例如字符串)时,path 是一个空数组([])。
    actual
    来自第二个参数的叶子值。
    expected
    来自第一个参数的叶子值。
    reason
    比较失败匹配的原因。
    {
      expected: undefined,
      actual: undefined,
      reason: { missing: true, … },
    }

    原因从一般到具体,从最外层到最内层:compare(true, new Date()) → “type” 是偏差的原因。具体顺序由引擎定义。

    相等性

    为了抑制潜在相关的差异,原始值使用 SameValue 进行比较(它不区分任何 NaN,但确实区分 -00/+0)。这可能会通过 CompareOptions 可配置。

    • 包含_以相同序列排列的相同值_的 TypedArrays 是相等的,除非 CompareOptions.reasons.constructor 启用。
    • Box 原始值(例如 new Boolean(true))等于其原始值(例如 true),除非 CompareOptions.reasons.constructor 启用。

    自定义类型由 HostTypes 处理(以避免自定义比较)。

    示例

    快速模式:相等
    compare('a', 'a');
    
    undefined
    快速模式:不相等
    compare('a', 'b');
    
    true
    完整模式:不相等
    compare('a', 'b', { mode: 'full' });
    
    Iterator => Iterable(1) {
      [] => {
        expected: 'a',
        actual: 'b',
        reason: { equality: true, … },
      },
    }
    快速模式:对象描述符与字面量
    compare(
      Object.create({}, { foo: { enumerable: true, value: 'a' } }),
      { foo: 'a' },
    );
    
    undefined
    compare(
      Object.create({}, { foo: { enumerable: true, value: 'a' } }),
      { foo: 'a' },
      { reasons: { descriptor: true } },
    );
    
    true
    compare(
      Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
      { foo: 'a' },
    );
    
    undefined
    compare(
      Object.create({}, { foo: { enumerable: true, get: () => 'a' } }),
      { foo: 'a' },
      { reasons: { descriptor: true } },
    );
    
    true
    完整模式:类型不相等
    compare('1', 1, { mode: 'full' });
    
    Iterator => Iterable(1) {
      [] => {
        expected: '1',
        actual: 1,
        reason: { type: true, … },
      },
    }
    完整模式:不可枚举值
    compare(
      Object.create({}, { foo: { enumerable: false, value: 'a' } }),
      { foo: 'a' },
      { mode: 'full' },
    );
    
    Iterator => Iterable(1) {
      ['foo'] => {
        expected: undefined,
        actual: 'a',
        reason: { enumerability: true, … },
      },
    }
    完整模式:不可枚举 getter
    compare(
      Object.create({}, { foo: { get: () => 'a' } }),
      { foo: 'a' },
      {
        mode: 'first',
      },
    );
    
    Iterator => Iterable(1) {
      ['foo'] => {
        expected: undefined,
        actual: 'a',
        reason: { enumerability: true, … },
      },
    }
    完整模式:多个叶子不相等,加上类型不匹配的干扰项
    compare(
      { foo: 'a', bar: 'c' },
      { foo: 'b', bar:  2  },
      {
        mode: 'full',
      },
    );
    
    Iterator => Iterable(2) {
      ['foo'] => {
        expected: 'a',
        actual: 'c',
        reason: { equality: true, … },
      },
      ['bar'] => {
        expected: 'c',
        actual: 2,
        reason: { equality: true, … },
      },
    }
    完整模式:多个叶子不相等和缺失
    compare(
      { foo: { bar: 'a'           } },
      { foo: { bar: 'b', qux: 'c' } },
      { mode: 'full' },
    );
    
    Iterator => Iterable(2) {
      ['foo', 'bar'] => {
        expected: 'a',
        actual: 'b',
        reason: { equality: true, … },
      },
      ['foo', 'bar', 'qux'] => {
        expected: undefined,
        actual: 'c',
        reason: { missing: true, … },
      },
    }
    完整模式:多个叶子不相等和原型
    compare(
      { foo: 'a', __proto__: null },
      { foo: 'b' },
      {
        mode: 'full',
        reasons: { prototype: true },
      },
    );
    
    Iterator => Iterable(2) {
      '[[Prototype]]' => {
        expected: null,
        actual: Object,
        reason: { prototype: true, … },
      },
      ['foo'] => {
        expected: 'a',
        actual: 'b',
        reason: { equality: true, … },
      },
    }
    完整模式:多个数组项不相等和缺失
    compare(
      ['a', 'b', 'c'     ],
      ['a', 'b', 'd', 'e'],
      {
        mode: 'full',
      },
    );
    
    Iterator => Iterable(1) {
      [2] => {
        expected: 'c',
        actual: 'd',
        reason: { equality: true, … },
      },
      [3] => {
        expected: undefined,
        actual: 'e',
        reason: { missing: true, … },
      },
    }

    兄弟提案

    当前提案本身就很有用,并为后续处理以下问题奠定了基础。

    当前提案不包括可能吸引自定义的功能,因此推迟这些功能会延迟确定如何支持自定义的需求。

    其他相关提案

    先前工作

    断言和期望

    绝大多数 ECMAScript 工程师使用两种形式之一:assertexpect。这些来自大约 4 个库:chai(每周 2000 万次)、jasmine(每周 140 万次)、jest(每周 2900 万次)、node:assert(无法确定)。这些是直接竞争对手,因此可以假设没有重叠,数字可相加:至少每周约 5100 万次(当加上 node:assert 的数字时,可能要高得多)。

    Assert
    • node:assertchai 的 TDD 集合有很大重叠。
    Expect
    • jasminejest(几乎?)相同,有专门的方法:expect(a).toEqual(b)
    • chai 的 BDD 集合是一种链式风格,自成一体:expect(a).to.equal(b)

    邻近领域

    许多主流语言原生包含某种形式的断言。举几个相关的例子:

    提案