细读 JS | 相等比较详解

发布于 2023-05-13 14:45:29 字数 21404 浏览 94 评论 0

记住一句话:Always use 3 equals unless you have a good reason to use 2.

一、概念

在 JavaScript 中我们经常会使用 ===(Strict equality,全等运算符)和 ==(Equality,相等运算符)来比较两个操作数是否相等,它俩都返回一个布尔值的结果(否定形式分别是 !==!=)。

两者的区别:

  • === 总是认为不同类型的操作数是不相等的。
  • == 与前者不同,它会尝试强制类型转换且比较不同类型的操作数。(即如果操作数的类型不同,相等运算符会在比较之前尝试将它们转换为相同的类型)

放一张很经典的图,相信很多小伙伴都看过了。

相等运算符(==)判断

图片出自 JavaScript-Equality-Table

二、全等运算符

全等运算符(===和 !==)使用全等比较算法来比较两个操作数。

MDN 可以看到,大致概括如下:

  • 如果操作数的类型不同,则返回 false
  • 如果两个操作数都是对象,只有当它们指向同一个对象时才返回 true
  • 如果两个操作数都为 null,或者两个操作数都为 undefined,返回 true
  • 如果两个操作数有任意一个为 NaN,返回 false
  • 否则,比较两个操作数的值:
    • 数字类型必须拥有相同的数值。+0 和 -0 会被认为是相同的值。
    • 字符串类型必须拥有相同顺序的相同字符。
    • 布尔运算符必须同时为 true 或同时为 false

列举几个示例:

console.log(1 === '1') // false
console.log(1 === true) // false
console.log(undefined === null) // false
console.log({} === {}) // false

以上这些结果相信是毫无疑问的。但是,从写下这篇文章的时候 MDN 上关于 === 的描述并未涉及 Symbol、BigInt 类型。

