后端問(wèn)為什么前端數(shù)值精度會(huì)丟失?
前言
相信各位前端小伙伴在日常工作中不免會(huì)涉及到使用 JavaScript 處理 數(shù)值 相關(guān)的操作,例如 數(shù)值計(jì)算、保留指定小數(shù)位、接口返回?cái)?shù)值過(guò)大 等等,這些操作都有可能導(dǎo)致原本正常的數(shù)值在 JavaScript 中確表現(xiàn)得異常(即精度丟失),這也是被很多開(kāi)發(fā)者詬病的一點(diǎn)(你該不會(huì)還沒(méi)踩過(guò)坑吧!),當(dāng)然包括很多 后端開(kāi)發(fā)者(不止一次的被問(wèn)到這個(gè)問(wèn)題)。
本文主要包含 精度丟失場(chǎng)景、精度丟失原因、解決方案 等方面的內(nèi)容,文中若有不正確的地方歡迎在評(píng)論區(qū)分享你的見(jiàn)解。
精度丟失場(chǎng)景
浮點(diǎn)數(shù)的計(jì)算
數(shù)值計(jì)算在前端的應(yīng)用還不算少,但涉及 浮點(diǎn)數(shù) 參與計(jì)算時(shí)可能會(huì)出現(xiàn)精度丟失,如下:
加( + )
正常計(jì)算:0.1 + 0.2 = 0.3
JavaScript計(jì)算:0.1 + 0.2 = 0.30000000000000004
減( - )
正常計(jì)算:1 - 0.9 = 0.1
JavaScript計(jì)算:1 - 0.9 = 0.09999999999999998
乘( * )
正常計(jì)算:0.0532 * 100 = 5.32
JavaScript計(jì)算:0.0532 * 100 = 5.319999999999999
除( / )
正常計(jì)算:0.3 / 6 = 0.05
JavaScript計(jì)算:0.3 / 6 = 0.049999999999999996
超過(guò)最值
所謂 超過(guò)最值(最大、最小值) 指的是超過(guò)了 Number.MIN_SAFE_INTEGER(- 9007199254740991),即
+(2^53 – 1) 或 Number.MAX_SAFE_INTEGER(+ 9007199254740991),即 -(2^53 – 1) 范圍的值,項(xiàng)目中最常見(jiàn)的就是如下幾種情況:
后端返回的數(shù)值超過(guò)最值
例一,后端返回的列表數(shù)據(jù),通常都會(huì)有相應(yīng)的
ID來(lái)標(biāo)識(shí)唯一性,但后端字啊生成這個(gè)ID時(shí)是 Long 類(lèi)型,那么該值很可能就會(huì)超過(guò)JavaScript中能表示的最大正整數(shù),此時(shí)就導(dǎo)致精度丟失,即前端實(shí)際獲取到的ID值和后端返回的將不一致例二,后端可能需要將一些值通過(guò)計(jì)算之后,把對(duì)應(yīng)的結(jié)果值返回給前端,此時(shí)若該值超過(guò)了 最值,那么也會(huì)產(chǎn)生精度丟失
前端進(jìn)行數(shù)值計(jì)算時(shí),計(jì)算結(jié)果超過(guò)最值

