前言
相信各位前端小伙伴在日常工作中不免会涉及到使用 JavaScript
处理 数值 相关的操作,例如 数值计算、保留指定小数位、接口返回数值过大 等等,这些操作都有可能导致原本正常的数值在 JavaScript
中确表现得异常(**即 精度丢失
),这也是被很多开发者诟病的一点(),当然包括很多 后端开发者(~~你该不会还没踩过坑吧!
不止一次的被问到这个问题
**~~)。
本文主要包含 精度丢失场景、精度丢失原因、解决方案 等方面的内容,文中若有不正确的地方欢迎在评论区分享你的见解。
精度丢失场景
浮点数的计算
数值计算在前端的应用还不算少,但涉及 浮点数 参与计算时可能会出现精度丢失,如下:
加( + )
- 正常计算:0.1 + 0.2 = 0.3
-
JavaScript
计算:0.1 + 0.2 = 0.30000000000000004
减( - )
- 正常计算:1 - 0.9 = 0.1
-
JavaScript
计算:1 - 0.9 = 0.09999999999999998
乘( * )
- 正常计算:0.0532 * 100 = 5.32
-
JavaScript
计算:0.0532 * 100 = 5.319999999999999
除( / )
正常计算:0.3 / 6 = 0.05
JavaScript
计算:0.3 / 6 = 0.049999999999999996超过最值
所谓 超过最值(
最大、最小值
) 指的是超过了Number.MIN_SAFE_INTEGER(- 9007199254740991)
,即 +(2^53 – 1) 或Number.MAX_SAFE_INTEGER(+ 9007199254740991)
,即 -(2^53 – 1) 范围的值,项目中最常见的就是如下几种情况:后端返回的数值超过最值
- 例一,后端返回的列表数据,通常都会有相应的
ID
来标识唯一性,但后端字啊生成这个ID
时是 Long 类型,那么该值很可能就会超过JavaScript
中能表示的最大正整数,此时就导致精度丢失,即前端实际获取到的ID
值和后端返回的将不一致 - 例二,后端可能需要将一些值通过计算之后,把对应的结果值返回给前端,此时若该值超过了 最值,那么也会产生精度丢失
- 例一,后端返回的列表数据,通常都会有相应的
前端进行数值计算时,计算结果超过最值
保留指定小数位
除了上述对涉及浮点数计算、超过最值的场景之外,我们通常还会对数值进行保留指定小数位的处理,而部分开发者可能会直接使用 Number.prototype.toFixed 来实现,但这个方法却并不能保证我们期望的效果,例如保留小数位时需要进行 四舍五入 时就会有问题,如下:
1 | console.log(1.595.toFixed(2)) // 1.59 ——> 期望为:1.60 |
精度丢失的原因
计算机内部实际上只能 存储/识别 **二进制
**,因此 文档、图片、数字 等都会被转换为 二进制,而对于数字而言,虽然我们看到的是 十进制 的表示结果,但实际上会底层会进行 十进制 和 二进制 的相互转换,而这个转换过程就有可能会出现 精度丢失,因为十进制转二进制后可能产生 无限循环 部分,而 实际存储空间是有限的。
IEEE 754 标准
Javascript
中的数字存储使用了 IEEE 754 中规定的 双精度浮点数 数据类型,双精度浮点数使用 64 位(8 字节)
来存储一个 浮点数,可以表示二进位制的 53 位
有效数字,即 (0-52 位为 1)
111…111 = (53 位为 1,0-52 位为 0)
1000…000 - 1,也就是 2^53 - 1,而这也就是 JavaScript 中 Number.MAX_SAFE_INTEGER(+ 9007199254740991)
对应的值。
双精度浮点数的组成
双精度浮点数(double)
由如下几部分组成:
sign
符号位,0 为正,1 为负- 占 1bit,在 63 位
exponent
指数部分,表示 2 的几次方- 占 11bit,在 52-62 位
- 指数 采用 偏移码表示法,即将 指数的真实值
e
加上一个 偏移量,然后得到 阶码(即计算结果)并将其表示为 二进制数 - 其中 偏移量 =
(2^n-1) - 1
,n 是 指数的位数(即n = 11
),因此偏移量为Math.pow(2, 11-1) - 1 = 1023
- 阶码
E
= 指数真值e
+ 偏移码(2^n-1) - 1
mantissa
尾数部分,表示浮点数的精度- 占 52bit,在 0-51 位
- 尾数采用 隐式 的方式表示,即在尾数的 最高位上 总是隐含着一个 1,并且隐藏在 **小数
点的左边** (即 1 < 尾数 < 2),因此尾数的有效位数为 53 位,而不是 52 位
十进制浮点数的存储过程
有了上面的公式,接下来我们来演示一下一个十进制浮点数是如何以 双精度浮点数 的形式被存储到计算机中的,其大致分为如下两步:
十进制转二进制
- 分别对整数部分和小数部分的十进制转化为二进制
求出 sign、exponent、mantissa 的值
下面我们通过 263.3 这个数值来演示。
十进制转二进制
分别将 263.3 的 整数部分 263 和 小数部分 0.3 转为对应的 二进制数,这里你可以使用便捷的 在线转换工具
,也可选择手动计算:
整数部分 转 二进制
- 一直 除以 2 直到余数为 0 或 出现循环,然后 从下往上 将每次的余数进行组合即可
小数部分 转 二进制
- 一直 乘以 2 直到乘积为 1 或 出现循环,然后 从上往下 将每次的乘积的 整数位 进行组合即可
[图片上传失败…(image-9ce327-1698853218630)]
最终得到的结果就是 263.3(
10
) 对应的 二进制 为 100000111.010011001…求出 sign、exponent、mantissa 的值
求 sign
- 其中 sign 为符号位,且 263.3(
10
) 为正数,因此 sign = 0
- 其中 sign 为符号位,且 263.3(
求 exponent
- 根据公式 (-1)^S
x
(1. M)x
2^(E-1023) 可知,其中的 尾数 要符合 1. M 的形式,因此 100000111.010011001… 中小数点需要往左移动 8位 变成 1.00000111 010011001 … - 其中的 8 就是 指数真值,但在实际存储时是存 阶码的二进制,根据公式 阶码 = 指数真值(8) + 偏移量(1023),即 阶码 = 1031,所以 exponent 值就为 1031 的二进制:10000000111
- 根据公式 (-1)^S
求 mantissa
- 根据上一步的 1.00000111 010011001 … 很容易知道尾数 mantissa = 00000111 010011001 …
最终存储形式
Number.prototype.toFixed 的舍入
关于这个方法的舍入方式,目前最多的说法就是 银行家算法 ,的确在大多情况下确实能够符合 银行家算法 的规则,但是部分情况就并不符合其规则,因此严格意义上来讲 Number.prototype.toFixed 并不算是使用了 **银行家算法
**,如果你要问为什么,请看 ECMAScript® 2024 Language Specification (tc39.es)
,在下文都会提及。
银行家算法
所谓银行家算法用一句话概括为:
四舍六入五考虑,五后
有数 就进一
,五后
无数 看 奇偶
,五前
为偶当 舍去
,五后
为奇要 进一
四舍 指保留位后的 数值 < 5 应
舍去
,4 只是个代表值六入 指保留位后的 数值 > 5 应
进一
,6 只是个代表值若保留位后的 数值 = 5,看 5 后 是否有数
若 5 后 无数,则看 5 前 的数值的 奇偶 来判断
- 若 5 前 的数值为 偶数,则 舍去
- 若 5 前 的数值为 奇数,则 进一
若 5 后 有数,则 进一
用例子来验证一下:
1 | // 四舍 |
1 | // 五后 有数,应进一 |
ECMAScript 定义的 toFixed 标准
一眼望上去是不是觉得看不懂,那么这里就来尝试解释一下这个标准的内容吧()!掺杂个人理解
让 x = 目标数字,如:**
(1.145).toFixed(2)
** 中x = 1.145
让 f = 参数,如:**
(1.145).toFixed(2)
** 中f = 2
若 **f =
undefined
**,即 未传参,则将 f = 0若 **f =
Infinite
**,即传入了 无穷值,则抛出 RangeError 异常若 **f <
0
或 f >100
**,即传入了不在0 - 100
之间的值,则抛出 RangeError 异常若 **x =
Infinite
**,即想要对 非准确值 保留位操作,则返回其 字符串形式- 例如,**
Infinity.toFixed(2) = 'Infinity'
、NaN.toFixed(2) = 'NaN'
**
- 例如,**
让 x = 计算机所能表示的数学值 ℝ(x)
- 从 数字 或 BigInt x 到 数学值 的转换表示为 **
x 的数学值
**,或ℝ(x)
- 从 数字 或 BigInt x 到 数学值 的转换表示为 **
让 **返回值符号 s = ‘’**,即为符号定义 初始值
若 **x <
0
**,则将 **s ='-'
**,并将 x =-x
若 **x ≥
10^21
**,则 返回值 m =x
对应的科学计数法
表示的字符串
若 **x <
10^21
**,则
a. 让 **n = `一个整数`**,其中 **`n / 10^f - x`** 尽可能接近于 **`0`**,如果有两个这样的 **n**,选择 **较大的 n**
b. 若 **n = `整数 0`**,则 **m = `"0"`**,否则,**m = 由 `n` 的 `十进制` 表示形式的数字组成的 `字符串值(按顺序,不带前导零)`**
c. 若 **指数 f ≠ `0`**,则 **k = `m.length`**
* 若 **k ≤ f**,则
* **z = 由代码单元 `0x0030(DIGIT ZERO)` 的 `f+1-k` 次出现组成的 `字符串`**
* **m = `z + m`**
* **k = `f + 1`**
* 让 **a = `m` 的第一个 `k-f` 码单元**
* 让 **b = `m` 的其它 `f` 个编码单元**
* 将 **m = `a + "." + b`**
- 返回 s + m 组成的字符串
看不懂?那就挑懂的地方看
不多说了,还是用 (1.125).toFixed(2) = 1.13 举个栗子吧!
- 根据上述规范初始 x = 1.125,f = 2,s = ‘’
- 根据规范 7 可知 x =
1.125.toPrecision(53)
= 1.125
根据规范 11.a 提供的公式:**
n / 10^f - x ≈ 0
** 代入计算:**n ≈ 112.5
**:- 此时最接近
n
的 整数 有 两个 值为110
和 **112
**,按标准取最大的113
- 在按 11.c 的规范得到
m = 1.13
- 此时最接近
最终返回
s + m= 1.13
还不会,再来个 (-1.105).toFixed(2) = -1.10 的栗子吧!
根据上述规范初始 x = 1.105,f = 2,s = ‘-‘
根据规范 7 可知 x =
(-1.105).toPrecision(53)
= 1.10499…根据规范 11.a 提供的公式:**
n / 10^f - x ≈ 0
** 代入计算:**n ≈ 110.4...
**:- 此时最接近
n
的 整数 只有 一个 值为 **110
**(因为只有小数点后为 5 时,向上 / 向下 取整才会有两种情况) - 在按 11.c 的规范得到
m = 1.10
- 此时最接近
最终返回
s + m= -1.13
如何解决前端数值的精度问题?
虽然知道了 精度丢失 的原因,也知道了 toFixed 舍入 的逻辑,但是实际上在进行计算时,我们还是希望按照实际看到的数值来进行计算或舍入,而不是底层转换过的值。
使用第三方库
需要的自行查阅:
思路扩展
浮点数计算
浮点数在 JavaScript
中经底层转换后可能会有精度丢失,但是 安全范围内的整数 却不会丢失,那么我们就可以先将 浮点数 转成 整数 进行计算后,再将计算结果成为浮点数。
以 0.1 + 0.2 = 0.30000000000000004 举个例子,如下:
- 原式:0.1 + 0.2 = x
- 扩大
10
倍:**0.1 10 + 0.2 10 = 10 * x - 变式:10 * x = 3
- 结果:x = 0.3
超过最值
前面提到的 后端返回 或 前端计算 产生的超过 安全范围的值,我们可以使用 BigInt 来处理,这是新增的原始值类型,它提供了一种方法来表示 大于 2^53 - 1
的整数。
【**
注意
**】这里的 BigInt 可不能用来处理后端返回超过安全范围内的值(如 id),因为当我们需要再将这些值转为 BigInt 之前就已经发生了精度丢失,所以在进行转换是无意义的,此时最好的方式就是接口以 字符串 的形式返回对应的值
保留指定小数位
既然 Number.prototype.toFixed() 的舍入方法并不是我们需要的,那么我们可以直接将其重写成符合的即可,例如:
1 | Number.prototype.toFixed=function (d) { |