toFixed 方法是一个常用于将数字转换为字符串并限制小数部分精度的JavaScript内置方法。然而,尽管它在许多情况下非常实用,但也存在一些潜在问题,尤其是在处理浮点数时。这些问题的根源在于JavaScript使用的是 IEEE 754 浮点数表示法,这种表示法并不总能够完全准确地表示所有可能的小数值。这可能导致toFixed在某些情况下生成不符合预期的结果。

什么是前端数值精度问题?

前端数值精度问题指的是在 JavaScript 中处理数值时可能会引发的精度损失或不准确性。这是因为 JavaScript 使用标准的 IEEE 754 浮点数表示法,这种表示法无法精确表示所有的小数。例如,让我们来看一个简单的示例:

1
2
var result = 0.1 + 0.2;
console.log(result); // 输出 0.30000000000000004,而不是 0.3

在这个示例中,我们期望得到 0.3,但由于浮点数表示的限制,结果变成了一个接近 0.3 的值,但不完全相等。这种情况可能会导致不准确的计算结果,特别是在处理金融数据或其他需要高精度的应用中。

前端数值精度遇到的问题

最近项目中遇到了一个数值精度的挑战,特别是在使用 toFixed() 方法时出现了一些令人困惑的结果。举个例子,当我尝试对 2.55 使用 toFixed(1) 时,期望得到的结果应该是 2.5,但实际上却是 2.5。为了解决这个问题,我开始进行一些研究,特别关注了 toFixed() 方法的工作原理。

在我的调研中,我发现了一些有趣的事情。首先,网上有一种说法是 toFixed() 方法使用的是银行家算法,这个算法涉及到四舍五入,但有五种情况来处理舍入,而只有四种情况用于舍弃小数位,因此 5 需要根据具体情况来决定是舍去还是进位。然而,我后来发现这并不是真正的原因。

实际上,问题的根本在于计算机在处理小数时使用了二进制表示法。计算机存储数据时,整数的存储通常没有问题,因为整数可以完全转换为二进制表示。但是,小数的二进制表示可能会引发精度问题。以 0.1 为例,它的二进制表示是一个无限循环的小数,类似于 0.00011001100110011…,但由于计算机存储的限制,它只能存储有限的位数。

因此,当我们使用 toFixed() 方法时,计算机会对这个无限循环的二进制小数进行近似处理,这就是为什么我们会看到结果不精确的原因。这个近似处理是计算机内部为了满足有限存储能力而做出的权衡。

所以,要解决这个问题,我们需要明白在使用 toFixed() 或其他数值操作时,可能会遇到的精度限制,并采取适当的方法来处理。在一些关键的场景中,可能需要考虑使用更高精度的数学库,以确保精确性和可靠性。这个经验教训也提醒我们在前端开发中要特别小心处理数值精度,特别是在金融领域或其他需要高精度计算的应用中。

如下图:

现在,你可能已经更清楚为什么在将 2.55 保留一位小数时结果是 2.5 而不是 2.6 了。这是因为计算机在进行二进制数值存储时会涉及进位和舍去操作。

这也解释了为什么在某些情况下,例如 0.1 + 0.2 并不等于 0.3。原因在于 0.1 和 0.2 本身就是不精确的二进制表示,当它们相加时,存在微小的误差。计算机无法确定你是否希望得到精确的 0.3 结果,因此它保持了这些微小误差。然而,同样不精确的数字在加法操作中可能会出现正确的结果,比如 0.2 和 0.3 相加。

这是为什么呢?因为计算存储的时候有进有啥,一个进一个舍两个相加就抵消了。

这个现象反映了在计算机中处理浮点数时,需要小心处理精度问题,尤其是在需要高度精确性的应用中。对于精确计算需求,我们可能需要考虑使用专门的数学库或采用其他策略来处理数值精度问题。

如下图:

解决前端数值精度问题的方法

为了解决前端数值精度问题,我们可以采取一系列方法和技术。下面我们将详细介绍其中一些方法,并提供相应的示例代码。

1. 重写 toFixed 方法