保留指定小數(shù)位
除了上述對(duì)涉及浮點(diǎn)數(shù)計(jì)算、超過(guò)最值的場(chǎng)景之外,我們通常還會(huì)對(duì)數(shù)值進(jìn)行保留指定小數(shù)位的處理,而部分開(kāi)發(fā)者可能會(huì)直接使用 Number.prototype.toFixed 來(lái)實(shí)現(xiàn),但這個(gè)方法卻并不能保證我們期望的效果,例如保留小數(shù)位時(shí)需要進(jìn)行 四舍五入 時(shí)就會(huì)有問(wèn)題,如下:
console.log(1.595.toFixed(2)) // 1.59 ——> 期望為:1.60console.log(1.585.toFixed(2)) // 1.58 ——> 期望為:1.59console.log(1.575.toFixed(2)) // 1.57 ——> 期望為:1.58console.log(1.565.toFixed(2)) // 1.56 ——> 期望為:1.57console.log(1.555.toFixed(2)) // 1.55 ——> 期望為:1.56console.log(1.545.toFixed(2)) // 1.54 ——> 期望為:1.55console.log(1.535.toFixed(2)) // 1.53 ——> 期望為:1.54console.log(1.525.toFixed(2)) // 1.52 ——> 期望為:1.53console.log(1.515.toFixed(2)) // 1.51 ——> 期望為:1.52console.log(1.505.toFixed(2)) // 1.50 ——> 期望為:1.51
精度丟失的原因
計(jì)算機(jī)內(nèi)部實(shí)際上只能 存儲(chǔ)/識(shí)別 二進(jìn)制,因此 文檔、圖片、數(shù)字 等都會(huì)被轉(zhuǎn)換為 二進(jìn)制,而對(duì)于數(shù)字而言,雖然我們看到的是 十進(jìn)制 的表示結(jié)果,但實(shí)際上會(huì)底層會(huì)進(jìn)行 十進(jìn)制 和 二進(jìn)制 的相互轉(zhuǎn)換,而這個(gè)轉(zhuǎn)換過(guò)程就有可能會(huì)出現(xiàn) 精度丟失,因?yàn)槭M(jìn)制轉(zhuǎn)二進(jìn)制后可能產(chǎn)生 無(wú)限循環(huán) 部分,而 實(shí)際存儲(chǔ)空間是有限的。
IEEE 754 標(biāo)準(zhǔn)
Javascript 中的數(shù)字存儲(chǔ)使用了 IEEE 754 中規(guī)定的 雙精度浮點(diǎn)數(shù) 數(shù)據(jù)類(lèi)型,雙精度浮點(diǎn)數(shù)使用 64 位(8 字節(jié)) 來(lái)存儲(chǔ)一個(gè) 浮點(diǎn)數(shù),可以表示二進(jìn)位制的 53 位 有效數(shù)字,即 (0-52 位為 1) 111...111 = (53 位為 1,0-52 位為 0) 1000...000 - 1,也就是 2^53 - 1,而這也就是 JavaScript 中 Number.MAX_SAFE_INTEGER(+ 9007199254740991) 對(duì)應(yīng)的值。
雙精度浮點(diǎn)數(shù)的組成

雙精度浮點(diǎn)數(shù)(double) 由如下幾部分組成:
sign符號(hào)位,0 為正,1 為負(fù)占 1bit,在 63 位
exponent指數(shù)部分,表示 2 的幾次方占 11bit,在 52-62 位
指數(shù) 采用 偏移碼表示法,即將 指數(shù)的真實(shí)值
e加上一個(gè) 偏移量,然后得到 階碼(即計(jì)算結(jié)果)并將其表示為 二進(jìn)制數(shù)其中 偏移量 =
(2^n-1) - 1,n 是 指數(shù)的位數(shù)(即n = 11),因此偏移量為Math.pow(2, 11-1) - 1 = 1023階碼
E= 指數(shù)真值e+ 偏移碼(2^n-1) - 1
-
mantissa尾數(shù)部分,表示浮點(diǎn)數(shù)的精度 占 52bit,在 0-51 位
尾數(shù)采用 隱式 的方式表示,即在尾數(shù)的 最高位上 總是隱含著一個(gè) 1,并且隱藏在 小數(shù) 點(diǎn)的左邊 (即 1 < 尾數(shù) < 2),因此尾數(shù)的有效位數(shù)為 53 位,而不是 52 位
十進(jìn)制浮點(diǎn)數(shù)的存儲(chǔ)過(guò)程
有了上面的公式,接下來(lái)我們來(lái)演示一下一個(gè)十進(jìn)制浮點(diǎn)數(shù)是如何以 雙精度浮點(diǎn)數(shù) 的形式被存儲(chǔ)到計(jì)算機(jī)中的,其大致分為如下兩步:
十進(jìn)制轉(zhuǎn)二進(jìn)制
分別對(duì)整數(shù)部分和小數(shù)部分的十進(jìn)制轉(zhuǎn)化為二進(jìn)制
求出 sign、exponent、mantissa 的值
下面我們通過(guò) 263.3 這個(gè)數(shù)值來(lái)演示。
十進(jìn)制轉(zhuǎn)二進(jìn)制
分別將 263.3 的 整數(shù)部分 263 和 小數(shù)部分 0.3 轉(zhuǎn)為對(duì)應(yīng)的 二進(jìn)制數(shù),這里你可以使用便捷的 在線轉(zhuǎn)換工具,也可選擇手動(dòng)計(jì)算:
整數(shù)部分 轉(zhuǎn) 二進(jìn)制
一直 除以 2 直到余數(shù)為 0 或 出現(xiàn)循環(huán),然后 從下往上 將每次的余數(shù)進(jìn)行組合即可

