JavaScript 的计算精度问题

394 阅读5分钟

背景

大多时候,前端开发人员在进行业务开发的时候,会碰到数据计算的场景。但是,在 JavaScript 中存在计算精度的问题,比如最能体现这点的一个经典问题就是,0.1 + 0.2 !== 0.3。因此,我们在处理数据计算的时候,有时会碰到一些让人特别疑惑的问题,比如后端返回了一个超长的数字,尤其是使用 Long 类型定义的。在使用计算时,突然发现计算的结果不对,不是最后几位为 0,就是计算得不到预想的结果。其实,在这个问题上,前端和后端都是没有问题的,代码也是没问题的。这里涉及到了一个知识盲区,Number安全整数

知识点

安全整数(safe integer)

在 JavaScript 中,能够准确表示的整数范围在 -253 到 253 之间(不含两个端点),超过这个范围,无法精确表示这个值,这使得 JavaScript 不适合进行科学和金融方面的精确计算。 一个安全整数是一个符合下面条件的整数:

  • 可以准确地表示为一个IEEE-754双精度数字,
  • 其IEEE-754表示不能是舍入任何其他整数以适应IEEE-754表示的结果。.

比如,253 - 1 是一个安全整数,它能被精确表示,在任何 IEEE-754 舍入模式(rounding mode)下,没有其他整数舍入结果为该整数。作为对比,253 就不是一个安全整数,它能够使用 IEEE-754 表示,但是 253 + 1 不能使用 IEEE-754 直接表示,在就近舍入(round-to-nearest)和向零舍入中,会被舍入为 253。 安全整数范围为 -(253 - 1)到 253 - 1 之间的整数,包含 -(253 - 1)和 253 - 1。我们也可以通过 Number.isSafeInteger() 方法用来判断传入的参数值是否是一个「安全整数」(safe integer)。 举个例子,

Number.isSafeInteger(3);                    // true
Number.isSafeInteger(Math.pow(2, 53))       // false
Number.isSafeInteger(Math.pow(2, 53) - 1)   // true
Number.isSafeInteger(NaN);                  // false
Number.isSafeInteger(Infinity);             // false
Number.isSafeInteger("3");                  // false
Number.isSafeInteger(3.1);                  // false
Number.isSafeInteger(3.0);                  // true
Math.pow(2, 53);  // 9007199254740992

Math.pow(2, 53) - 1;     // 9007199254740991

Math.pow(2, 53) === Math.pow(2, 53) + 1; // true

Number.MIN_SAFE_INTEGER  // -9007199254740991
-(Math.pow(2, 53) - 1)  // -9007199254740991

Number.MAX_SAFE_INTEGER  // 9007199254740991
Math.pow(2, 53) - 1     // 9007199254740991

上面的代码中,超过了 2 的 53 次方之后,一个数就不准确了。ES6 中引入了 Number.MAX_SAFE_INTEGERNumber.MIN_SAFE_INTEGER 这两个常量,用来表示这个范围的上下限。从上面的代码中,也可以看出来,JavaScript 能够精确表示的极限。

解决方案

计算精度问题

了解了 JavaScript 中的计算精度缺陷之后,我们可以通过 mathjs 库来进行解决。例如

// 1. 引入 mathjs
npm install mathjs
// 2. 使用
import {
  atan2, chain, derivative, e, evaluate, log, pi, pow, round, sqrt
} from 'mathjs'

// functions and constants
round(e, 3)                    // 2.718
atan2(3, -3) / pi              // 0.75
log(10000, 10)                 // 4
sqrt(-4)                       // 2i
pow([[-1, 2], [3, 1]], 2)      // [[7, 0], [0, 7]]
derivative('x^2 + x', 'x')     // 2 * x + 1

// expressions
evaluate('12 / (2.3 + 0.7)')   // 4
evaluate('12.7 cm to inch')    // 5 inch
evaluate('sin(45 deg) ^ 2')    // 0.5
evaluate('9 / 3 + 2i')         // 3 + 2i
evaluate('det([-1, 2; 3, 1])') // -7

// chaining
chain(3)
    .add(4)
    .multiply(2)
    .done()  // 14

或者,可以通过我们自己封装的函数库 cloud-utils 里的 accAddaccSubaccMulaccDiv 来进行数据计算。

JSON.parse() 遇到的大数据问题

后端返回的数据一般都是 JSON 格式的字符串,比如

const response ='{"num": 90071992547409999, "username": "jack"}';
JSON.parse(response); // { num: 90071992547410000, username: 'jack' }

我们一般在使用网络请求库,如 axios 的时候,会在内部使用 JSON.parse 把后端返回的数据转为 JavaScript 对象,便于前端开发人员使用。拿上面的例子来说,由于返回了超出安全整数的范围的数据,所以在使用 JSON.parse 进行解析的时候,就出现了问题。因此,我们需要借助第三方包来解决此问题,这里推荐使用 json-bigint。它会把超出 JavaScript 安全整数范围的数字转为一个 BigNumber 类型的对象,对象数据是它内部的一个算法处理之后的,我们要做的就是在使用的时候转为字符串来使用。

// 1. 安装 json-bigint
npm i json-bigint

// 2. 使用示例
var JSONbig = require('json-bigint');

var json = '{ "value" : 9223372036854775807, "v2": 123 }';
console.log('Input:', json);
// { "value" : 9223372036854775807, "v2": 123 }
console.log('');

console.log('node.js built-in JSON:');
var r = JSON.parse(json);
console.log('JSON.parse(input).value : ', r.value.toString());
// JSON.parse(input).value :  9223372036854776000
console.log('JSON.stringify(JSON.parse(input)):', JSON.stringify(r));
// JSON.stringify(JSON.parse(input)): {"value":9223372036854776000,"v2":123}

console.log('\n\nbig number JSON:');
var r1 = JSONbig.parse(json);
console.log('JSONbig.parse(input).value : ', r1.value.toString());
// JSONbig.parse(input).value :  9223372036854775807
console.log('JSONbig.stringify(JSONbig.parse(input)):', JSONbig.stringify(r1));
// JSONbig.stringify(JSONbig.parse(input)): {"value":9223372036854775807,"v2":123}

因此,我们需要在使用 axios 时做如下相关的改造

import axios from 'axios';
import JSONbig from 'json-bigint';

const request = axios.create({
  transformResponse: [function (data) {
  	try {
      // 转换成功,则返回转换后的结果
    	return JSONbig.parse(data);
    } catch(err) {
    	return data;
    }
  }]
})