本文作者为 360 奇舞团前端开发工程师
在使用 JavaScript 处理运算时,有时会碰到数字运算结果不符合预期的情况,比如经典的 0.1 + 0.2 不等于 0.3。当然这种问题不只存在于 JavaScript,不过编程语言的一些原理大致相通,我以 JavaScript 为例解释这种问题,并说明前端如何尽可能保证数字精确。
let a = 0.1,b=0.2,c=0.3
console.log(a + b === c) //false
1. 计算机数字如何存储
理解类似问题的基础,首先要理解计算机数字的处理方式。计算机的一切信息都是二进制,数字也不例外,所有数字都是一段二进制。
在 JavaScript 中存储数字的二进制有 64 位,即我们常说的 64 位双精度浮点型数字。每个数字对应的 64 位二进制分为三段:符号位、指数位、尾数位。
其中符号位在六十四位的第一位,0 表示正数,1 表示负数。符号位之后的 11 位是指数位,决定了数字的范围。指数位之后的 52 位是尾数位,决定了数字的精度。
在 JavaScript 中,双精度浮点型的数转化成二进制的数保存,读取时根据指数位和尾数位的值转化成双精度浮点数。
比如说存储 8.8125 这个数,它的整数部分的二进制是 1000,小数部分的二进制是 1101。这两部分连起来是 1000.1101,但是存储到内存中小数点会消失,因为计算机只能存储 0 和 1。
1000.1101 这个二进制数用科学计数法表示是 1.0001101 * 2^3,这里的 3 (二进制是 0011)即为指数。
可使用如下代码查看:
function getBinaryRepresentation(number) {const buffer = new ArrayBuffer(8); // 创建一个包含8字节的 ArrayBufferconst view = new DataView(buffer); // 创建一个DataView以便访问内存中的数据view.setFloat64(0, number); // 将浮点数写入到内存中// 读取内存中的字节,并将其转换为二进制字符串const binaryString = Array.from(new Uint8Array(buffer)).map(byte => byte.toString(2).padStart(8, '0')).join('');// 将二进制字符串分割为符号位、指数位和尾数位const signBit = binaryString[0];const exponentBits = binaryString.substring(1, 12);const mantissaBits = binaryString.substring(12,64);return { signBit, exponentBits, mantissaBits };
}const number = 8.8125; // 要展示的数字
const { signBit, exponentBits, mantissaBits } = getBinaryRepresentation(number);console.log(`符号位: ${signBit}`);
console.log(`指数位: ${exponentBits}`);
console.log(`尾数位: ${mantissaBits}`);符号位: 0
指数位: 10000000010
尾数位: 0001101000000000000000000000000000000000000000000000
现在我们很容易判断符号位是 0,尾数位就是科学计数法的小数部分 0001101。指数位用来存储科学计数法的指数,此处为 3。指数位有正负,11 位指数位表示的指数范围是 -1023~1024,所以指数 3 的指数位存储为 1026(3 + 1023)。
可以判断 JavaScript 数值的最大值为 53 位二进制的最大值:2^53 -1。
PS:科学计数法中小数点前的 1 可以省略,因为这一位永远是 1。比如 0.5 二进制科学计数为 1.00 * 2^-1。
2. 为什么会产生小数精度问题
首先补充一下小数的二进制的计算方法:
十进制小数转为二进制与整数相反,需要每次乘以 2
8.8125
o.8125*2 = 1.625 => 1
0.625*2 = 1.25 =>1
0.25*2 = 0.5 =>0
0.5*2 = 1 =>1
小数部分为 1101
二进制小数转为十进制
1*2^-1 + 1*2^-2 + 0*2^-3 + 1*2^-4
在了解数字的存储后,很容易理解小数精度问题,因为十进制有 Π 这种无限循环数字,二进制也有循环数字。比如让 0.1 变为二进制,按照二进制转换永远会有余数,所以会是一个无限循环的二进制 0.0001 1001 1001 1001...(1100循环)。0.2 也是同理 0.0011 0011 0011 0011...(0011循环)。
所以当两个浮点数相加时,结果会有一些误差。比如 0.1 + 0.2 ,实际上是 0.0001 1001 1001...(1001循环) + 0.0011 0011 0011...(0011循环),如果截取于第 52 位,就会得到一个有误差的结果,转为十进制为0.30000000000000004,与 0.3 不相等。
3. 前端如何保证小数准确
首先出于安全性及准确性考虑,重要的数字计算应该交给服务端负责,相对于前端,服务端有更成熟稳定的数字处理方法,安全性也会更高。
当然前端有时也需要一些精确的数字计算,比如一些动画处理、定时器处理以及一些条件判断等。我简单列举几种方法供大家参考:
toFixed 指定小数位数 这种方法比较简单,不过有个点要注意,这个方法是四舍五入,但有时候看上去并不会,比如 2.55.toFixed(1) 显示的结果是 2.5 而不是 2.6。这是因为 2.55 二进制存储的值并不精确,调用 2.55.toPrecision(100) 可以看到这个数的实际值是 2.5499..... ,所以截取一位四舍五入是 2.5。再举一个例子 (2.449999999999999999).toFixed(1) = 2.5,因为这个数与 2.45 的差值小于 Number.EPSILON。
将小数转为整数计算 这个方法的问题是转换会增加额外的复杂度和计算量,在某些场景下,可能会导致数值溢出问题。
第三方库 精确计算推荐使用成熟的库,像 BigNumber.js、decimal.js ,进行高精度的浮点数计算。原理是把数字计算变为字符串计算。
JavaScript 的计算比较复杂,由于没有细分数字类型,底层计算以二进制进行,存储值、计算值都有可能因为精度丢失而不准确,而显示值可能会因为浏览器等宿主环境不同而有差别,所以一定要注意经常产生精度丢失的地方。
- END -
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。