小數(shù)部分 轉(zhuǎn) 二進(jìn)制
一直 乘以 2 直到乘積為 1 或 出現(xiàn)循環(huán),然后 從上往下 將每次的乘積的 整數(shù)位 進(jìn)行組合即可

最終得到的結(jié)果就是 263.3(10) 對(duì)應(yīng)的 二進(jìn)制 為 100000111.010011001...
求出 sign、exponent、mantissa 的值
求 sign
其中 sign 為符號(hào)位,且 263.3(
10) 為正數(shù),因此 sign = 0求 exponent
根據(jù)公式 (-1)^S
x(1. M)x2^(E-1023) 可知,其中的 尾數(shù) 要符合 1. M 的形式,因此 100000111.010011001... 中小數(shù)點(diǎn)需要往左移動(dòng) 8位 變成 1.00000111 010011001 ...其中的 8 就是 指數(shù)真值,但在實(shí)際存儲(chǔ)時(shí)是存 階碼的二進(jìn)制,根據(jù)公式 階碼 = 指數(shù)真值(8) + 偏移量(1023),即 階碼 = 1031,所以 exponent 值就為 1031 的二進(jìn)制:10000000111
求 mantissa
根據(jù)上一步的 1.00000111 010011001 ... 很容易知道尾數(shù) mantissa = 00000111 010011001 ...
最終存儲(chǔ)形式

