起因 最近在项目上涉及到大数的展示,不仅是个大数,还是个小数。然后我们对数字进行验证的时候,发现数字太大了,前端这边根本无法算出正确的结果,而且小数部分还存在精度误差问题。这时候想到了利用 bignumber.js 来解决这个问题;但是我们的系统已经基本进入了后期优化阶段,因为各种原因,这个时候再引入一个新的库有些得不偿失,而且用到的地方就这一个(其他涉及到数字的地方都有专门的方案用来解决精度问题,但是无法解决大数的问题)。所以我就想写个方法专门用来解决这个地方的精度问题以及计算问题。
过程 精度问题 造成精度丢失的原因目前我见过的常见的可能有以下几种:
后台传过来的就是浮点型,数字太大了,在传输到显示的过程中,哪怕不加任何运算,精度也会丢失;
toFixed()
方法造成的精度丢失;
浮点数加减法造成的精度丢失。
下面我们来分别讨论下这三种问题产生的原因以及解决方法。
大数精度 我们发现在js中,数字一旦超过安全值,就开始变得不再精准,哪怕是简单的加法运算。产生这种问题的原因是js采用的是 IEEE 754 即IEEE二进制浮点数算术标准中的双精度浮点数。何为 IEEE 754?网上已经又很多详细的解释了,这里不再赘述。
js的安全值范围是(-9007199254740991 ~ 9007199254740991)。也就是 -(Math.pow(2, 53) - 1) ~ (Math.pow(2, 53) - 1)
。为了避免超出安全值范围导致精度丢失,只需要让后端传String类型即可。
toFixed() 我们先看以下几个toFixed结果。
(1.345 ).toFixed(2 ) (1.375 ).toFixed(2 ) (1.666 ).toFixed(2 ) (1.636 ).toFixed(2 ) (1.423 ).toFixed(2 ) (1.483 ).toFixed(2 )
经过几次试探,我们发现x.toFixed(f)
偶尔会发生精度丢失的问题。 现在看看为什么会出现这样的问题。研究了一下ECMA 262 中对Number.prototype.toFixed9(fractionDigits)
指定的规则。纯英文的,我就不翻译了。涉及到精度的步骤大概是下面这样。
134 / Math .pow(10 , 2 ) - 1.345 135 / Math .pow(10 , 2 ) - 1.345 137 / Math .pow(10 , 2 ) - 1.375 138 / Math .pow(10 , 2 ) - 1.375
*为什么1.345对应的步骤10.a要用134和135?
在规范中没有解释这个n的来源,我根据上下文理解应该是 n = (x * Math.pow(10, f)).toString().split('.')[0]
,其中x为原值,f为参数;然后又因为四舍五入只可能为当前值或者当前值加1,所以用的是134和135。
显然,根据内部的运算规则,toFixed的精度丢失是不可避免的,所以我们可以通过重写toFixed方法来解决这个问题。
Number .prototype.toFixed = function (f ) { let params = Number (f) const num = this if (isNaN (num)) return `${num} ` if (isNaN (params)) params = 0 if (params > 100 || params < 0 ) throw new RangeError ('toFixed() digits argument must be between 0 and 100' ) let temp = num * Math .pow(10 , params) const tempInteger = temp.toString().split('.' )[0 ] const judgeInteger = (temp + 0.5 ).toString().split('.' )[0 ] const tempArr = tempInteger.split('' ) tempArr.splice(tempArr.length - f, 0 , '.' ) const judgeArr = judgeInteger.split('' ) judgeArr.splice(judgeArr.length - f, 0 , '.' ) return judgeInteger > tempInteger ? `${judgeArr.join('' )} ` : `${tempArr.join('' )} ` }
浮点数加减 我们经常会遇到这种问题,0.1 + 0.2 !== 0.3
。这是因为js在运算的时候会先把数字转换为二进制,但是一些小数转为二进制是无限循环的,所以会造成结果的误差。看以下代码。
(0.1 ).toString(2 ) (0.2 ).toString(2 ) (0.3 ).toString(2 ) (0.1 + 0.2 ).toString(2 )
小数转换二进制时的无限循环不可避免。所以我有个想法就是将其转换为字符串,然后按小数点分割成两部分,每部分都一位一位算,最后再将两部分和小数点拼接起来,因为计算的时候都是18以内(为何是18?单位最大为9,9 + 9 = 18)的整数加减法,所以这样可以避免因为小数转二进制而造成的误差。下一节,详细介绍一下这个思路的实现过程。
大数运算(浮点数运算) 现在我们详细介绍一下上一节所说的思路的实现过程。首先我们看加法。
大数加法 在开始以前,我们先做一些准备,考虑一下都有哪些可能性,以及可能出现的BUG。
符号及NaN 先写一个简单的add(x, y)
方法。
const add = (x, y ) => x + y
通过传不同的参数,可能会出现以下几种情况:
传入两个非负数,正常计算;
一正一负,加法变减法;
均为负数,绝对值加法运算,然后取负;
一个或多个为非数字,即为NaN,会导致结果出错;
一个或多个为Boolean类型或者null时,需先转换为其对应的数值再进行计算;
然后我们在add方法里面处理一下这几种情况。
const add = (x = '' , y = '' ) => { if (Number .isNaN(Number (x)) || Number .isNaN(Number (y))) return x + y if (typeof x === 'boolean' || x === null ) x = Number (x).toString() if (typeof y === 'boolean' || y === null ) y = Number (y).toString() let calMethood = true let allAegative = false let sum = '' let flag = 0 let subtracted = x let minus = y if (x.includes('-' ) && y.includes('-' )) { allAegative = true calMethood = true subtracted = x.split('-' )[1 ] minus = y.split('-' )[1 ] } else if (x.includes('-' ) || y.includes('-' )) { calMethood = false let tempX = x.split('-' )[0 ] ? x.split('-' )[0 ] : x.split('-' )[1 ] let tempY = y.split('-' )[0 ] ? y.split('-' )[0 ] : y.split('-' )[1 ] if (+tempX > +tempY) { subtracted = tempX minus = tempY allAegative = x.includes('-' ) } else { subtracted = tempY minus = tempX allAegative = y.includes('-' ) } } return Number (x) + Number (y) }
核心计算过程 处理完了符号,以及可能出现的报错,下面就开始计算部分了。这里采用的是先将字符串用split转换为数组,然后反转数组,使得数组从第零位到最后一位分别对应数字的个位到最大位,最后一位一位计算得到结果。可以写一个方法用来计算。整个实现过程也非常简单
const arrSum = (arr1, arr2, sum, flag) { for (let i = 0 ; i < Math .max(arr1.length, arr2.length); i ++) { if (calMethood) { const temp = (+arr1[i] || 0 ) + (+arr2[i] || 0 ) + flag if (temp < 10 ) { sum = `${temp} ${sum} ` flag = 0 } else { sum = `${temp - 10 } ${sum} ` flag = 1 } } else { let temp = (arr1[i] || 0 ) - (arr2[i] || 0 ) + flag if ((+arr1[i] || 0 ) < (+arr2[i] || 0 )) { temp += 10 flag = -1 } else { flag = 0 } sum = `${temp} ${sum} ` } } return { sum, flag, } }
然后我们在添加一下字符串转换数组的过程。需要注意的是,我们需要特殊考虑一下小数,因为小数的字符串在分割时会将小数点也作为一位分割,所以我们先按小数点分割,将字符串分割为整数和小数两部分。
let integerA = subtracted.split('.' )[0 ].split('' ).reverse() let decimalA = [] let integerB = minus.split('.' )[0 ].split('' ).reverse() let decimalB = [] if (x.includes('.' )) { decimalA = subtracted.split('.' )[1 ].split('' ) } if (y.includes('.' )) { decimalB = minus.split('.' )[1 ].split('' ) } for (let i = 0 ; i < Math .max(decimalA.length, decimalB.length); i ++) { decimalA[i] = +decimalA[i] || 0 decimalB[i] = +decimalB[i] || 0 } decimalA = decimalA.reverse() decimalB = decimalB.reverse()
然后进行计算,先算小数后算整数
decimalA = decimalA.reverse() decimalB = decimalB.reverse() const decimalAns = arrSum(decimalA, decimalB, sum, flag)sum = decimalAns.sum.replace(/0*$/ , '' ) flag = decimalAns.flag if (sum !== '' ) sum = `.${sum} ` const integerAns = arrSum(integerA, integerB, sum, flag)sum = integerAns.sum flag = integerAns.flag if (flag !== 0 ) { sum = `${flag} ${sum} ` } sum = sum.replace(/^0*/ , '' )
然后最后只需要将最后的sum和符号拼起来就是最终的结果。
return allAegative ? `-${sum} ` : sum
大数减法 减法与加法类似,且在上面的过程中,已经有了一个雏形。 比如说 x - y
可以看成是 x + (-y)
,所以就有了一个思路是,增加一个参数用来判断是否是减法,如果是减法就给y值取反,然后仍然进行加法运算。
const add = (x, y, methood = '+' ) => { y = methood === '-' ? -y : y return x + y } add(2 , 3 ) add(2 , 3 , '-' ) add(2 , -3 , '-' )
参照这个思路,我们可以在已经写好的加法上稍作改造,加以下几行代码。
const add = (x = '' , y = '' , methood = '+' ) => { if (methood === '-' ) { b = b.includes('-' ) ? b.split('-' )[1 ] : `-${b} ` } }
总结 市面上已经有非常成熟的解决方案了,我这就是属于重复造轮子了,纯当学习.
参考 双精度浮点数 ECMAScript (ECMA-262)
源码 const addLargeNumber = (a = '' , b = '' , methood = '+' ) => { if (Number .isNaN(Number (a)) || Number .isNaN(Number (b))) return a + b if (methood === '-' ) { b = b.includes('-' ) ? b.split('-' )[1 ] : `-${b} ` } let calMethood = true let allAegative = false let subtracted = a let minus = b if (a.includes('-' ) && b.includes('-' )) { allAegative = true calMethood = true subtracted = a.split('-' )[1 ] minus = b.split('-' )[1 ] } else if (a.includes('-' ) || b.includes('-' )) { calMethood = false let tempX = a.split('-' )[0 ] ? a.split('-' )[0 ] : a.split('-' )[1 ] let tempY = b.split('-' )[0 ] ? b.split('-' )[0 ] : b.split('-' )[1 ] console .log(+tempX, +tempY, +tempX > +tempY) if (+tempX > +tempY) { subtracted = tempX minus = tempY allAegative = a.includes('-' ) } else { subtracted = tempY minus = tempX allAegative = b.includes('-' ) } } let integerA = subtracted.split('.' )[0 ].split('' ).reverse() let decimalA = [] let integerB = minus.split('.' )[0 ].split('' ).reverse() let decimalB = [] let flag = 0 let sum = '' if (a.includes('.' )) { decimalA = subtracted.split('.' )[1 ].split('' ) } if (b.includes('.' )) { decimalB = minus.split('.' )[1 ].split('' ) } for (let i = 0 ; i < Math .max(decimalA.length, decimalB.length); i ++) { decimalA[i] = +decimalA[i] || 0 decimalB[i] = +decimalB[i] || 0 } decimalA = decimalA.reverse() decimalB = decimalB.reverse() const decimalAns = arrSum(decimalA, decimalB, sum, flag) sum = decimalAns.sum.replace(/0*$/ , '' ) flag = decimalAns.flag if (sum !== '' ) sum = `.${sum} ` const integerAns = arrSum(integerA, integerB, sum, flag) sum = integerAns.sum flag = integerAns.flag if (flag !== 0 ) { sum = `${flag} ${sum} ` } sum = sum.replace(/^0*/ , '' ) || '0' function arrSum (arr1, arr2, sum, flag ) { for (let i = 0 ; i < Math .max(arr1.length, arr2.length); i ++) { if (calMethood) { const temp = (+arr1[i] || 0 ) + (+arr2[i] || 0 ) + flag if (temp < 10 ) { sum = `${temp} ${sum} ` flag = 0 } else { sum = `${temp - 10 } ${sum} ` flag = 1 } } else { let temp = (+arr1[i] || 0 ) - (+arr2[i] || 0 ) + flag if ((arr1[i] || 0 ) < (arr2[i] || 0 )) { temp += 10 flag = -1 } else { flag = 0 } sum = `${temp} ${sum} ` } } return { sum, flag, } } return allAegative ? `-${sum} ` : sum }