具体的方法是将字符串与整数进行运算,因为整数的存储在计算机中是精确的,既然 Number.prototype.toFixed() 的舍入方法并不是我们需要的,那么我们可以直接将其重写成符合的即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Number.prototype.toFixed=function (d) { // d是小数保留的位数
var s=this+"";
if(!d)d=0;
if(s.indexOf(".")==-1)s+=".";
s+=new Array(d+1).join("0");
if(new RegExp("^(-|\\+)?(\\d+(\\.\\d{0,"+(d+1)+"})?)\\d*$").test(s)){
var s="0"+RegExp.$2,pm=RegExp.$1,a=RegExp.$3.length,b=true;
if(a==d+2){
a=s.match(/\d/g);
if(parseInt(a[a.length-1])>4){
for(var i=a.length-2;i>=0;i--){
a[i]=parseInt(a[i])+1;
if(a[i]==10){
a[i]=0;
b=i!=1;
}else break;
}
}
s=a.join("").replace(new RegExp("(\\d+)(\\d{"+d+"})\\d$"),"$1.$2");

}if(b)s=s.substr(1);
return (pm+s).replace(/\.$/,"");
}return this+"";

}

或者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export function toFixed(num, d = 2) { // num是需要转换的数值,d是小数保留的位数
let numSplit = num.toString().split('.');
if (numSplit.length == 1 || !numSplit[1][d] || numSplit[1][d] <= 4) {
return num.toFixed(d);
}
function toFixed(num, d = 2) {
let numSplit = num.toString().split(".");
if (
numSplit.length == 1 ||
!numSplit[1][d] ||
numSplit[1][d] <= 4
) {
return num.toFixed(d);
}
numSplit[1] = (+numSplit[1].substring(0, d) + 1 + "").padStart( d,0);
if (numSplit[1].length > d) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, d + 1);
}
return numSplit.join(".");
}
if (numSplit[1].length > d) {
numSplit[0] = +numSplit[0] + 1;
numSplit[1] = numSplit[1].substring(1, d + 1);
}
return numSplit.join('.');
}

2. 使用第三方库

在处理需要高精度的数学运算时,可以考虑使用第三方库,例如BigNumber.js。这些库提供了更高精度的数字表示和数学运算,使你能够更可靠地处理精确计算。

推荐的第三方库,自行查阅:
math.js
big.js
bignumber.js
decimal.js

下面以BigNumber.js为例:

1
2
3
4
5
6
7
// 使用 BigNumber.js 示例
const BigNumber = require('bignumber.js');

const num1 = new BigNumber(0.1);
const num2 = new BigNumber(0.2);
const result = num1.plus(num2);
console.log(result.toString()); // 输出 "0.3"

3. 避免累加小数

另一种解决数值精度问题的方法是尽量避免累加小数。在某些情况下,你可以将小数转换为整数,并在最后再将结果转回小数。这可以减少精度问题的发生。

1
2
3
4
5
6
7
8
function addDecimals(num1, num2) {
const multiplier = 100; // 选择一个适当的倍数
const result = (num1 * multiplier + num2 * multiplier) / multiplier;
return result;
}

var result = addDecimals(0.1, 0.2);
console.log(result); // 输出 0.3

这个示例中,我们将小数转换为整数,执行累加操作,然后再将结果还原为小数。

4. 累加和减小小数

在一些场景中,你可能需要多次累加或减小小数,这可能导致累积的精度问题。在这种情况下,你可以使用一个累加器来跟踪结果,并将小数累加到整数上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function accumulateDecimals(numbers) {
let accumulator = 0;
const precision = 2; // 指定小数精度

for (const num of numbers) {
accumulator += Math.round(num * Math.pow(10, precision));
}

const result = accumulator / Math.pow(10, precision);
return result;
}

const values = [0.1, 0.2, 0.3];
const sum = accumulateDecimals(values);
console.log(sum); // 输出 0.6

这个示例中,我们使用一个累加器accumulator来追踪结果,并在最后将其转换回小数。

5. 谨慎处理百分比和比率

