相等性比较和同值

JavaScript 提供了三种不同的值比较操作

  • === — 严格相等(三等号)
  • == — 宽松相等(双等号)
  • Object.is()

选择哪种操作取决于你希望执行的比较类型。简而言之

  • 双等号(==)在比较两个值时会执行类型转换,并特殊处理 NaN-0+0 以符合 IEEE 754 规范(因此 NaN != NaN,且 -0 == +0);
  • 三等号(===)会执行与双等号相同的比较(包括对 NaN-0+0 的特殊处理),但**不**进行类型转换;如果类型不同,则返回 false
  • Object.is() 不进行类型转换,也不特殊处理 NaN-0+0(这使得它的行为与 === 相同,除了在这些特殊的数值上)。

它们对应于 JavaScript 中的四种相等算法中的三种

请注意,它们之间的区别都与它们对原始值的处理有关;它们都没有比较参数在结构上是否概念相似。对于任何具有相同结构但本身是不同对象的非原始对象 xy,上述所有形式都将评估为 false

使用 === 的严格相等

严格相等比较两个值的相等性。在比较之前,任何值都不会隐式转换为其他值。如果值具有不同的类型,则认为这些值不相等。如果值具有相同的类型,不是数字,并且具有相同的值,则认为它们相等。最后,如果两个值都是数字,则如果它们都非 NaN 且值相同,或者其中一个是 +0 而另一个是 -0,则认为它们相等。

js
const num = 0;
const obj = new String("0");
const str = "0";

console.log(num === num); // true
console.log(obj === obj); // true
console.log(str === str); // true

console.log(num === obj); // false
console.log(num === str); // false
console.log(obj === str); // false
console.log(null === undefined); // false
console.log(obj === null); // false
console.log(obj === undefined); // false

严格相等几乎总是正确的比较操作。对于除数字以外的所有值,它使用显而易见的语义:一个值只等于它本身。对于数字,它使用略微不同的语义来掩盖两个不同的边缘情况。第一个是浮点零可以是正号或负号。这在表示某些数学解决方案时很有用,但由于大多数情况不关心 +0-0 之间的差异,因此严格相等将它们视为相同的值。第二个是浮点包含一个非数字值 NaN 的概念,用于表示某些不明确的数学问题的解决方案:例如,负无穷大加上正无穷大。严格相等将 NaN 视为不等于任何其他值——包括它本身。(唯一 (x !== x)true 的情况是 xNaN。)

除了 ===,严格相等还被数组索引查找方法使用,包括 Array.prototype.indexOf()Array.prototype.lastIndexOf()TypedArray.prototype.indexOf()TypedArray.prototype.lastIndexOf(),以及 case 匹配。这意味着你不能使用 indexOf(NaN) 来查找数组中 NaN 值的索引,也不能在 switch 语句中使用 NaN 作为 case 值并使其匹配任何内容。

js
console.log([NaN].indexOf(NaN)); // -1
switch (NaN) {
  case NaN:
    console.log("Surprise"); // Nothing is logged
}

使用 == 的宽松相等

宽松相等是_对称的_:对于任何值 ABA == B 总是与 B == A 具有相同的语义(除了应用转换的顺序)。使用 == 执行宽松相等的行为如下

  1. 如果操作数具有相同的类型,则按如下方式进行比较
    • 对象:仅当两个操作数引用同一对象时返回 true
    • 字符串:仅当两个操作数具有相同字符且顺序相同时返回 true
    • 数字:仅当两个操作数具有相同的值时返回 true+0-0 被视为相同的值。如果任一操作数为 NaN,则返回 false;因此 NaN 永远不等于 NaN
    • 布尔值:仅当两个操作数都为 true 或都为 false 时返回 true
    • BigInt:仅当两个操作数具有相同的值时返回 true
    • Symbol:仅当两个操作数引用相同的 Symbol 时返回 true
  2. 如果其中一个操作数为 nullundefined,则另一个也必须为 nullundefined 才能返回 true。否则返回 false
  3. 如果其中一个操作数是对象而另一个是原始值,则将对象转换为原始值
  4. 在此步骤中,两个操作数都转换为原始值(字符串、数字、布尔值、Symbol 和 BigInt 之一)。其余的转换逐个进行。
    • 如果它们是相同的类型,则使用步骤 1 进行比较。
    • 如果其中一个操作数是 Symbol 而另一个不是,则返回 false
    • 如果其中一个操作数是布尔值而另一个不是,则将布尔值转换为数字true 转换为 1,false 转换为 0。然后再次宽松比较这两个操作数。
    • 数字到字符串:将字符串转换为数字。转换失败会导致 NaN,这将保证相等性为 false
    • 数字到 BigInt:根据其数学值进行比较。如果数字是 ±Infinity 或 NaN,则返回 false
    • 字符串到 BigInt:使用与 BigInt() 构造函数相同的算法将字符串转换为 BigInt。如果转换失败,则返回 false