那么,我们直接看 ECMAScript 最新标准吧。(#sec-7.2.16

看起来“好像”只是简单的几句话对吧,翻译一下:

需要注意的是,Type(x) 是 ECMAScript 标准定义的抽象操作(Abstract Operation),并非是 JavaScript 的某个语法,它返回的是 8 种数据类型。如果你多读 ECMAScript 标准,就会发现很多抽象操作里都有引用到 Type(x),假设每处都对 Type(x) 进行描述显得多余重复,何不将它抽出来定义成一个抽象操作。(我猜 ECMAScript 标准修订者也是这么想的)

  1. 如果 xy 是两种不同的数据类型,则返回 false
  2. 如果 xy 的类型同时是 Number 或 BigInt 类型,那么
    a. 对应返回 Number::equal(x, y)BigInt::equal(x, y) 的操作结果。
  3. 返回 SameValueNonNumber(x, y) 的结果。

注意:该算法与 SameValue 算法的区别在于对有符号零NaN 的处理。

原来它是这样的三句话,哎~

1. Number::equal(x, y)

#sec-6.1.6.1.13

翻译过来就是:

  1. 如果 xy 任意一个的值为 NaN,则返回 false
  2. 如果 xy 的数学量(Number value)是相等的,则返回 true
  3. 如果 x+0y-0,则返回 true(反之亦然)。
  4. 以上均不符合,则返回 false

需要注意的是,+0-0 的数学量是不相等的。(将 Number value 翻译成数学量,不一定准确哈,这是 Google Translation 告诉我的,我没去细究,就那个意思,哈哈)

1 / +0 // Infinity
1 / -0 // -Infinity
Infinity === -Infinity // false

2. BigInt::equal(x, y)

#sec-6.1.6.2.13

如果 xy 均为 BigInt 类型,且具有相同的数学整数值(mathematical integer),则返回 true,否则返回 false

3. SameValueNonNumeric(x, y)

#sec-7.2.13

翻译过来就是:

  1. 断言:x 既不是 Number 类型,也不是 BigInt 类型。
  2. 断言:xy 的数据类型相同。
  3. 如果 x 是 Undefined 类型,则返回 true
  4. 如果 x 是 Null 类型,则返回 true
  5. 如果 xString 类型,那么
    a. 如果 xy 是完全相同的代码单元序列(在相应的索引处具有相同的长度和相同的代码单元),则返回 true;否则返回 false
  6. 如果 x 是 Boolean 类型,那么
    a. 如果 xy 同时为 true,或同时为 false,则返回 true,否则返回 false
  7. 如果 x 是 Symbol 类型,那么
    a. 如果 xy 都是相同的 Symbol 值,则返回 true,否则返回 false
  8. 如果 xy 是同一个引用值,则返回 true,否则返回 false

几个点注意一下:

  1. Assert 是断言。若以 Assert: 为开头的步骤(Step),明确了其算法的不变条件。例如上面的 SameValueNonNumeric 算法,第 1、2 步骤明确了 xy 的具有相同的数据类型,且不为 Number 或 BigInt 类型。可以理解为,这两句断言是该算法的前提条件。

    细心的同学可能会发现,类似第 5 条步骤:if Type(x) is String, then...,接着下一条不是 if Type(y) is String, then...,因为它没必要了,前面的步骤已经明确了它的类型是一致的,如果有反而多余了。回头再看 Number::equal(x, y) 算法,其中第 1、2 步骤分别说明了 xNaNyNaN 的情况,因为它前置条件没有断言。下文的算法也有类似的情况。

  2. 如果第 3、4 步骤有人不理解的话,看这里。由于 Undefined、Null 类型对应的值分别只有 undefinednull。如果 x 为 Undefined 类型,则可以推断出 xy 的值,只能是 undefined,因此返回 true。Null 同理。

    注意:本文表示数据类型,均以大写字母开头,例如 Undefined、String 等,而表示某种数据类型的值,均以小写字母出现,例如 undefinednull 等。前者不使用代码块形式高亮展示,后者则高亮展示。

4. 总结一下

  • 如果操作数的数据类型不同,则返回 false
  • 如果两个操作数的数据类型相同:
    • 如果操作数均为引用类型,只有当它们指向同一个对象时才返回 true,否则返回 false
    • 如果操作数是 String、Boolean、Undefined、Null 类型之一,它们的值相等才返回 true,否则返回 false
    • 如果操作数是 Symbol 类型,只有它们指向同一个值才返回 true,否则返回 false。(单独拿出来是因为它有点像引用类型的意思,Symbol 类型的本质就是创建一个独一无二的字符串,即使我们使用 Symbol() 函数创建了表面“看似一样”的字符串,但实际上它们是不相等的。例如:Symbol() === Symbol() 结果为 false
    • 如果操作数是 Number 或 BigInt 类型,(除了一个例外)它们必须具有相同的数学值,才会返回 true,否则返回 false
      • xy 的值之一是 NaN,返回 false
      • x+0y-0,返回 true,反之亦然。
      • x+0ny-0n,返回 true,反之亦然。

三、全等运算符的“坑”

根据以上的比较算法,感觉 === 也并不是总“靠谱”。例如以下“反直觉”的判断:

// PS:NaN 是一个全局对象的属性,与 Number.NaN 的值一样,都为 NaN(Not-A-Number)。
console.log(NaN === NaN) // false
console.log(Number.NaN === NaN) // false

console.log(+0 === -0) // true

1. NaN

NaN 是 JavaScript 中唯一一个不等于自身的值。

因此,NaN !== NaN 结果为 true 似乎没毛病,只是反人类、反直觉罢了。

下面总结了几种方法,来判断一个值是否为 NaN

// 1. 利用 NaN 的特性,JavaScript 中唯一一个不等于自身的值
function isnan(v) {
  return v !== v
}

// 2. 利用 ES5 的 isNaN() 全局方法
function isnan(v) {
  return typeof v === 'number' && isNaN(v)
}

// 3. 利用 ES6 的 Number.isNaN() 方法
function isnan(v) {
  return Number.isNaN(v)
}

// 4. 利用 ES6 的 Object.is() 方法
function isnan(v) {
  return Object.is(v, NaN)
}

因此,无法通过 Array.prototype.indexOf() 来确定 NaN 在数组中的索引值。

[NaN].indexOf(NaN) // -1

可使用 ES6 的 Array.prototype.includes 方法判断

[NaN].includes(NaN) // true

Array.prototype.indexOf() 内部基于 Strict Equality Comparison 去比较,判断两个 Number 类型的操作数是否相等是根据 equal 算法实现的。而 Array.prototype.includes 内部则使用了 sameValueZero 算法,前者认为 NaNNaN 是不相等的,后者则认为相等的。(详情看 Number::equal (x, y)Number::sameValueZero (x, y)

关于 NaNisNaN()Number.isNaN() 的,请看文章 JavaScript 的迷惑行为大赏 里面总结的第二、第三节内容。

2. +0 与 -0

在 JavaScript 中,数字类型包括浮点数、+Infinity(正无穷)、-Infinity(负无穷)和 NaN(not-a-number,非数字)。

还有 ES2021 标准中增加了一种 BigInt(原始)类型,表示极大的数字(非本文范围,不展开叙述)。

其实,+0-0 是不相等的,为什么?

console.log(1 / +0 === Infinity) // true
console.log(1 / -0 === -Infinity) // true
console.log(Infinity === -Infinity) // false

// 因此,+0 和 -0 是两个不相等的值。只是“全等比较算法”里认为他们是相对的而已。

3. 处理以上两种特殊的情况

在 ES6 标准中,提出来一个方法 Object.is(),对以上两种情况都做了“正确”的处理。

Object.is(NaN, NaN) // true
Object.is(+0, -0) // false

而 ES5 可以这样去处理:

function objectIs(x, y) {
  if (x === y) {
    // x === 0 => compare via infinity trick
    return x !== 0 || (1 / x === 1 / y)
  }

  // x !== y => return true only if both x and y are NaN
  return x !== x && y !== y
}

四、相等运算符

相等运算符(==!=)使用抽象相等比较算法比较两个操作数。

在看这一节之前,如果对 JavaScript 的类型转换不熟悉的话,建议先看下这文章 Type Conversion 详解

1. ES5 相等比较算法

MDN 可以看到 x == y 比较的描述,如下:

  • 如果两个操作数都是对象,则仅当两个操作数都引用同一个对象时才返回 true
  • 如果一个操作数是 null,另一个操作数是 undefined,则返回 true
  • 如果两个操作数是不同类型的,就会尝试在比较之前将它们转换为相同类型:
    • 当数字与字符串进行比较时,会尝试将字符串转换为数字值。
    • 如果操作数之一是 Boolean,则将布尔操作数转换为 10
      • 如果是 true,则转换为 1
      • 如果是 false,则转换为 0
    • 如果操作数之一是对象,另一个是数字或字符串,会尝试使用对象的 valueOf()toString() 方法将对象转换为原始值。
  • 如果操作数具有相同的类型,则将它们进行如下比较:
    • Stringtrue 仅当两个操作数具有相同顺序的相同字符时才返回。
    • Numbertrue 仅当两个操作数具有相同的值时才返回。+0 并被 -0 视为相同的值。如果任一操作数为 NaN,则返回 false
    • Booleantrue 仅当操作数为两个 true 或两个 false 时才返回 true

截止发文日期,我们可以看到它并没有关于 Symbol 和 BigInt 类型的描述,因此以上相等比较并不是最新的。

目前 MDN 上的文档仍展示的是 ES5.1 的算法,而阮一峰老师翻译的一篇也不是最新的相等比较算法,它里面没有 ES2020 新增的 BigInt 类型。

2. ES6+ 相等比较算法

目前 ECMAScript 标准最新的抽象相等比较算法如下:

翻译一下:

  1. 如果 xy 相同,则返回全等比较 x === y 的结果。
  2. 如果 xnullyundefined,则返回 true
  3. 如果 xundefinedynull,则返回 true
  4. 如果 x 为 Number 类型,而 y 为 String 类型,那么先将 y 转换为 Number 再比较。
  5. 如果 x 为 String 类型,且 y 为 Number 类型,那么先将 x 转换为 Number 再比较。
  6. 如果 x 是 BigInt 类型,且 y 是 String 类型,则
    a. 将 y 转换为 BigInt 类型,转换结果暂记为 n
    b. 如果 nNaN,则返回 false
    c. 否则,返回 x == n 的比较结果。
  7. 如果 x 为 String 类型,y 为 BigInt 类型,则返回比较结果 y == x
  8. 如果 x 为 Boolean 类型,那么先将 x 转换为 Number 类型,再比较。
  9. 如果 y 为 Boolean 类型,那么先将 y 转换为 Number 类型,再比较。
  10. 如果 x 是 String、Number、BigInt 或 Symbol 类型,并且 y 是Object 类型,那么将 y 转换成原始(Primative)类型再比较。
  11. 如果 x 是 Object 类型,且 y 是String、Number、BigInt 或Symbol 类型,那么将 x 转换成原始(Primative)类型再比较。
  12. 如果 x 是 BigInt 类型,且 y 是 Number 类型,或者 x 是 Number 类型且 y 是 BigInt 类型,则
    a. 如果 xyNaNInfinity-Infinity 中的任何一个,则返回 false
    b. 否则返回 xy 的比较结果。
  13. 以上都不满足,那么返回 false。例如规则 10 在类型转换时出现 TypeError 状况时,那么就会在这里返回 false

相等运算符与全等运算符===)运算符之间最显着的区别在于,后者不尝试类型转换。相反,前者始终将不同类型的操作数视为不同。

列举几个示例:

// 这些例子都是很简单的
console.log(false == undefined) // false
console.log(null == undefined) // true
console.log(null == false) // false
console.log(null == 0) // false
console.log(null == '') // false
console.log('\n  123  \t' == 123) // true, because conversion to number ignores leading and trailing whitespace in JavaScript.

相信很多人在没有完全弄清楚相等运算符比较的【套路】之前,会很让人抓狂...

针对以上 13 条规则,再提炼总结一下(但是标准描述真的是非常地严谨):

  1. 如果两个操作数是同一类型,返回 x === y 的结果。
  2. 如果一个操作数是 null,另一个操作数是 undefined,则返回 true
  3. 如果一个操作数是 Number 类型,另一个是操作数 String 类型,那么会将 String 类型的操作数先转换为 Number 类型,接着按第 1 点去比较并返回结果。
  4. 如果一个操作数 x 是 BigInt 类型,另一个是操作数 y 是 String 类型,那么会将 String 类型的操作数先转换为 BigInt 类型(假设转换结果为 n)。
    1. 如果 nNaN,则返回 false
    2. 否则返回 x == n 的结果。
  5. 如果一个操作数是 Boolean 类型,则会将布尔值转换为 Number 类型,并返回 x == ToNumber(y) 的结果。
  6. 如果一个操作是引用类型,另一个是原始类型,那么会将引用值转换为原始值,并返回 ToPrimitive(x) == y
  7. 如果一个操作数是 BigInt 类型,另一个是 Number 类型,那么
    1. 如果其中一个数是 NaNInfinity-Infinity 中的任何一个,则返回 false
    2. 如果 xy 的数学值相等,则返回 true,否则返回 false
  8. 返回 false(包括以上转换过程出现 TypeError 都会在这里返回 false)。

仔细观察可以看得到,相等比较都向着数字类型(这里指 Number 和 BigInt)转化的趋势。

五、相等比较常见示例

其实熟悉以上算法之后,遇到一些看似很“奇葩”的比较也不足为惧了。

1. 0 == false

0 == null // false
0 == undefined // false,同理

根据 Type(x) 抽象操作规则,Type(0) 结果为 NumberType(null) 结果为 Null,再结合比较算法,可以清楚地知道它按照第 13 点返回 false

需要注意的是,这里的 Type(x) 是指 ECMAScript 标准里面定义的一个操作,该操作表示返回 x 对应的数据类型。而不是具体的 JavaScript 语法,与 typeof() 有本质意义上的不同,别混淆了。

2. [] == ![]

看到这个式子,千万别急眼,一步一步来......

[] == ![] // true

我们来分析一下,根据运算符优先级,! (逻辑非)的优先级高于 ==,因此优先执行 ![]。而 [] 属于真值(Truthy) ,所以 ![] 结果为 false

[] == false

根据第 9 条规则,先将 Boolean 类型的值转换为 Number 类型,所以变成了:

[] == 0

再根据第 11 条规则,先将引用类型转换为原始类型,故进行的操作是 ToPrimitive([])

(这步操作可能稍微复杂一点点,但别急)由于数组实例本身没有 @@toPrimitive 方法,且此时 ToPrimitive 操作的 hint 值为 "default"(根据 ToPrimitive 规则,里面的其中一个步骤会将 hint 设为 "number"),然后进行 OrdinaryToPrimitive 操作,因此会先调用 valueOf 方法,再调用 toString 方法。

// 由于
[].valueOf() // 结果为数组本身,并非原始值,接着调用 toString 方法
[].toString() // 结果为 "",所以 [] 的 ToPrimitive 操作返回的是空字符串

// 所以变成了
'' == 0

根据第 5 条规则,将 "" 转换为数值 0,即

0 == 0 // true

严格来说,其实还有一步的。根据第 1 条规则,返回 0 === 0 的结果。

整个转换过程如下,因此 [] == ![] 比较结果为 true

[] == ![]
[] == false
[] == 0
'' == 0
0 == 0
0 === 0 // true

3. {} == !{}

它看着跟前面一个例子很相似,转换过程同理,但结果是...

{} == !{} // false

首先,根据操作符优先级顺序,先将 !{} 转换为 false。即变成了 {} == false 的比较,然后将 false 转换为 0,所以变成了。

{} == 0

然后将 {} 转换为原始值,即执行 ToPrimitive({}) 操作。其中 hint"default"。所以先执行 {}.toString() 方法,得到 "[object Object]" 结果,由于结果已经是原始值,不再调用 valueOf() 方法了。

"[object Object]" == 0

根据第 5 条规则,将 "[object Object]" 转换为数值,进行的操作是 ToNumber("[object Object]"),即执行方法 Number("[object Object]"),得到的结果是 NaN

NaN == 0

由于 NaN0 都是 Number 类型,根据第 1 条规则,返回 NaN === 0 的结果。而 NaN 与任何操作数(包括其本身)进行全等比较,均返回 false

因此 {} == !{} 的结果为 false。整个过程如下:

{} == !{}
{} == false
{} == 0
'[object Object]' == 0
NaN == 0
NaN === 0 // false

4. 10 == 10n

来看看 Number 类型与 BigInt 类型的相等比较。

10 == 10n // true

根据规则第 12 条,且两个操作数并非是 NaNInfinity-Infinity,因此比较两者的数学值(mathematical value),其数学值是相等的,所以结果为 true

五、其他

1. +、-、*、/、% 的隐式类型转换

除了 +- 既可以作为一元运算符、也可以是算术运算符,其余的均为算术运算符。

  • +-*/% 均为算术运算符时,会将运算符两边的操作数先转换为 Number 类型(若已经是 Number 类型则无需再转换),再进行相应的算术运算。

  • +- 作为一元运算符时,即只有一个运算符和操作数。前者将操作数转换为 Number 类型并返回。后者将操作数转换为 Number 类型,然后取反再返回。

未完待续,拼命更新中...

六、参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

深巷少女

暂无简介

文章
评论
385 人气
更多

推荐作者

佚名

文章 0 评论 0

今天

文章 0 评论 0

゛时过境迁

文章 0 评论 0

达拉崩吧

文章 0 评论 0

呆萌少年

文章 0 评论 0

孤者何惧

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文