细读 JS | 相等比较详解

简介: 细读 JS | 相等比较详解

前言


记住一句话:

Always use 3 equals unless you have a good reason to use 2.


正文


一、概念


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


两者的区别:


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

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

333.webp.jpg

相等运算符(==)判断


图片出自 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

444.webp.jpg

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

需要注意的是,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


555.webp.jpg


翻译过来就是:


  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


666.webp.jpg

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


3. SameValueNonNumeric(x, y)


#sec-7.2.13


777.webp.jpg


翻译过来就是:


  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 的 Arra.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)


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
}


四、相等运算符


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


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 标准最新的抽象相等比较算法如下:

223.webp.jpg

翻译一下:


  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 的结果。
  1. 如果一个操作数是 Boolean 类型,则会将布尔值转换为 Number 类型,并返回 x == ToNumber(y) 的结果。
  2. 如果一个操作是引用类型,另一个是原始类型,那么会将引用值转换为原始值,并返回 ToPrimitive(x) == y
  3. 如果一个操作数是 BigInt 类型,另一个是 Number 类型,那么
  1. 如果其中一个数是 NaNInfinity-Infinity 中的任何一个,则返回 false
  2. 如果 xy 的数学值相等,则返回 true,否则返回 false
  1. 返回 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 类型,然后取反再返回。


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


六、参考


目录
相关文章
|
缓存 自然语言处理 JavaScript
细读 JS | JavaScript 模块化之路
细读 JS | JavaScript 模块化之路
132 0
细读 JS | JavaScript 模块化之路
|
Web App开发 JavaScript 前端开发
细读 JS | XSS、CSRF 浅谈
细读 JS | XSS、CSRF 浅谈
127 0
|
算法 JavaScript 前端开发
细读 JS | 浅谈内存泄露、内存溢出
细读 JS | 浅谈内存泄露、内存溢出
206 0
|
存储 Web App开发 JavaScript
细读 JS | Cookie 详解
细读 JS | Cookie 详解
338 0
细读 JS | Cookie 详解
|
存储 JavaScript 前端开发
细读 JS | 事件详解
本文将会介绍事件、事件流、事件对象、事件处理程序、事件委托、以及兼容 IE 浏览器等内容。
145 0
细读 JS | 事件详解
|
存储 弹性计算 自然语言处理
细读 JS | 执行上下文、作用域
细读 JS | 执行上下文、作用域
123 0
细读 JS | 执行上下文、作用域
|
JavaScript 前端开发
细读 JS |(隐式)数据类型转换详解
细读 JS |(隐式)数据类型转换详解
179 0
细读 JS |(隐式)数据类型转换详解
|
JavaScript 前端开发
细读 JS | valueOf 和 toString 方法
在讲述ToPrimitive 和 OrdinaryToPrimitive 操作时,涉及到这两方法,所以今天来简单写一下。
169 0
|
JSON JavaScript 前端开发
细读 JS | 数据类型详解
细读 JS | 数据类型详解
105 0
细读 JS | 数据类型详解
|
12天前
|
存储 移动开发 JavaScript
学习javascript,前端知识精讲,助力你轻松掌握
学习javascript,前端知识精讲,助力你轻松掌握