前段时间在开发的过程中遇到一个奇怪的 Bug。
在服务端数据正常,前端页面渲染代码正常的情况下,浏览器页面渲染出的内容却不一样。
经过一番定位,最终在 Chrome 浏览器的控制台找到了线索。
在控制台里面查看到的情形是 response 和 preview 的值不一样
。
preview 的结果截图
response 的结果截图
这就奇怪了,理论上来说 preview 和 response 都是同一份数据,怎么可能不一样呢? 然而事实就是如此。
preview 返回 817809136971941000
response 返回 817809136971940993
于是,我通过 postman 发起请求,返回的数据和 response 的值一致。 后又将两个返回值和数据库里面的数据做了比对,同样发现 response 的值和后端数据库存储的是一样的。 也就是说 response 的值是对的,preview 的值是错的。
经过网上搜索、讨论、以及各种测试,最终发现了问题的原因。
直接原因就是:后端对接口做了改造,将原本返回的 string 类型的 ID 改为了 Long 类型。
根本原因是:JavaScript 中 Number 类型在处理 Long 型的数值的时候,超过了一定限制之后就会出现精度丢失的情况。
前面的 preview 和 response 不一致的问题就是因为 preview 在显示的时候处理 response 的 Long类型的时候触发了精度丢失。
可以在浏览器控制台验证一下:
可以看出,变量 b 在输出的时候值变了,xx40993变为了xx41000。
继续在控制台验证,输出更多 Long 类型数字的情形:
817809136971940993 => 817809136971941000 // 18 位
81780913697194099 => 81780913697194100 // 17 位
81780913697194041 => 81780913697194050 // 17 位,第16位 +1 了
81780913697194031 => 81780913697194030 // 17 位,第16位未 +1。
8178091369719409 => 8178091369719409 // 16 位,正常
可以看出,直接在控制台执行 Long 数值的时候,其执行结果千奇百怪。 其根本原因就是因为数字太长所以触发了 JS 数值类型的精度问题。
所以解决办法也很简单:让后端将其返回的 number 类型转换为 string 即可。
那么 Javascript 为什么会出现 Long 类型数值的精度问题呢?
Javascript 采用的是双精度浮点数存储的,每个数字占 8 个字节,即 64 个bit。 所以,JavaScript 中数值类型的精度是有限的,内部只有一种数字类型 Number。 所有数字都是采用 IEEE 754 标准定义的双精度 64 位格式存储,即使整数也是如此。 这就是说,JavaScript 语言实际上并没有真正的整数,所有数值都是小数(64 位浮点数)。
上图所示即为双精度浮点数的存储方式,途中划分了存储位,64 位格式存储其实际存储小数的有 52 位。
第 [63] 位 sign 表示符号位,1 bit,0 表示正数,1 表示负数。
第 [62~52] 位 exponent 表示指数位,11 bits,最大值为 11个1,即 Math.pow(2, 11)-1,即(0 ~ 2047)
第 [51~0] 位 fraction 表示具体小数位,从上图可以看出其存储位是 52位,即 52 bits,最大值为 52 个1。
然而,其实际还有一位是非显式存储的,因为二进制表示有效数字总是 1xxx 的形式,为数部分在规约形式下第一位默认为1,给省略了。
因此,其实际可以有 52 + 1 位来存储,即 Math.pow(2, 53)-1,即(9007199254740991)
能够通过 53 位小数位一一对应存储的数我们将其称为安全数字。 javascript 提供了查询安全数字的方法。
console.log(Number.MAX_SAFE_INTEGER); // 9007199254740991 (Math.pow(2, 53) - 1)
console.log(Number.MIN_SAFE_INTEGER); // -9007199254740991
在实际程序运行过程中,如果有数字超过了这个安全数字范围,则就会出现计算不准确的问题。
前面的 817809136971940993
有长度达到了17,很显然大于了 9007199254740991
,因此出现计算错误(精度丢失)也就能理解了。
javascript 数值计算有一个很经典的问题,0.1+0.2 === 0.30000000000000004
,其底层原因就是前面的双精度浮点数存储导致的。
计算过程如下:
// Number(0.1).toString(2) 0.1 转换为二进制
0.0001100110011001100110011001100110011001100110011001100110011
// 从第一个1开始,向后保留52位尾数,(1入0舍)
0.0001100110011001100110011001100110011001100110011001101
// Number(0.2).toString(2) 0.2 转换为二进制
// 转二进制
0.001100110011001100110011001100110011001100110011001100110011
// 从第一个1开始,向后保留52位尾数,(1入0舍)
0.001100110011001100110011001100110011001100110011001101
// 进行相加
0.0001100110011001100110011001100110011001100110011001101
+
0.001100110011001100110011001100110011001100110011001101
=
// 相加后的结果
0.0100110011001100110011001100110011001100110011001100111
// 从第一个1开始,向后保留52位尾数,(1入0舍)
0.01001100110011001100110011001100110011001100110011010
// 转十进制
0.30000000000000004
通过上面一步一步计算可以看出,之所以0.1+0.2 === 0.30000000000000004
有三个原因:
1)javascript 的数值计算是将数字转换为二进制进行计算的。
2)0.1 和 0.2 转换为二进制之后陷入了无限循环。
3)javascript 的数值存储是有精度限制的,即最多52位有效小数,1入0舍,对0.1和0.2分别进行了数值取舍。
经过一番精度截取之后再计算就导致了 0.1+0.2 != 0.3
了。
精度丢失的根本问题就在于 Javascript 语言本身的数值类型采用的是“双精度浮点数”。
而“双精度浮点数”本身存储位只有 64 位,除去符号位、指数位之后就只剩下 52 位,再加上 1 位非显式存储位,总共 53 位。
即小数后面最多可以有52个1,最大值为 Math.pow(2, 53)-1
,超过这个值就没法存了,只能丢弃,也就是所谓的“精度丢失”。
超过 2^53-1 之后的数被称为不安全的数,因为此后只要指数相同,并且尾数前 52 位相同,则这个两个数数值相同(因为 52位之后的数被丢弃了)。