传统上,根据 ECMAScript,所有原始值和对象都与 undefinednull 宽松不相等。但是大多数浏览器允许在某些上下文中,非常狭窄的一类对象(特别是任何页面的 document.all 对象)表现得仿佛它们_模拟_值 undefined。宽松相等就是这样一种上下文:如果且仅当 A 是一个_模拟_ undefined 的对象时,null == Aundefined == A 才评估为 true。在所有其他情况下,对象永远不与 undefinednull 宽松相等。

在大多数情况下,不建议使用宽松相等。使用严格相等进行比较的结果更容易预测,并且由于没有类型强制转换,因此计算速度可能更快。

以下示例演示了涉及数字原始值 0、BigInt 原始值 0n、字符串原始值 '0' 以及其 toString() 值为 '0' 的对象的宽松相等比较。

js
const num = 0;
const big = 0n;
const str = "0";
const obj = new String("0");

console.log(num == str); // true
console.log(big == num); // true
console.log(str == big); // true

console.log(num == obj); // true
console.log(big == obj); // true
console.log(str == obj); // true

宽松相等仅由 == 运算符使用。

使用 Object.is() 的同值相等

同值相等确定两个值在所有上下文中是否_功能上相同_。(此用例演示了Liskov 替换原则的一个实例。)一个例子发生在尝试修改不可变属性时

js
// Add an immutable NEGATIVE_ZERO property to the Number constructor.
Object.defineProperty(Number, "NEGATIVE_ZERO", {
  value: -0,
  writable: false,
  configurable: false,
  enumerable: false,
});

function attemptMutation(v) {
  Object.defineProperty(Number, "NEGATIVE_ZERO", { value: v });
}

Object.defineProperty 在尝试更改不可变属性时会抛出异常,但如果未请求实际更改,它不会执行任何操作。如果 v-0,则未请求更改,也不会抛出错误。在内部,当重新定义不可变属性时,新指定的值会使用同值相等与当前值进行比较。

同值相等由 Object.is 方法提供。它在语言中几乎所有期望等效标识值的地方都使用。

同值零相等

类似于同值相等,但 +0 和 -0 被认为是相等的。

同值零相等不作为 JavaScript API 公开,但可以通过自定义代码实现

js
function sameValueZero(x, y) {
  if (typeof x === "number" && typeof y === "number") {
    // x and y are equal (may be -0 and 0) or they are both NaN
    return x === y || (x !== x && y !== y);
  }
  return x === y;
}

同值零仅在将 NaN 视为等效时与严格相等不同,并且仅在将 -0 视为等效于 0 时与同值相等不同。这使得它在搜索时通常具有最合理的行为,尤其是在处理 NaN 时。它被 Array.prototype.includes()TypedArray.prototype.includes() 以及 MapSet 方法用于比较键的相等性。

比较相等方法

人们经常通过说其中一个是另一个的“增强”版本来比较双等号和三等号。例如,双等号可以被认为是三等号的扩展版本,因为前者执行后者所做的一切,但对其操作数进行类型转换——例如,6 == "6"。或者,可以声称双等号是基准,而三等号是增强版本,因为它要求两个操作数是相同的类型,因此它增加了一个额外的约束。

然而,这种思考方式意味着相等比较构成了一个一维的“光谱”,其中“完全严格”位于一端,“完全宽松”位于另一端。这个模型在 Object.is 上失效了,因为它既不比双等号“宽松”,也不比三等号“严格”,也不适合介于两者之间(即,既比双等号严格,又比三等号宽松)。我们可以从下面的同值比较表中看出,这是由于 Object.is 处理 NaN 的方式。请注意,如果 Object.is(NaN, NaN) 评估为 false,我们_可以_说它符合宽松/严格光谱,作为三等号的一种更严格的形式,它区分 -0+0。然而,对 NaN 的处理意味着这不正确。不幸的是,Object.is 必须根据其特定特征来思考,而不是其相对于相等运算符的宽松或严格程度。

