专业的编程技术博客社区

网站首页 > 博客文章 正文

如何使用 Fraction.js 解决 BigInt 的计算盲区?

baijin 2025-07-10 13:05:44 博客文章 2 ℃ 0 评论

家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。

1. 无法绕过的 JavaScript 数字精确度

当浮点数精度不够时,如何在 JavaScript 中处理精确的十进制计算?尤其是在牵涉到处理钱、百分比或任何需要精确的计算时。

//  1.基本算术问题不准确
0.1 + 0.2; // 输出 0.30000000000000004
1.0 - 0.9; // 输出 0.09999999999999998
0.7 + 0.1; // 输出 0.7999999999999999
//   2.除法问题不准确
1 / 3; // 0.3333333333333333
1 / 6; // 0.16666666666666666
//  乘法问题不准确
0.3 / 0.1; // 2.9999999999999996
(1 / 98) * 98; // 0.9999999999999999
//  货币计算不准确
19.99 * 0.1; // 1.9989999999999999

以上计算并非 JavaScript 语言错误,而是计算机存储十进制数的基本限制,其影响几乎所有编程语言。计算机以二进制方式存储数字,虽然这对整数非常有效,但却会产生小数问题。

在十进制系统中,开发者可以将 1/10 表示为 0.1。但在二进制中,0.1 是一个循环分数,就像试图用十进制表示 1/3(0.333333……)一样。当以 JavaScript 的 64 位浮点格式(IEEE 754)存储时则必须被截断,从而导致微小的舍入误差。

虽然 BigInt 一定程度上可以解决这个问题(不处理小数),但也有自己的局限性:

// BigInt 仅适用于整数
BigInt(1) / BigInt(3);
// BigInts不能用于除法而直接报错
BigInt(0.1);
// 不能将小数转化为 BigInt

从这点来看,BigInt 虽然可以解决整数的精度问题,但却无法处理小数。实际上,其通常用于加密或非常大的整数计算。因此,一种常见的解决方法是将所有数乘以 100 以完全避免小数:

// 常见方法:乘以倍数去除小数
const dollars = 19.99 * 100;
// 1999 cents
const tax = 8.5 * 100;
// 850 basis points
// Still breaks down
console.log((dollars * tax) / 10000);
// 169.91499999999996 还是会有舍入错误

该方法看起来很简单,但如果计算复杂或操作繁多,就会失效。

2.Fraction.js 使用分数而非小数

强烈推荐开发者使用 Fraction.js 来解决精度问题,这是一个将数字作为分数而不是浮点小数处理的库,其认为 1/3 永远比 0.333333 更精确。

Fraction.js 支持使用 BigInt 表示分子和分母,确保在提高准确性的同时最大程度地降低性能开销。其设计针对精度进行了优化,使其成为其他数学工具,例如: Polynomial.js 和 Math.js 的强大替代品。

import Fraction from "fraction.js";

// 基础数学运算是准确的
new Fraction(0.1).add(0.2).toString(2); // "0.30"
new Fraction(1.0).sub(0.9).toString(2); // "0.10"
new Fraction(0.7).add(0.1).toString(1); // "0.8"
// 指定精度的除法运算
new Fraction(1, 3).toString(2); // "0.33"
new Fraction("1/6").toString(2); // "0.17"
// 货币计算也是准确的
const price = new Fraction("19.99");
const tax = new Fraction("0.085"); // 8.5% tax
const total = price.add(price.mul(tax));
total.toString(2); // "21.69"
// 对于分数则用需要实际的分数表示
new Fraction("0.333333").toFraction(); // "1/3"
new Fraction("1.4166666").toFraction(); // "17/12"

Fraction.js 不会尝试用二进制表示小数(因为舍入误差),而是将每个数字存储为整数的比率,例如:0.1 在内部变为 1/10。

const C_ZERO = BigInt(0);
const C_ONE = BigInt(1);
//  已经简化
const parse = function (p1, p2) {
  let n = C_ZERO,d = C_ONE, s = C_ONE;
    if (p1 % 1 === 0) {
      n = BigInt(p1);
    } else if (p1 > 0) {
      let z = 1;
      let A = 0,B = 1;
      let C = 1,D = 1;
      let N = 10000000;
      if (p1 >= 1) {
        z = 10 ** Math.floor(1 + Math.log10(p1));
        p1 /= z;
      }
      // Using Farey Sequences
      while (B <= N && D <= N) {
        let M = (A + C) / (B + D);
        if (p1 === M) {
          if (B + D <= N) {
            n = A + C;
            d = B + D;
          } else if (D > B) {
            n = C;
            d = D;
          } else {
            n = A;
            d = B;
          }
          break;
        } else {
          if (p1 > M) {
            A += C;
            B += D;
          } else {
            C += A;
            D += B;
          }
          if (B > N) {
            n = C;
            d = D;
          } else {
            n = A;
            d = B;
          }
        }
      }
      n = BigInt(n) * BigInt(z);
      d = BigInt(d);
    }
  }
  P["s"] = s < C_ZERO ? -C_ONE : C_ONE;
  P["n"] = n < C_ZERO ? -n : n;
  P["d"] = d < C_ZERO ? -d : d;
};
function Fraction(a, b) {
  parse(a, b);
  return newFraction(P["s"] * P["n"], P["d"]);
}

从 Fraction.js 的构造函数来看,其内部使用了 Farey 序列的数学概念。在给定的正整数 n 下,所有分子和分母均不超过 n 的最简分数的有序集合。

因此,parse 函数的主要目的是将给定的数字 p1 转换为一个最简的分数形式,确保分子和分母都是大整数,并处理正负符号。函数可用于需要将实数转换为分数的应用场景,例如:数值计算、比例表示等。

下面是 Fraction.js 的 add 、mul 方法的具体实现:

function mul(a, b) {
  parse(a, b);
  return newFraction(
    this["s"] * P["s"] * this["n"] * P["n"],
    this["d"] * P["d"]
  );
}
function add(a, b) {
  parse(a, b);
  return newFraction(
    this["s"] * this["n"] * P["d"] + P["s"] * this["d"] * P["n"],
    this["d"] * P["d"]
  );
}

对于 add 来说,其内部依然采用的是整数的运算法则,而非采用小数:

  • this["s"] * this["n"] * P["d"]:表示 a 的分数形式与 b 的分母相乘
  • P["s"] * this["d"] * P["n"]:表示 b 的分数形式与 a 的分母相乘。

这个加法公式实际上是通过通分(将两个分数转化为相同的分母)来实现的。

3.Fraction.js 的诸多限制

Fraction.js 功能虽然强大,但并非万能,其仅适用于有理数,即可以表示为整数比率的数字,例如:如 3/4 或 22/7 等。这使得其非常适合货币计算或分数,但对于无理数(如 π 或 √2)则不太适用。

同时, Fraction.js 在内部使用 BigInt,因此可以获得比常规小数更高的精度(注意不是无限精度)。但是某些运算也有限制,例如:平方根给出的是近似值而不是精确值,并且不支持 sin 或 cos 等函数,当然也包括复数。

即使有诸多限制,Fraction.js 仍然非常有用。开发者可以使用它来提前计算精确值,处理没有舍入误差的货币计算,构建与分数一起使用的软件,或解决小数精度真正重要的问题。

参考资料

https://www.trevorlasn.com/blog/fraction-numbers-in-javascript

https://github.com/rawify/Fraction.js

https://zhuanlan.zhihu.com/p/28162086

https://larrylu.dev/why-01-02-03-a-deep-dive-into-ieee-754-and-floating-point-arithmetic

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表