前言
在最近业务开发中, 作者偶遇到了一个与 JavaScript 浮点数相关的 Bug。
这里就简单描述下背景: 在提现相关业务时, 会将展示给用户以元为单位
的数值转化为以分为单位
的数值。 例如, 0.57元 转化为 57 分
。
转化方法很简单
// 小程序代码 onInput: 监听Input事件
onInput(e) {
let value = e.target.value;
//限制除数字和小数点以外的字符输入
if (!/^\d*\.{0,2}\d{0,2}$/.test(value)) {
value = value
.replace(/[^\d.]/g, '')
.replace(/^\./g, '')
.replace(/\.{2,}/g, '.')
// 保留数字小数点后两位
.replace(/^(.*\..{2}).*$/, '$1');
}
//...
this.setData({
cash: +value * 100 // 乘100, 将元转化为分
})
}
这段看似没有问题的代码, 提交给后台时, 接口却返回参数值格式不正确。
最初, 怀疑是正则表达式有疏漏, 但测试了一下没有问题, 然后就尝试了用户输入的数值 0.57, 却发现计算值却出人意料, 也就是题目中的 0.57 * 100 === 56.99999999999999
前端开发同学或多或少都应该看到过0.1 + 0.2 === 0.30000000000000004
这个经典问题。 作者当初也抱着好奇的态度看了相关文章, 说来惭愧, 想到自己无论如何也不会开发0.1 + 0.2
的业务, 也只是了解到了为什么会是这样的结果就浅尝辄止了。
如今踩了坑, 只能说是自己跳进了当年挖的坑, 那今天就将这个坑填上。
本文文章会讲述以下几个问题, 已经熟悉同学就可以不用看啦。
- 为什么 0.1 + 0.2 === 0.30000000000000004
- 为什么 0.57 * 100 === 56.99999999999999
- 为什么 0.57 * 1000 === 570
Why 0.1 + 0.2 === 0.30000000000000004 ?
要解答这个问题始终绕不过JavaScript中最基础也是最核心的浮点数的格式存储
。 在JS中, 无论整数还是小数都是Number
类型, 它的实现遵循IEEE 754, 是标准的Double双精度浮点数, 使用固定的64位来表示。
看到这里你可能就不想看下去了。好好好, 那就后面再说, 这里就用大白话简单讲解, 详细内容在文章后面阅读。
实际上, JS中的数字都会转化为二进制存储下来, 由于数字存储限定了64位, 但现实世界中, 数字是无穷的, 所以一定会有数字超出这个存储范围。超出这个范围的数字在存储时就会丢失精度。
同时, 我们都知道, 整数十进制转二进制时, 是除以二去余数, 这是可以除尽的! 但我们可能不知道的是, 小数十进制转化为二进制的计算方法是, 小数部分*2, 取整数部分, 直至小数部分为0, 如果永远不为零, 在超过精度时的最后一位时0舍入1。
/* 0.1 转化为二进制的计算过程 */
0.1 * 2 = 0.2 > 取0
0.2 * 2 = 0.4 > 取0
0.4 * 2 = 0.8 > 取0
0.8 * 2 = 1.6 > 取1
0.6 * 2 = 1.2 > 取1
0.2 * 2 = 0.4 > 取0
...
后面就是循环了
到这里, 我们就可以发现一些端倪了
// 使用toString(2), 将10进制输出为二进制的字符串
0.1.toString(2);
// "0.00011001100110011001100110011001100110011001100110011001100..."
0.2.toString(2);
// "0.001100110011001100110011001100110011001100110011001100110011..."
// 二进制相加结果, 由于超过精度, 取52位, 第53位舍0进1
> "0.010011001100110011001100110011001100110011001100110011,1"
// 最后存储下来的结果是
const s = "0.010011001100110011001100110011001100110011001100110100"
// 用算法处理一下。
a = 0;
s.split('').forEach((i, index) => { a += (+i/Math.pow(2, index+1))});
// a >> 0.30000000000000004
到这里, 0.1 + 0.2 === 0.30000000000000004
的
以上论述过程仍有一些疑惑之处
- 为什么小数转化为二进制后, 52位以后就超过精度了?
这些都与64位双精度浮点数是如何存储的有关, 我们放到最后再说。
Why 0.57 * 100 === 56.99999999999999 ?
Why 0.57 * 1000 === 570 ?
阅读完上面一节, 对小数的乘法我们也可以有一些自己的猜测了。
0.57这个数值在存储时, 本身的精度不是很准确, 我们用toPrecision
这个方法可以获取小数的精度。
0.57.toPrecision(55)
// "0.5699999999999999511501869164931122213602066040039062500"
作者最初的想法有点愚蠢, 0.57
的实际值是0.56999..
, 那0.57 * 100
也就是0.56999... * 100
, 那结果就是56.99999999999999
啦。
而此时, 路总问了我一个问题, 为什么0.57 * 1000 === 570
而不是 569.99999...
, 不求甚解的我只能先回答”应该是精度丢失吧”
然而, 我”小小的眼睛里充满了大大的疑惑”…
后来想了下, 其实我们都知道, 计算机的乘法实际上是累加计算, 并不是我们想的按位相乘。
// 伪代码
(0.57) * 100
= (0.57) * (64 + 32 + 4)
= (0.57二进制) * (2^6 + 2^5 + 2^2)
= 0.57二进制 * 2^6 + 0.57二进制 * 2^5 + 0.57 * 2^2
由于精度丢失, 这个是真的丢失啦, 在二进制转十进制时, 结果就是56.99999…了
同理, (0.57 * 1000)
也不是简单的乘, 也是累加起来的, 只是最后精度丢失时舍0进1, 结果就是570而已。
解决问题
对于大部分业务来讲, 确定数字精度后, 使用Math.round
就可以了。 例如本文最初遇到的BUG
const value = Math.round(0.57 * 100);
而我们不太确定精度的浮点数运算, 通用的解决方案都是将小数转化为整数, 进行计算后, 再转化为小数就好了。
以下是引用[1]
/**
* 精确加法
*/
function add(num1, num2) {
const num1Digits = (num1.toString().split('.')[1] || '').length;
const num2Digits = (num2.toString().split('.')[1] || '').length;
const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
return (num1 * baseNum + num2 * baseNum) / baseNum;
}
当然已经有成熟的工具库可以使用了, 例如Math.js
, BigDecimal.js
, number-precision
等等, 使用哪个任君挑选
IEEE754标准下的浮点数存储
其实下面这段内容来自于Wiki
64位如图进行划分
第0位: 是符号的标志位 第1-11位: 指数位 第12-63位: 尾数
以0.1
为例, 0.1
的二进制是0.00011001100110011001100110011001100110011001100110011001100...
那么, 首先, 该数是正数, 标志位 sign = 0
其次, 将小数转化为科学计数法, 指数位-4即exponent = 2 ^10 - 4 = 1019
1.1001100110011001100110011001100110011001100110011001100... * 2^-4
由于科学计数法, 第一个数始终是1, 所以可以忽略存储, 只要存后面的52位就可以了
如果超过了52位, 就是对第53位舍0进1, 结果也就是100110011001100110011001100110011001100110011001101
了。
Double精度的浮点数存储大概就是这个样子了, 这也解答了上述的疑惑。