相信很多人都看过下面这张图:
可以说将 js 的黑暗面展示的淋漓尽致,这篇文章咱们就一条一条的解读一下,让你知道 js 为什么这么黑。文章参考了很多大佬的分析(链接贴在末尾),本人只是进行了一些微小的学习和整理工作。如果我的分析你看的不是太懂的话,可以点进去进一步学习下。
1、typeof NaN > "number"
如果有人对这个感到迷惑,那说明你应该重新学下编程基础了,来让我们复习一下 js 里的几大空值:
-
undefined
:空,代表不存在,这里啥都没有,真正的一片虚无。 -
null
:空对象,代表这里有个变量,但没有指向实际值,你可以简单的将其理解为空指针。其类型为Object
,虽然说这是个 历史遗留问题。但其实也说得通(描述了一个啥都不是的空对象)。 -
NaN
:不是一个数字(Not-A-Number),表示这里应该是一个数字,但是出了问题没办法提供实际的数字值,多用在数学计算失败的返回值上。
看到了么,人家本来就是用来描述数字的。
2、9999999999999999 == 10000000000000000
精度丢失问题,我们都知道 js 里所有的数字都是 IEEE 754 标准中的 64 位双精度浮点数,这个规范下的 64 个 bit 位分配如下:
正负部分 | 指数部分 | 小数部分 |
---|---|---|
1 位 | 11位 | 52位 |
所以说,一个数字所能表示的最大精度就是 2 ^ 53 - 1
,也就是 9007199254740992
,在 js 中这个值为 Number.MAX_SAFE_INTEGER(最大安全数值),当超过了这个值的时候,多出的位数就会被置为 0,从而导致溢出为其他值。
2 的 53 次方而不是 52 次方的原因是,IEEE 754 规定,如果指数部分的值在 0 到 2047 之间(不包含两端),那么有效数字的第一位默认总是 1,不进行保存。后面减一是因为,数字里包含 0,所以实际的数字范围也是个开区间。
需要指出的是,虽然不安全,但是 js 依旧可以处理这些数值,这种 “不安全” 的情况会一直持续到 (2 ^ 53 - 1) x 2 ^ 971,即开区间的 2 ^ 1024(指数部分为 11 位),这个值也就是 js 所能表示的最大值(Number.MAX_VALUE)
3、0.1 + 0.2 == 0.3 // false
这个和上面的超大值溢出一样,也属于溢出问题,你可能会问 0.1 和 0.2 这玩意也能溢出?是的溢出了,造成这种反直觉溢出的主要原因是十进制小数转二进制采用的是 乘二取整法,你可以在各种 在线进制转换工具 中尝试一下,会得到如下结果:
十进制数值 | 指数值 | 二进制小数值 |
---|---|---|
0.1 | -4 | 1100110011001100110011001100110011001100110011001101(52位) |
0.2 | -3 | 1100110011001100110011001100110011001100110011001101(52位) |
这两个小数值 相加 后,可以发现位数为 53 位,正好溢出了。
不过 js 表示,这种情况我也没招,当时考虑的太少也没搞啥东西来避免这个问题(只要使用了IEEE 754 浮点数格式来存储浮点数都存在精度丢失问题。但是 C#、Java 提供了 Decimal、BigDecimal 类来避免精度丢失),不如我给你个值你自己判断吧。于是你可以在 js 中找到常量 Number.EPSILON,如果两个数值相差小于这个值,你就可以认为这两个值相等。
4、Math.max() 返回 -Infinity / Math.min() 返回 Infinity
事实上,你可以在 MDN 对应的介绍中(Math.max(),Math.min())看到这个机制是确实存在的,为什么要这样呢?实际上这是一种安全机制,因为这两个函数都是需要参数的,他们两个存在的意义是 获取所有参数中的最大值 / 最小值。如果你不提供参数的话就会出现这样,通过返回这种明显异常的数值来警告你代码是不正确的。
所以,为什么不直接抛个异常出来呢?
5、[] + [] 返回 ""
导致这个的主要原因是加法操作符重载问题,在 js 中,加号有两种用途,两个数字相加和字符串拼接。而在决定究竟执行哪个操作时,有下面两个比较特殊的规则:
- 两个运算子中只要有一个是字符串类型,那么就执行字符串拼接
- 如果运算子是对象,那么就先调用其
.valueOf
方法,如果方法的返回值不是一个有效数字,就调用其.toString
方法
记住这两条规则,下面所有和加号有关的例子都可以用这两条规则来解释。而且注意这两个规则不是互斥的,比如现在这个例子:
- 因为数组是个对象,所以调用了其 valueOf 方法,但是这个方法返回了他本身(还是个数组),所以 js 又去调用了其 toString 方法
- 而数组重写了自己的 toString 方法,其返回值和 Array.prototype.join() 的返回值相同。所以其 toString 方法返回了一个空字符串
- 两个空字符串拼接,结果还是一个空字符串。
并且,由于规则二的存在,所以我们可以通过重写 valueOf 方法来“修复”这个问题,如下:
Array.prototype.valueOf = function() {
return this.reduce((pre, cur) => pre + cur, 0)
}
6、[] + {} 返回 "[object Object]"
看上一条里的规则1,只要运算子有字符串,就会执行字符串拼接。
而 [] 的 toString 返回空字符串,{} 的 toString 返回 "[object Object]"
这两者得到这个结果就是自然而然的事情。
7、{} + [] 返回 0
在 js 中,大括号会被优先解释为代码块而非创建对象。所以这句话的意思等同于 {}; +[]
。分号前什么都没有输出,然后加号被解释为一元数值运算符,和数组 [] 进行运算,得到数字 0。
这里有两个要点,一是 js 的大括号会优先解释为代码块,例如:
function func(){}.toString()
// 会报错
{}.valueOf()
// 会报错
const func = () => {}
// 大括号为代码块
当大括号前没有什么代码对其进行说明,或者我们使用特定的关键字,例如 function 或者 => 指定后,大括号作为复合语句的一部分肯定会解释为代码块。想要解决这个问题,我们可以通过添加小括号(表达式运算符)的方式告诉 js 这个大括号不是一个代码块:
// 这里借助的是小括号的最高优先级特性
// 先将函数生成出来,然后再访问这个函数对象的 toString 方法
(function func(){}).toString()
// "function func(){}"
({}).valueOf()
// {}
const func = () => ({})
// 大括号为箭头函数返回的一个对象
所以,在这个例子中,我们可以通过相同的方法得到符合我们预期的结果:
({} + [])
// "[object Object]"
第二个要点是一元的数值操作符 +
,在 MDN 介绍 中可以看到,它会把操作值转换为数字类型,和加法操作符的字符串拼接规则一样,数值操作符 + 操作对象时,也会依次读取 .valueOf 和 .toString 方法来获取原始值再进行转换,也就是说:
- 空数组的 toString 返回空字符串
""
,Number("")
转化为数字后值为 0。 - 而空对象的 toString 返回字符串
"[object Object]"
,Number("[object Object]")
转为数字后值为 NaN。
8、true + true + true === 3 / true - true 返回 0
在 js 中,false 的实际值为 0,true 的实际值为 1(不是所有非 0 值)。而使用加法和减法运算符时,true 可以被转换为有效数字 1,所以这两个结果就自然如此了。
9、true == 1 为真 / true === 1 为假
除了刚才提到的 true 的实际值为 1,这个例子还涉及到 js 的全等运算符(三等操作符、严格操作符),它和相等操作符(==)的区别就在于:
相等操作符(==)会在比较时先进行隐式类型转换,并比较转换后的值,而全等操作符(===)不会进行类型转换,类型不一致就是不一致,所以全等操作符更加严格。
了解了这一点之后,这两个例子也就好理解了:
- 在等于比较中:true 被隐式转换为 1,所以 true == 1 为真。
- 在全等比较中:1 和 true 的类型不一致,所以 true === 1 为假。
10、(! + [] + [] + ![]).length == 9
标准的 js 恶心人题目,这个完全就是运算符优先级和隐式类型转换的产物,参考 MDN 的优先级介绍,我们可以明确这个表达式的优先级:
然后再结合取反操作符(!)会把操作值转换为布尔类型,而数值操作符(+)会将操作值转换为对应的数字类型,所以最后会获得这样的表达式(+[] 会返回 0 ):
true + "" + false
再回想下我们上文提到的加法操作符规则,只要有字符串,其运算子都会被转换为字符串,所以这个表达式的最终结果就是:
"truefalse"
其长度为 9。
这种问题恶心就恶心在,平时写码中基本不会遇到,但是面试的时候还特容易出,虽然在其他语言里也能遇到,但是 js 的隐式类型转换让它的混淆程度更上一层楼。
11、9 + "1" 返回 "91"
还是上文提到的加法操作符重载,只要有字符串,就会进行字符串拼接,所以其值为 "9" + "1"
也就是 "91"
12、91 - "1" 返回 90
是不是有点麻,不要麻,js 的算数操作符中只有加号有重载,减号也没有什么“字符串削减”操作,那肯定是数字相减啊,既然要相减,那肯定是 "1" 转换为数字,而不是 91 转换为字符串。
13、[] == 0 为真
看一下 相等操作符(==)判断规则,可以发现,当两个操作值类型不同,且其中一个为对象时,会访问其 valueOf 或者 toString 方法获取其原始值,于是:
[] 变成了空字符串 ""。
然后继续,如果两个操作值类型不同,且是数字与字符串进行比较时,会尝试将字符串转换为数字值,于是:
空字符串 "" 变成了数字 0 。
然后结果就显而易见了。
写在最后
从上面的例子里可以看到,绝大多数问题都是由 js 的隐式类型转换导致的。所以可以在各种比较操作符的说明中看到一长串的介绍,说明了在遇到不同类型时 js 会怎么处理。作为动态类型语言,js 在让编码更低成本的同时不可避免的引入了这些问题,所以在平时的编码中更要积极的使用各种严格类型工具来尽量避免这些问题。
本人才疏学浅,如有纰漏欢迎指正。
参考
- "Thanks for inventing javascript" を解読してみた
- JavaScript的精度丢失和隐式类型转换_随风丶逆风的博客-CSDN博客
- 详解 undefined 与 null 的区别
- JavaScript 中小数和大整数的精度丢失 | 岁月如歌 (wordpress.com)
- 数值 -- JavaScript 标准参考教程(alpha) (ruanyifeng.com)
- Annotated ES5 The Number Type