在处理百分比和比率时,也要格外小心。将它们转换为小数,确保正确的精度,并在需要时进行逆向转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function calculatePercentage(decimal, precision = 2) {
const percentage = (decimal * 100).toFixed(precision) + '%';
return percentage;
}

const decimalValue = 0.75;
const percentage = calculatePercentage(decimalValue);
console.log(percentage); // 输出 "75.00%"

function convertPercentageToDecimal(percentage) {
const decimal = parseFloat(percentage) / 100;
return decimal;
}

const percentageValue = "75.00%";
const decimalEquivalent = convertPercentageToDecimal(percentageValue);
console.log(decimalEquivalent); // 输出 0.75

这个示例中,我们编写了两个函数,一个用于计算百分比,另一个用于将百分比转换回小数。

6. 使用 Math 库

JavaScript 的Math库提供了一些有用的方法来处理数值精度问题。例如,Math.round()可以用于四舍五入,Math.floor()可以用于向下取整,Math.ceil()可以用于向上取整,以及Math.max()Math.min()可以用于比较数值。

1
2
3
4
5
6
7
8
function roundToDecimal(number, precision) {
const multiplier = Math.pow(10, precision);
return Math.round(number * multiplier) / multiplier;
}

const num = 0.155;
const rounded = roundToDecimal(num, 2);
console.log(rounded); // 输出 0.16

7. 使用 ES6 新特性

Number.isFinite()

Number.isFinite() 方法用于检查给定的数值是否有限(finite)。一个有限数是一个不是 Infinity 或 -Infinity 的数。

1
2
3
4
5
6
7
const num = 42;

if (Number.isFinite(num)) {
console.log("Number is finite.");
} else {
console.log("Number is not finite.");
}

在上述示例中,我们首先声明一个数值 num,然后使用 Number.isFinite() 来检查它是否是有限数。如果是有限数,则输出 “Number is finite.”,否则输出 “Number is not finite.”。

这个方法对于确保数值不是无穷大或负无穷大非常有用,因为无穷大的数值可能会引发不准确的计算或其他问题。

Number.isNaN()

Number.isNaN() 方法用于检查给定的数值是否是 NaN(Not-a-Number)。NaN 表示一个非法的或不可表示的数值。

1
2
3
4
5
6
7
const num = NaN;

if (Number.isNaN(num)) {
console.log("Number is NaN.");
} else {
console.log("Number is not NaN.");
}

在上述示例中,我们声明一个数值 num,然后使用 Number.isNaN() 来检查它是否是 NaN。如果是 NaN,则输出 “Number is NaN.”,否则输出 “Number is not NaN.”。

这个方法对于在处理数值时检测非法或不可表示的数值非常有用,以避免对它们进行不合适的操作。

Number.parseFloat()

Number.parseFloat() 方法用于将字符串解析为浮点数,并返回解析后的浮点数。

1
2
3
4
const str = "3.14";
const num = Number.parseFloat(str);

console.log(num); // 输出 3.14

在上述示例中,我们有一个包含浮点数的字符串 str,然后使用 Number.parseFloat() 将它解析为浮点数。这个方法非常有用,因为它能够处理字符串中的小数点,使得解析后的结果与原始数值保持一致。

这些 ES6 新特性方法 Number.isFinite()Number.isNaN()Number.parseFloat() 提供了更严格和更精确的数值处理工具。它们有助于准确地检查数值的有限性和 NaN 状态,同时还能够正确地解析包含小数点的字符串,以保持数值的精确性。这些方法可以用于许多前端开发场景,特别是在处理用户输入或从外部源获取的数据时,以确保数据的正确性和可靠性。

总结

数值精度问题是前端开发中一个常见的挑战,但我们可以采取多种方法来解决它。无论是使用内置方法如 toFixed,还是使用第三方库如 BigNumber.js,或者编写自定义函数来处理精度,都可以根据具体的需求选择合适的方法。重要的是要了解数值精度问题,并在开发过程中采取适当的措施,以确保数值计算的准确性和可靠性。希望这篇文章对你理解和解决前端数值精度问题有所帮助。