前端知库

怎样手写大数相加?为什么 JS 的大整数会算错?

JS 的 Number 是双精度浮点数,超过 Number.MAX_SAFE_INTEGER(2^53 - 1)的整数无法精确表示,所以接口里的长 ID、金额要用字符串传输。手写大数相加就是模拟竖式加法,从末位逐位相加并处理进位;现代环境也可以直接用 BigInt。

参考答案

简答

后端返回一个订单 ID 9007199254740993,前端拿到后变成了 9007199254740992,这就是大数精度问题。JS 的数字用 64 位双精度浮点数表示,能精确表示的整数最大是 Number.MAX_SAFE_INTEGER,也就是 2^53 - 1,即 9007199254740991。超过这个值,相邻的整数有些无法区分,就会被舍入到最近的能表示的值。

所以长 ID 这类数据应该由后端以字符串返回。需要计算时,一种做法是手写字符串加法,像小学列竖式一样:两个字符串从最后一位开始,逐位相加,再加上上一位的进位,结果对 10 取余是这一位,除以 10 取整是新的进位;全部加完如果还有进位,前面再补一个 1。

现代浏览器和 Node 都支持 BigInt,写 9007199254740993n 或者 BigInt('9007199254740993'),可以精确表示任意大的整数,直接相加即可。

要注意,BigInt 不能和普通数字混着算,1n + 1 会抛 TypeError,必须先统一类型。JSON.parse 遇到大数字也会先按 Number 解析,精度在进入你的代码之前就已经丢了,所以光有 BigInt 还不够,传输时仍要用字符串。

手写字符串相加

JavaScript
function addStrings(a, b) {
  let i = a.length - 1;
  let j = b.length - 1;
  let carry = 0;
  const digits = [];
  while (i >= 0 || j >= 0 || carry > 0) {
    const sum = (i >= 0 ? Number(a[i]) : 0) + (j >= 0 ? Number(b[j]) : 0) + carry;
    digits.push(sum % 10);
    carry = Math.floor(sum / 10);
    i--;
    j--;
  }
  return digits.reverse().join('');
}

console.log(9007199254740993); // 9007199254740992
console.log(addStrings('9007199254740991', '2')); // 9007199254740993
console.log(addStrings('999', '1')); // 1000
console.log(addStrings('0', '0')); // 0
console.log((BigInt('9007199254740991') + 2n).toString()); // 9007199254740993

第一行直接写数字字面量 9007199254740993,打印出来却是 ...992。这说明精度不是在计算时丢的,而是在把它变成 Number 的那一刻就丢了。

循环条件为什么要包含 carry > 0?看 999 + 1:三位都加完以后,进位还是 1,如果只判断 i、j,最前面那个 1 就丢了,结果会变成 000。

两个字符串长度不同时,短的那个读完以后按 0 处理,所以 i >= 0 ? ... : 0 不能省。结果是从低位往高位 push 的,最后要 reverse 一次。

小数为什么也会算错

0.1 + 0.2 得到 0.30000000000000004,原因类似:0.1 在二进制里是无限循环小数,只能存一个近似值,两个近似值相加,误差就显露出来了。金额计算一般换算成“分”这样的整数来算,或者用专门的十进制库;比较两个小数是否相等时,用差值小于 Number.EPSILON 这类容差来判断。更多边界见《浮点精度、安全整数和 BigInt 有哪些边界?》。

常见追问

接口返回的大数字已经是 Number 了,前端还能救回来吗?

如果用的是默认的 JSON.parse,解析完就已经丢了,救不回来。可以让后端改成字符串,或者用支持大数的 JSON 解析库,比如 json-bigint,在解析时直接读取原始文本。

大数相乘怎么写?

同样模拟竖式:开一个长度为两个数位数之和的数组,第 i 位乘第 j 位的结果累加到第 i + j + 1 位上,最后统一处理进位,再去掉开头多余的 0。