x y == === Object.is SameValueZero
undefined undefined ✅ true ✅ true ✅ true ✅ true
null null ✅ true ✅ true ✅ true ✅ true
true true ✅ true ✅ true ✅ true ✅ true
false false ✅ true ✅ true ✅ true ✅ true
'foo' 'foo' ✅ true ✅ true ✅ true ✅ true
0 0 ✅ true ✅ true ✅ true ✅ true
+0 -0 ✅ true ✅ true ❌ false ✅ true
+0 0 ✅ true ✅ true ✅ true ✅ true
-0 0 ✅ true ✅ true ❌ false ✅ true
0n -0n ✅ true ✅ true ✅ true ✅ true
0 false ✅ true ❌ false ❌ false ❌ false
"" false ✅ true ❌ false ❌ false ❌ false
"" 0 ✅ true ❌ false ❌ false ❌ false
'0' 0 ✅ true ❌ false ❌ false ❌ false
'17' 17 ✅ true ❌ false ❌ false ❌ false
[1, 2] '1,2' ✅ true ❌ false ❌ false ❌ false
new String('foo') 'foo' ✅ true ❌ false ❌ false ❌ false
null undefined ✅ true ❌ false ❌ false ❌ false
null false ❌ false ❌ false ❌ false ❌ false
undefined false ❌ false ❌ false ❌ false ❌ false
{ foo: 'bar' } { foo: 'bar' } ❌ false ❌ false ❌ false ❌ false
new String('foo') new String('foo') ❌ false ❌ false ❌ false ❌ false
0 null ❌ false ❌ false ❌ false ❌ false
0 NaN ❌ false ❌ false ❌ false ❌ false
'foo' NaN ❌ false ❌ false ❌ false ❌ false
NaN NaN ❌ false ❌ false ✅ true ✅ true

何时使用 Object.is() 而不是三等号

通常,Object.is 对零的特殊行为可能引起兴趣的唯一情况是,在某些元编程方案中,特别是在属性描述符方面,当你的工作希望反映 Object.defineProperty 的某些特征时。如果你的用例不需要这个,建议避免使用 Object.is,而改用 ===。即使你的要求涉及将两个 NaN 值之间的比较评估为 true,通常也更容易对 NaN 检查进行特殊处理(使用早期 ECMAScript 版本中可用的 isNaN 方法),而不是弄清楚周围的计算可能如何影响你在比较中遇到的任何零的符号。

以下是内置方法和运算符的非详尽列表,它们可能导致 -0+0 之间的区别在你的代码中体现出来

- (一元取反)

考虑以下示例

js
const stoppingForce = obj.mass * -obj.velocity;

如果 obj.velocity0(或计算为 0),则会在该位置引入一个 -0 并传播到 stoppingForce

Math.atan2Math.ceilMath.powMath.round

在某些情况下,即使参数中不存在 -0,这些方法也可能返回 -0。例如,使用 Math.pow-Infinity 提升到任何负奇数指数会评估为 -0。请参阅各个方法的文档。

Math.floorMath.maxMath.minMath.sinMath.sqrtMath.tan

在某些情况下,如果参数中存在 -0,这些方法可能会返回 -0。例如,Math.min(-0, +0) 评估为 -0。请参阅各个方法的文档。

~, <<, >>

这些运算符中的每一个都在内部使用 ToInt32 算法。由于内部 32 位整数类型中零只有一个表示,因此 -0 在反向操作后无法存活往返。例如,Object.is(~~(-0), -0)Object.is(-0 << 2 >> 2, -0) 都评估为 false

当不考虑零的符号时,依赖 Object.is 可能很危险。当然,当目的是区分 -0+0 时,它确实能实现所需的功能。

注意事项:Object.is() 和 NaN

Object.is 规范将 NaN 的所有实例视为同一对象。然而,由于 类型化数组 可用,我们可以拥有 NaN 的不同浮点表示,它们在所有上下文中不表现相同。例如

js
const f2b = (x) => new Uint8Array(new Float64Array([x]).buffer);
const b2f = (x) => new Float64Array(x.buffer)[0];
// Get a byte representation of NaN
const n = f2b(NaN);
// Change the first bit, which is the sign bit and doesn't matter for NaN
n[0] = 1;
const nan2 = b2f(n);
console.log(nan2); // NaN
console.log(Object.is(nan2, NaN)); // true
console.log(f2b(NaN)); // Uint8Array(8) [0, 0, 0, 0, 0, 0, 248, 127]
console.log(f2b(nan2)); // Uint8Array(8) [1, 0, 0, 0, 0, 0, 248, 127]

另见