JSON Bigint 大数精度丢失的背后

如果你在 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} × 2^{exponent-0x3ff} × 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 类型,直接展示。当需要大数运算时,将 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 的精度标准,可能会在不同系统传递数值时发生精度丢失的问题,所以也有建议开发者按照双精度浮点数规范来约束自己的系统。

如何利用 JavaScript BigInt 类型在不造成类型语义丢失的前提下,解决前后端接口大数的传输,是一个既有趣又有挑战的话题,同时也相当考验标准制定者和开发者的智慧了。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券