json parse 大數精度丟失


如果你在 Chrome Dev Tools 控制台中輸入 JSON.parse('{"taskid": 9007199254740993}') 運行結果返回的將會是 {taskid: 9007199254740992}。為什么 parse 后的數值會不一致?

雙精度浮點數 IEEE 754

JavaScript 采用雙精度浮點數( IEEE 754 標准)來表示它的 Number 類型。一個數字占用 64 bits 存儲空間(這里的每一位都只能存放 0 或 1):

General double precision float

第一位 0 表示正值、1 表示負值;第 2- 12 位表示 2 的指數部分(可正可負);剩下的 52 個 bits 表示尾數部分,它的長度決定了數字的精度。

(1)sign×2exponent0x3ff×1.mantissa

如果我們將符號位和指數位共 12 個 bits 表示為 16 進制(4 個二進制 bits 1111 得到 1 個 16 進制的 f),那么它的取值范圍為 [000, 7ff]。其中,規范約定當取值 7ff 時,可以表示無窮大或 NaN。

所以雙精度浮點數能表示的最大 16 進制數為 0x7fef_ffff_ffff_ffff,轉為十進制約為 1.79 ×10 的 308 次方。能表示的數的范圍非常大,但受限於尾數的長度,能“精確”表示的數字並不多,我們來看看這個數到底是多少。

最大安全整數

從以上表示公式我們能看到,當指數部分只取 1 位,尾數部分取滿 52 位時,可以精確表示出 JavaScript 里的整數,其 16 進制形式為 0x001f_ffff_ffff_ffff ,即 9007199254740991

它等於 2 的 53 次方減 1,在 ES6 中,可以通過 Number.MAX_SAFE_INTEGER 引用到這個數值。

Number.MAX_SAFE_INTEGER === Math.pow(2, 53) - 1  // true
Number.MAX_SAFE_INTEGER === 0x001f_ffff_ffff_ffff   // true
Number.MAX_SAFE_INTEGER === 9007199254740991     // true
Number.MAX_SAFE_INTEGER === -Number.MIN_SAFE_INTEGER  // true

超過這個最大安全整數的運算,都可能因為發生進位溢出,造成精度丟失。

前后端大數傳輸方案

大數的運算和前后端傳輸是前端開發領域中的一個重要知識點。

本文開頭提到的問題,源自於一個真實的項目案例,taskid 是 MySQL 數據庫中的 bigint 類型字段。在 MySQL 中,一個 bigint 存儲占用 8 Bytes 的空間,即 64 bits。當取值為無符號整型時,能表示的范圍是 0 到 2 的 64 次方減 1,即 18446744073709551615

當 taskid 取值在 (9007199254740991, 18446744073709551615] 之間時,后端程序(受語言特性和第三方庫影響)通常能正確的執行 JSON 序列化操作,並通過 HTTP 接口返回給前端,而前端執行 JSON.parse 解碼時,會因為語言本身的限制發生精度丟失,引發 bug。

大數轉字符串類型

為了解決大數傳遞精度丟失的問題,常見的方案是“將大數轉為字符串類型”。具體的做法如下:

后端程序先將大數轉為 string 類型,再進行 JSON encode,傳給前端。前端拿到數據后 decode 成 string 類型,直接展示。可參考 json-bigint

當需要大數運算時(可參考博客大數加法實現),將 string split 成多段安全整數字符串,每段單獨轉為 number 類型,在安全范圍內計算完成后,再 join 成 string 類型進行展示。

一些第三方庫(如 json-bigint)之所以能正確的處理大數 parse ,且不造成精度丟失,其實現原理也是類似。在拿到接口的 JSON 數據時,並不直接 JSON.parse,而是先將整塊數據當作 text 字符串,將其中的大數以 string 類型進行存儲和標記,再使用定制化的 JSON.parse。

類型語義丟失

我們知道前端往后端 POST 數據時,有兩種常見的編碼形式 application/x-www-form-urlencodedapplication/json

當我們需要傳遞一個 number 類型的 id 給接口時,application/x-www-form-urlencoded 在 HTTP Request Body 中傳輸的是 id=1,而 application/json 的 Body 則是 {"id":1} 。我們之所以認為后者的語義更好,是因為后者能正確地反映出 id 的真實類型為 number。

而當這個 id 為 String 類型時,前者傳輸的依然是 id=1,后者則變為了 {"id":"1"}。對於后端程序來說,這層類型語義能讓參數類型校驗和計算更加准確和方便。

而如果前后端采用將“大數轉為字符串”的方案,當 taskid 以 string 類型返回時,調用方將無法判斷出它在業務和 DB 中到底是 char 字符類型存儲的,還是 bigint 類型存儲,導致類型語義丟失的情況發生。

類型語義有那么重要嗎?這是另外一個話題了,但從 TypeScript 的發展趨勢來看,為 JavaScript 加一個明確的類型,有很重大的意義。

ECMAScript 與 JSON 標准中的沖突

為了解決大數運算的問題,ECMAScript 標准中引入了 BigInt 類型(當前處於 Stage 3,且 Chrome 已經支持),通過在數字后面加一個 n,可以顯式的聲明一個 BigInt 類型對象,在進行運算時,將不再會發生精度丟失。

0x001f_ffff_ffff_ffffn + 2n === 9007199254740993n // true
2n**64n - 1n === 18446744073709551615n // true

在前端環境中,可以極其方便地進行大數運算。但這種做法,在進行 JSON 編解碼時卻遇到了大難題。

JSON 標准(IETF 7159)中定義了 JSON 支持的數據展示類型為 string、number、boolean、null 和這四個基礎類型所組成的 object、array 結構。其他 JavaScript 類型在編解碼時都會被靜默消除或轉換。

JSON.stringify({a:undefined, b: NaN, c: Symbol('c'), d:new Date(), e: new Set([1,2,3]), f:()=>{}}) 
// {"b":null,"d":"2019-07-31T10:21:47.848Z","e":{}}

從開發者的直觀感受上,BigInt 作為 Number 類型的補充,應當在 JSON 標准中當作 Number 類型被支持。但從語言設計的角度來看,1 和 1n 是完全不同的對象類型,如果使用同一種表示方式,那么必然會發生“類型語義丟失”的現象。

更麻煩的地方在於,JSON 標准屬於更廣泛的標准,對 JSON 標准的改動,會影響到其他所有語言的實現,這可不是 JavaScript 弟弟能 hold 得住的。作為 ES 標准的制定者,TC39 委員會的大神們擱置了這個問題,而調皮的 Chrome 則在開發者試圖 stringify 一個 BigInt 時,拋出了 Do not know how to serialize a BigInt 的異常。

事實上 JSON 標准中已經預料到,如果不設定 Number 的精度標准,可能會在不同系統傳遞數值時發生精度丟失的問題,所以也有建議開發者按照雙精度浮點數規范來約束自己的系統。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM