[译]理解JavaScript中的 Loose Equality

87 阅读8分钟

作者:EmNudge;本文翻译自 dev.to/emnudge/und…

对于那些更喜欢视听形式的人,可以在这里看到与文章几乎相同的视频:

抽象平等,或者正如我在这篇文章中所说_的_,“松散平等”是(我认为)JavaScript中最容易被误解的话题之一。人们知道松散平等,双等于 (==), 检查它的操作数是否大致相等。字符串“55”和数字55是一回事,但不_严格_是一回事,三等于 (===).

人们通常建议不要使用松散相等。就个人而言?如果JavaScript推出了一个_严格_严格模式来删除松散相等,我就不会太费心了。

但是有很多错误的信息,我认为清理一些会有所帮助。这就是为什么我一直在这个话题上工作这么久。

// loose equality vs strict equality
"55" == 55   // -> true
"55" === 55  // -> false

实际上,松散相等是一个过程,它试图隐式_地强制其_操作数是相同的类型,然后将其传递给严格相等以给出真正的结果。隐式强制本身实际上并不太坏。它在许多其他语言中使用,JavaScript程序员经常使用它。

在这个例子中,我们利用false sy和truthy值来检查是否应该将数组打印到控制台。如果数组存在并且长度属性大于0,则打印出来。

// example of implicit coercion
const myArr = [1, 2, 3, 4, 5];
if (myArr && myArr.length) {
  console.log("My arr is: " + myArr);
}

Falsy值包括转换为布尔值时将计算为false的所有JavaScript值。

Boolean('')         // -> false
Boolean(0)          // -> false
Boolean(0n)         // -> false
Boolean(NaN)        // -> false
Boolean(null)       // -> false
Boolean(undefined)  // -> false

然而,不要把它和抽象的相等混淆。双相等通常不依赖于这个系统。当使用完全相同的值时,我们只得到一半的真。我不是统计学家,但是50-50对我来说看起来像零相关。

false == ''         // -> true
false == 0          // -> true
false == 0n         // -> true
false == NaN        // -> false
false == null       // -> false
false == undefined  // -> false

事实上,我甚至可以说虚假价值的概念从来没有规范中的抽象平等中出现过?规范是什么?

JavaScript规范是一个深奥的文档,它指导浏览器如何使用JavaScript。浏览器都可以自己编写实现代码,但是如果你想知道JavaScript是如何工作的,而不需要深入研究C++代码,这是最好的地方。 规范通常会很混乱,但是这个特定的部分实际上是可读的。它将抽象相等定义为步骤列表,我认为这很酷。如果你想知道为什么null松散地等于未定义,这就是原因。因为它是这样说的。没有低级的原因为什么它必须是这样的——讨论就到此为止。它是这样工作的,因为文档说它应该这样。

虽然我可以浏览文档,但我将使用一个我一直在使用的工具来更简单地解释它——抽象平等步进器。我已经编写了大致匹配规范的步骤。为了帮助我的工具工作,格式化方面有一些小的变化,但本质上是一样的。

让我们输入一些我们刚刚展示的例子来探索这是如何工作的。

(在这里查看)

我们可以看到它声明任何一个操作数都是布尔值,我们将布尔值转换为数字。总是。不管另一个值是什么。

请注意,它告诉我们执行一个抽象的等式比较,但是这些步骤定义了什么是抽象的等式比较。没错,这递归。我们用新的值重新开始。因为类型现在是相等的,我们把它扔到一个严格的等式比较中,这个比较返回true,因为它们是相同的值。

请注意,抽象等式使用严格等式。

因此,如果实现与规范完全匹配,技术上抽象的平等肯定性能较差。这在实践中太微不足道了,但我认为这很有趣。

让我们尝试false和 ''. 我们像上次一样将布尔值转换为数字,但是现在我们只剩下一个数字对一个字符串。

(在这里查看)

我们将字符串转换为数字,然后转到严格相等。我们在这里经常转换为数字。这是有充分理由的。数字可以被认为是最原始的类型。比较数字和数字很容易,这也是我们比较其他任何东西时所做的。即使我们使用引用相等(比如两个对象)进行比较,我们也在比较内存位置,正如你可能已经猜到的,内存位置是数字。

对于所有其他例子,我们可以用0代替false。

0 == NaN        // -> false
0 == null       // -> false
0 == undefined  // -> false

0不是NaN,所以这将是false。然后没有步骤定义0和null或未定义,所以默认情况下我们得到false。

这里与错误的价值观无关。只是看看步骤和遵守规则。

说完这个,让我们来看看抽象平等怪异的一个常见例子——一个真正的抓耳挠腮者。

WTF JS-头疼

![] == []  // -> true

这看起来很矛盾,但实际上是有意义的。首先,我们将左边的数组转换为布尔值。这确实涉及到false sy的概念,但是我们还没有触及抽象等式,只是表达式求值。因为数组不是false sy,所以我们会得到true,但是我们使用感叹号,所以我们翻转它并得到false。

false == []

因为布尔运算在这个系统中总是转向数字,所以我们的操作数是0和 []. 现在怎么办? 现在我们发现自己面对着神奇的To Primitive。这个很有趣。我们不能只比较一个原始值和一个对象,我们需要两个原始值或两个对象。我们试着把我们的数组变成一个原始,然后弹出一个空字符串。

(注意:函数只是一个可调用的对象。当我们使用术语对象时,我们包括函数)

0和'意味着我们把字符串变成一个数字,这导致我们得到相等的0和0。

但是To Primitive是如何工作的?它是做什么的?

我们可以看一下规范,但是这次有点困难,所以我冒昧地将它转换成普通的JavaScript。

// https://www.ecma-international.org/ecma-262/11.0/index.html#sec-toprimitive
const isCallable = (obj, prop) => typeof obj[prop] === 'function';
const isPrimitive = (val) =>
  val === null || !['object', 'function'].includes(typeof val);
// return primitive or error
function toPrimitive(obj) {
  if (isPrimitive(obj)) return obj;
  // try calling it if it isn't undefined or null
  if (obj[Symbol.toPrimitive] != null) {
    if (!isCallable(obj, Symbol.toPrimitive)) {
      // note the real error often includes a little bit more info about the type
      throw new TypeError(`${obj[Symbol.toPrimitive]} is not a function`)
    }
    const val = obj[Symbol.toPrimitive]('default');
    if (isPrimitive(val)) return val;
    throw new TypeError('Cannot convert object to primitive value');
  }
  for (const methodName of ['valueOf', 'toString']) {
    if (!isCallable(obj, methodName)) continue;
    const val = obj[methodName]();
    if (isPrimitive(val)) return val;
  }
  // if the callable methods only returned objects
  throw new TypeError('Cannot convert object to primitive value');
}

如果我们传递了一个原语值,只要返回它。不需要将原语转换为原语。

然后我们检查一个Symbol. to Primitive属性。这是JavaScript最近添加的一个属性,它允许您更容易地定义To Primitive行为。

如果这样的方法存在,我们尝试将它转换为一个数字。怎么做?我们检查一个. value Of属性,这就是Number所调用的。如果您尝试将您的对象添加到一个数字中,它将尝试寻找这个属性并调用它。

如果您的对象上不存在这个属性,或者它本身返回一个对象,我们会尝试将它转换成字符串。当然,使用. toString属性。默认情况下,这实际上是在所有对象上定义的,包括数组。如果您不触摸您的对象,那么To Primitive将返回一个字符串。对于数组,这意味着将其所有值作为逗号分隔的列表返回。如果它是空的,那就是一个空字符串。

const obj = {
    valueOf() {
        console.log('calling valueOf');
        return 100;
    },
    toString() {
        console.log('calling toString');
        return '👀';
    }
};
console.log(obj + 43);
console.log(`I see you ${obj}`);

(注意:字符串连接本身并不总是调用. toString)

这就是你的解释!

但是如果你仔细观察,你会注意到一些错误被抛出。等等,这是否意味着…

是的。经常有这样的情况,仅仅使用双等于就会抛出一个错误,而不是返回false。让我们现在就创建这样一个场景。

用平等检查抛出错误

const obj1 = {
    [Symbol.toPrimitive]: 45
};
console.log(obj1 == 45);
// Uncaught TypeError: number 45 is not a function

我们也可以把它变成一个函数,但是返回一个对象。

const obj2 = {
    [Symbol.toPrimitive]: () => Object()
};
console.log(obj2 == 45);
// Uncaught TypeError: Cannot convert object to primitive value

或者用其他方法做同样的事情

const obj3 = {
    toString: () => Object(),
    valueOf: () => Object()
};
console.log(obj3 == 45);
// Uncaught TypeError: Cannot convert object to primitive value

现在,我们实际上不能删除大多数对象上的这些方法。我前面提到,所有对象都默认实现了这一点。所有对象当然都从对象原型继承了这个方法,我们不能真正删除它。

但是,可以使用Object.create(null)制作一个没有原型的对象。因为它没有原型,所以它没有value Of()和toString(),因此如果我们将它与原语进行比较,它会抛出一个错误。神奇!

Object.create(null) == 45
// Uncaught TypeError: Cannot convert object to primitive value

绕了一圈,让我们以这篇文章的本质——如何理解松散平等结束。

结论

当比较两种不同类型的东西时,它将有助于将更复杂的类型转换为更简单的表示形式。如果我们能转换成数字,那就这样做。如果我们要向组合中添加一个对象,获取原始值,然后再尝试从中挤出一个数字。 null和un定义是松散相等的,就是这样。

如果我们得到像Symbol()这样的东西,或者我们互相比较null或un定义的东西,默认情况下我们会得到false。Symbol()实际上有一个. toString()方法,但是这并不重要。规范说我们得到false,所以我们得到false。

如果我们想以更简单的形式描述这些步骤,它看起来像这样:

  1. null等于未定义
  2. 数(字符串) == 数
  3. Big Int(string) = = big int
  4. 数(布尔) == 任何东西
  5. To Primitive(对象) == 任何东西
  6. Big Int(数字) = = big int

保持好奇心!