Number.prototype.toFixed 的舍入
關(guān)于這個(gè)方法的舍入方式,目前最多的說(shuō)法就是 銀行家算法 ,的確在大多情況下確實(shí)能夠符合 銀行家算法 的規(guī)則,但是部分情況就并不符合其規(guī)則,因此嚴(yán)格意義上來(lái)講 Number.prototype.toFixed 并不算是使用了 銀行家算法,如果你要問(wèn)為什么,請(qǐng)看 ECMAScript? 2024 Language Specification (tc39.es),在下文都會(huì)提及。
銀行家算法
所謂銀行家算法用一句話概括為:
四舍六入五考慮,五后 有數(shù) 就進(jìn)一,五后 無(wú)數(shù) 看 奇偶,五前 為偶當(dāng) 舍去,五后 為奇要 進(jìn)一
四舍 指保留位后的 數(shù)值 < 5 應(yīng)
舍去,4 只是個(gè)代表值六入 指保留位后的 數(shù)值 > 5 應(yīng)
進(jìn)一,6 只是個(gè)代表值若保留位后的 數(shù)值 = 5,看 5 后 是否有數(shù)
若 5 后 無(wú)數(shù),則看 5 前 的數(shù)值的 奇偶 來(lái)判斷
若 5 前 的數(shù)值為 偶數(shù),則 舍去
若 5 前 的數(shù)值為 奇數(shù),則 進(jìn)一
若 5 后 有數(shù),則 進(jìn)一
用例子來(lái)驗(yàn)證一下:
// 四舍(1.1341).toFixed(2) = '1.13'// 六入(1.1361).toFixed(2) = '1.14'// 五后 有數(shù) ,進(jìn)一(1.1351).toFixed(2) = '1.14'// 五后 無(wú)數(shù),看奇偶,五前為 3 奇數(shù),進(jìn)一(1.1350).toFixed(2) = '1.14'// 五后 無(wú)數(shù),看奇偶,五前為 0 偶數(shù),舍去(1.1050).toFixed(2) = '1.10'
看起來(lái)沒(méi)有問(wèn)題是吧!
// 五后 有數(shù),應(yīng)進(jìn)一(1.1051).toFixed(2) = 1.11 (正確 √)(1.105).toPrecision(17) = '1.1050000000000000' // 精度// 五后 無(wú)數(shù),看奇偶,五前為 0 偶數(shù),應(yīng)舍去(1.105).toFixed(2) = 1.10 (正確 √)// 五后 無(wú)數(shù),看奇偶,五前為 2 偶數(shù),應(yīng)舍去(1.125).toFixed(2) = 1.13 (不正確 ×)1.125.toPrecision(17) = '1.1250000000000000' // 精度// 五后 無(wú)數(shù),看奇偶,五前為 4 偶數(shù),應(yīng)舍去(1.145).toFixed(2) = 1.15 (不正確 ×)1.145.toPrecision(17) = '1.1450000000000000' // 精度// 五后 無(wú)數(shù),看奇偶,五前為 6 偶數(shù),應(yīng)舍去(1.165).toFixed(2) = 1.17 (不正確 ×)1.165.toPrecision(17) = '1.1650000000000000' // 精度// 五后 無(wú)數(shù),看奇偶,五前為 8 偶數(shù),應(yīng)舍去(1.185).toFixed(2) = 1.19 (不正確 ×)1.185.toPrecision(17) = '1.1850000000000001' // 精度
ECMAScript 定義的 toFixed 標(biāo)準(zhǔn)

一眼望上去是不是覺(jué)得看不懂,那么這里就來(lái)嘗試解釋一下這個(gè)標(biāo)準(zhǔn)的內(nèi)容吧(摻雜個(gè)人理解)!
讓 x = 目標(biāo)數(shù)字,如:
(1.145).toFixed(2)中x = 1.1245讓 f = 參數(shù),如:
(1.145).toFixed(2)中f = 2若 f =
undefined,即 未傳參,則將 f = 0若 f =
Infinite,即傳入了 無(wú)窮值,則拋出 RangeError 異常若 f <
0或 f >100,即傳入了不在0 - 100之間的值,則拋出 RangeError 異常若 x =
Infinite,即想要對(duì) 非準(zhǔn)確值 保留位操作,則返回其 字符串形式例如,
Infinity.toFixed(2) = 'Infinity'、NaN.toFixed(2) = 'NaN'讓 x = 計(jì)算機(jī)所能表示的數(shù)學(xué)值 ?(x)
從 數(shù)字 或 BigInt x 到 數(shù)學(xué)值 的轉(zhuǎn)換表示為
x 的數(shù)學(xué)值,或?(x)讓 返回值符號(hào) s = '',即為符號(hào)定義 初始值
若 x <
0,則將 s ='-',并將 x =-x若 x ≥10^21,則 返回值 m =x對(duì)應(yīng)的科學(xué)計(jì)數(shù)法表示的字符串
若 x <
10^21,則a. 讓 n =
一個(gè)整數(shù),其中n / 10^f - x盡可能接近于0,如果有兩個(gè)這樣的 n,選擇 較大的 nb. 若 n =
整數(shù) 0,則 m ="0",否則,m = 由n的十進(jìn)制表示形式的數(shù)字組成的字符串值(按順序,不帶前導(dǎo)零)c. 若 指數(shù) f ≠
0,則 k =m.length- 若 k ≤ f,則
- z = 由代碼單元0x0030(DIGIT ZERO)的f+1-k次出現(xiàn)組成的字符串
- m = z + m
- k = f + 1
- 讓 a = m 的第一個(gè) k-f 碼單元
- 讓 b = m 的其它 f 個(gè)編碼單元
- 將 m = a + "." + b
12. 返回 s + m 組成的字符串

