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 },
);
明确不在范围内
- 这不是测试运行器(
describe、it 等)。
- 这不是测试工具套件(
mock、stub 等)。
解决方案(草图)
比较
一个深度比较值的函数。
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 -
- 返回一个包含所有偏差的
Deviations 的 Iterator,当不存在偏差时返回 undefined。
- reasons
- 确定差异时是否以及如何处理更特殊的情况。
- reasons.constructor
false | true
- 是否考虑构造函数。这影响,除其他外,Box 原始值(
new Boolean(true) 与 true)和 TypedArrays(new 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 对象(
WeakMap、WeakRef、WeakSet)的相等性。
偏差
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,但确实区分 -0 与 0/+0)。这可能会通过 CompareOptions 可配置。
- 包含_以相同序列排列的相同值_的 TypedArrays 是相等的,除非
CompareOptions.reasons.constructor 启用。
- Box 原始值(例如
new Boolean(true))等于其原始值(例如 true),除非 CompareOptions.reasons.constructor 启用。
自定义类型由 HostTypes 处理(以避免自定义比较)。
示例
快速模式:相等
compare('a', 'a');
undefined
快速模式:不相等
完整模式:不相等
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 工程师使用两种形式之一:assert 和 expect。这些来自大约 4 个库:chai(每周 2000 万次)、jasmine(每周 140 万次)、jest(每周 2900 万次)、node:assert(无法确定)。这些是直接竞争对手,因此可以假设没有重叠,数字可相加:至少每周约 5100 万次(当加上 node:assert 的数字时,可能要高得多)。
Assert
node:assert 和 chai 的 TDD 集合有很大重叠。
Expect
jasmine 和 jest(几乎?)相同,有专门的方法:expect(a).toEqual(b)
chai 的 BDD 集合是一种链式风格,自成一体:expect(a).to.equal(b)
邻近领域
许多主流语言原生包含某种形式的断言。举几个相关的例子:
提案