toFixed 方法到底是什么逻辑

最近看到不少关于 toFixed 方法的舍入逻辑的文章,其中有不少文章认为 toFixed 的实现采用的是所谓的银行家算法,也就是四舍六入五成双。也有文章认为是四舍五入。也都给了不少例证。还有些人认为无法确定,主要看浏览器厂商是怎么实现的。为什么会出现这些讨论,主要还是因为这个方法的计算结果有时候真的是让人猝不及防。

toFixed 的问题

直接看几个执行结果感受一下

1
2
1.36.toFixed(1) // 1.4 没有问题
1.31.toFixed(1) // 1.3 没有问题

这种没有卡在 5 上面的都不会出什么问题,四舍六入很正常。问题在于中间这个5。看下面的例子

1
2
1.35.toFixed(1) // 1.4 这里的5入
1.45.toFixed(1) // 1.4 这里的5又舍去了。

有不少人看到这里就认为 toFixed 采用的是四舍六入五成双了,上面这两个结果完全的诠释了出现5的时候,如果前面的数字是奇数,则进1让其成为偶数。如果前面的数字是偶数,直接舍去5

再多看几个例子

1
2
1.15.toFixed(1) // 1.1 并没有进1 成1.2来凑偶数
1.05.toFixed(1) // 1.1 进1之后反倒成奇数了

那 toFixed 在遇到5的时候,到底用的什么逻辑呢,与其用不同的数字去执行 toFixed 查看结果,来验证自己的猜想,不如直接看看在 js 标准中,关于 toFixed 是怎么定义的

toFixed 的实现标准

Screenshot 2023-04-03 at 18.04.38.png

上图是直接从 ECMAScript® 2024 Language Specification 文档中截的图,其中最主要的逻辑是11.a这一步

稍作解释如下:

x 是我们要操作的数,f 是保留的小数位数, n 是在这一步找出的结果,n需要满足的条件是, 整数且使表达式 $ n / 10 ^ f - x $ 最接近于0, 如果这样的数找到2个,取其中较大的那个

对于 1.05.toFixed(1) 这个表达式,x 是 1.05, f 是 1,把 数字代进表达式计算,这个 n 应该是最接近10.5的一个整数,这样的数字有两个,分别是 10 和 11,根据规则最较大的11, 经过后续 11.c 的一些操作,主要就是数值缩放,确定小数点位置。主要功能还是寻找 n 的过程。

通过toFixed的实现逻辑,可以看出来toFixed 采用的是四舍五入的逻辑,在它的规范里没有提到任何取偶数的概念

既然如此,那1.15.toFixed(1) 的结果应该是1.2才对,为什么实际执行结果却是1.1。其实看下面的代码应该就能感受一二了

1
2
1.15.toPrecision(100)
// 1.149999999999999911182158029987476766109466552734375000000000000000000000000000000000000000000000000

1.15 在系统中就不是严格上的 1.15,通过这个高精度表示的数字来看,就知道为什么 1.15.toFixed(1) 的结果为什么是1.1了,因为小数点后第二位根本不是5而且4,所以直接舍去了。

在浏览器控制台执行下面 js 代码验证

1
2
11 / 10 - 1.15 // -0.04999999999999982  更接近于0, 所以取 11
12 / 10 - 1.15 // 0.050000000000000044

疑问

如果你在浏览器控制台去执行下面代码

1
2
3
87.55.toFixed(1) // 87.5
875 / 10 - 87.55 // -0.04999999999999716
876 / 10 - 87.55 // 0.04999999999999716

和0的差距是一样的, 按逻辑 87.55.toFixed(1) 的结果应该是 87.6, 但是实际结果却是87.5。看看高精度表示的87.55可以理解

1
2
87.55.toPrecision(100) 
// 87.54999999999999715782905695959925651550292968750000000000000000000000000000000000000000000000000000

但是这里为什么计算出来的和0的距离为什么不像 1.15 的例子那样呢?

这里可能就涉及到开头说的浏览器在实现 js 引擎的时候用的是什么逻辑了(如果你知道可以评论区解释一下)。但不管浏览器具体采用的是什么逻辑,通过 js 标准以及这些例子,可以确定 toFixed 方法使用的逻辑是四舍五入,至于为什么部分数字不符合预期,是由于精度问题导致了。就好像为什么 0.1 + 0.2 !== 0.3 是一样的

解决方案

在明白问题原因之后,可以采用使用 yatter 中的 toFixed 方法来规避问题

1
2
3
import {toFixed} from 'yatter';

console.log(toFixed(87.55, 1)) // 87.6

或者采用 decimal-format ,提供了更丰富的格式化功能

1
2
3
4
5
6
7
import DecimalFormat from 'decimal-format';

const df = new DecimalFormat('0.0');
df.format(87.55); // 87.6

df.setRoundingMode(RoundingMode.HALF_EVEN); // 设置为 四舍六入五成双 模式
df.format(87.85) // 87.8

还有更多舍入模式,以及输出格式不一一举例说明