看不懂?那就挑懂的地方看
不多說(shuō)了,還是用 (1.125).toFixed(2) = 1.13 舉個(gè)栗子吧!
根據(jù)上述規(guī)范初始 x = 1.125,f = 2,s = ''
根據(jù)規(guī)范 7 可知 x =
1.125.toPrecision(53)= 1.125
根據(jù)規(guī)范 11.a 提供的公式:
n / 10^f - x ≈ 0代入計(jì)算:n ≈ 112.5:此時(shí)最接近
n的 整數(shù) 有 兩個(gè) 值為110和112,按標(biāo)準(zhǔn)取最大的113在按 11.c 的規(guī)范得到
m = 1.13最終返回
s + m= 1.13

還不會(huì),再來(lái)個(gè) (-1.105).toFixed(2) = -1.10 的栗子吧!
根據(jù)上述規(guī)范初始 x = 1.105,f = 2,s = '-'
根據(jù)規(guī)范 7 可知 x =
(-1.105).toPrecision(53)= 1.10499...
根據(jù)規(guī)范 11.a 提供的公式:
n / 10^f - x ≈ 0代入計(jì)算:n ≈ 110.4...:此時(shí)最接近
n的 整數(shù) 只有 一個(gè) 值為110(因?yàn)橹挥行?shù)點(diǎn)后為 5 時(shí),向上 / 向下 取整才會(huì)有兩種情況)在按 11.c 的規(guī)范得到
m = 1.10最終返回
s + m= -1.13
如何解決前端數(shù)值的精度問(wèn)題?
雖然知道了 精度丟失 的原因,也知道了 toFixed 舍入 的邏輯,但是實(shí)際上在進(jìn)行計(jì)算時(shí),我們還是希望按照實(shí)際看到的數(shù)值來(lái)進(jìn)行計(jì)算或舍入,而不是底層轉(zhuǎn)換過(guò)的值。
使用第三方庫(kù)
需要的自行查閱:
math.js
big.js
bignumber.js
decimal.js
思路擴(kuò)展
浮點(diǎn)數(shù)計(jì)算
浮點(diǎn)數(shù)在 JavaScript 中經(jīng)底層轉(zhuǎn)換后可能會(huì)有精度丟失,但是 安全范圍內(nèi)的整數(shù) 卻不會(huì)丟失,那么我們就可以先將 浮點(diǎn)數(shù) 轉(zhuǎn)成 整數(shù) 進(jìn)行計(jì)算后,再將計(jì)算結(jié)果成為浮點(diǎn)數(shù)。
以 0.1 + 0.2 = 0.30000000000000004 舉個(gè)例子,如下:
原式:0.1 + 0.2 = x
擴(kuò)大
10倍:0.1 * 10 + 0.2 * 10 = 10 * x變式:10 * x = 3
結(jié)果:x = 0.3
超過(guò)最值
前面提到的 后端返回 或 前端計(jì)算 產(chǎn)生的超過(guò) 安全范圍的值,我們可以使用 BigInt 來(lái)處理,這是新增的原始值類(lèi)型,它提供了一種方法來(lái)表示 大于 2^53 - 1 的整數(shù)。

保留指定小數(shù)位
既然 Number.prototype.toFixed() 的舍入方法并不是我們需要的,那么我們可以直接將其重寫(xiě)成符合的即可,例如:
Number.prototype.toFixed=function (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+"";}
最后
以上就是本文的全部?jī)?nèi)容了,由于涉及到部分內(nèi)容 計(jì)網(wǎng) 相關(guān)內(nèi)容,所以可能理解起來(lái)會(huì)比較吃力,但是跨過(guò)這道坎也就沒(méi)那么難理解了。

希望本文對(duì)你有所幫助!!!
下方加 Nealyang 好友回復(fù)「 加群」即可。
如果你覺(jué)得這篇內(nèi)容對(duì)你有幫助,我想請(qǐng)你幫我2個(gè)小忙:
