前端训练营:1v1私教,终身辅导计划,帮你拿到满意的
offer
。 已帮助数百位同学拿到了中大厂offer
。微信在文章底部,欢迎来撩~~~~~~~~
Hello,大家好,我是 Sunday。
在几年前的一次字节跳动面试中,面试官提出的一个关于 BigInt 的问题让我印象深刻。那时,我对 BigInt 还知之甚少,但这个问题引起了我对它的好奇心。今天,我想和大家深入探讨一下 BigInt 的奥秘,以及它在解决 JSON.parse 大型数字解析问题中的应用。
本文部分内容引自《Why does JSON.parse corrupt large numbers and how to solve this?》这篇文章。
绝大多数网络应用都会涉及从服务器获取数据,这些数据以 JSON 格式接收,并被解析为 JavaScript 中的对象或数组,以供前端页面使用。通常,我们会使用 JavaScript 内置的 JSON.parse 函数来进行数据解析,这个过程既高效又便捷。
JSON 数据格式非常简单,它实际上是 JavaScript 的子集,因此可以与 JavaScript 完全互换。许多时候,前端开发者不会怀疑 JavaScript 中的 JSON 数据会出现问题,但有些情况却不行。
比如以下场景:
{"count": 9123372036854000123}
当将其解析为 JavaScript 并读取 count 时,会得到如下的值:
9123372036854000000
很显然,此时解析的值是不准确的,最后三位数字变成了零。
当类似于 7584775647658465744 的长数字出现时,它不仅是有效的 JSON,也是有效的 JavaScript。然而,在 JavaScript 中将这样的值解析为数字时,问题就会显现出来。
这是因为 JavaScript 最初只有一种数字类型 Number,它是一种 64 位浮点值,类似于 C++、Java 或 C# 中的 Double 值,可以存储大约 16 位数字。 因此,像 9123372036854000123 这样的 19 位数字无法被完全表示。 在这种情况下,最后三位数字会丢失,导致数值损坏。
类似的情况也会出现在处理分数时。比如,当开发者在 JavaScript 中计算 1/3 时,结果如下:
console.log(1 / 3); // 输出结果为 0.3333333333333333
实际上,该值应该是一个具有无限位数小数的结果,但 JavaScript 数字在大约 16 位数字后就会结束。
那么,JSON 文档中像 9123372036854000123 这样的大数值是如何产生的呢? 其实,这源于其他编程语言,比如 Java 或 C#,这些语言具有不同的数字数据类型(比如 Long)。 Long 类型是一个 64 位值,可以容纳最多约 20 位的整数值,而它不需要像浮点值那样存储指数值(Exponential Value)。
因此,在类似 Java 的语言中,开发者可能会拥有一个 JavaScript 的 Number 类型无法正确表示的 Long 值,或者在其他语言中类似的 Double 类型也无法准确表示。
JavaScript 的 Number 类型还有一些其他限制:该值可能会溢出或下溢。 例如,1e+500 将变为无穷大,而 1e-500 将变为 0。但是,在实际应用中,这些限制很少成为问题。
第一个解决方案是在 JSON.parse 中利用一个可选的 reviver 参数,它允许开发者以不同的方式解析内容。
如果指定了 reviver 函数,则解析出的 JavaScript 值(解析值)会经过一次转换后才将被最终返回(返回值)。
更具体点讲就是:解析值本身以及它所包含的所有属性,会按照一定的顺序(从最最里层的属性开始,一级级往外,最终到达顶层,也就是解析值本身)分别的去调用 reviver 函数,在调用过程中,当前属性所属的对象会作为 this 值,当前属性名和属性值会分别作为第一个和第二个参数传入 reviver 中。
如果 reviver 返回 undefined,则当前属性会从所属对象中删除,如果返回了其他值,则返回的值会成为当前属性新的属性值。
当遍历到最顶层的值(解析值)时,传入 reviver 函数的参数会是空字符串 “”(因为此时已经没有真正的属性)和当前的解析值(有可能已经被修改过了),当前的 this 值会是 {“”: 修改过的解析值},在编写 reviver 函数时,要注意到这个特例。(这个函数的遍历顺序依照:从最内层开始,按照层级顺序,依次向外遍历)
JSON.parse(
'{"1": 1,"2": 2,"3": {"4": 4,"5": {"6": 6}}}',
function (k, v) {
console.log(k);
// 输出当前的属性名,从而得知遍历顺序是从内向外的,
// 最后一个属性名会是个空字符串。
return v;
// 返回原始属性值,相当于没有传递 reviver 参数。
}
);
但是,当 JSON.parse 开始解析 JSON 字符串时,它首先会将数字识别为 JavaScript 中的标准数字类型。即使在 reviver 参数执行之前,数字已经被处理并存储为 JavaScript 中的数字表示形式,超出浮点数范围的大数字在此阶段就已损坏。
因此,尽管 reviver 允许对解析后的值进行修改和处理,但它无法解决数字损坏的问题,因为该问题在 JSON.parse 内部处理数字时就已经发生。
所以,开发者无法仅凭内置的 JSON.parse 来解决问题,必须寻找其他的 JSON 解析器。 幸运的是,存在许多出色的解决方案可供选择。
以下是一些优秀的开源库,专门应对 JSON.parse 的各种问题,可根据实际需求自行选择使用。
JSON.parse 和 JSON.tringify 的实现,支持 bigints 。 基于 Douglas Crockford JSON.js 包和 bignumber.js 库。
var JSONbig = require('json-bigint');
var json = '{"value": 9223372036854775807,"v2": 123}';
console.log('Input:', json);
console.log('');
console.log('node.js built-in JSON:');
var r = JSON.parse(json);
console.log('JSON.parse(input).value :', r.value.toString());
console.log('JSON.stringify(JSON.parse(input)):', JSON.stringify(r));
console.log('\n\nbig number JSON:');
var r1 = JSONbig.parse(json);
console.log('JSONbig.parse(input).value :', r1.value.toString());
console.log('JSONbig.stringify(JSONbig.parse(input)):', JSONbig.stringify(r1));
输出结果如下:
Input: {"value" : 9223372036854775807, "v2": 123}
node.js built-in JSON:
JSON.parse(input).value : 9223372036854776000
JSON.stringify(JSON.parse(input)): {"value":9223372036854776000,"v2":123}
big number JSON:
JSONbig.parse(input).value : 9223372036854775807
JSONbig.stringify(JSONbig.parse(input)): {"value":9223372036854775807,"v2":123}
lossless-json 用于解析 JSON,而且不会有丢失数字信息的风险。基础用法如下:
import {parse, stringify} from 'lossless-json'
const text = '{"decimal":2.370,"long":9123372036854000123,"big":2.3e+500}'
// JSON.parse will lose some digits and a whole number:
console.log(JSON.stringify(JSON.parse(text)))
// '{"decimal":2.37,"long":9123372036854000000,"big":null}'
// WHOOPS!!!
// LosslessJSON.parse will preserve all numbers and even the formatting:
console.log(stringify(parse(text)))
// '{"decimal":2.370,"long":9123372036854000123,"big":2.3e+500}'
这个库的运作方式与本机的 JSON.parse 和 JSON.stringify 完全相同。然而,它与标准方法的不同之处在于 lossless-json 保留了大数字的完整信息。
在这里,它并不将数值解析为标准的数字类型,而是将其解析为 LosslessNumber,这是一个将数值以字符串形式存储的轻量级类。开发者可以使用 LosslessNumber 执行一般的操作,但如果这些操作会导致信息丢失,该类将抛出错误。
js-json-bigint 是一个 JavaScript 库,它允许使用 BigInt 来支持对 JSON 进行编码。如果需要在服务器中处理 64 位整数,该库能够满足这一需求,因为它将 64 位整数解析为 bigint。与此同时,这个库没有任何依赖,并且只有 443 字节的体积。
该库的实现非常简洁,核心代码仅有大约 20 多行:
export function parseJSON(text, reviver) {
if (typeof text !== 'string') {
return null
}
return JSON.parse(text.replace(/([^\"]+\"\:\s*)(\d{16,})(\,\s*\"[^\"]+|}$)/g, '$1"$2n"$3'), (k, v) => {
if (typeof v === 'string' && /^\d{16,}n$/.test(v)) {
v = BigInt(v.slice(0, -1))
}
return typeof reviver === 'function' ? reviver(k, v) : v
})
}
export function stringifyJSON(value, replacer, space) {
return JSON.stringify(value, (k, v) => {
if (typeof v === 'bigint') {
v = v.toString() + 'n'
}
return typeof replacer === 'function' ? replacer(k, v) : v
}, space).replace(/([^\"]+\"\:\s*)(?:\")(\d{16,})(?:n\")(\,\s*\"[^\"]+|}$)/g, '$1$2$3')
}
不涉及第三方库,使用 BigInt 值也可能会导致棘手的问题。 当混合使用大整数和常规数字时,JavaScript 可以默默地将一种数字类型强制转换为另一种数字类型,从而导致错误。
const a = 91111111111111e3 // a regular number
const b = 91111111111111000n // a bigint
console.log(a == b)
// 返回 false (应该是 true)
console.log(a> b)
// 返回 true (应该是 false)
比如,以上示例会看到两个常量 a 和 b 持有相同的数值。 但一个是数字,另一个是 BigInt,使用 == 和 > 等常规运算符可能会导致错误的结果。
总之,最好的办法是从一开始就尽量避免与大数字打交道,同时为了防止陷入与 BigInt 数据类型相关的难以调试的问题,使用 TypeScript 显式定义数据模型会很有帮助。
不过,值得一提的是关于 “JSON.parse source text access”的 TC39 proposal 提案已经被提出。
该提案扩展了 JSON.parse 行为以授予 reviver 函数对输入源文本的访问权限并扩展 JSON.stringify 行为以支持原始 JSON 文本基元的对象占位符的提案。
const digitsToBigInt = (key, val, {source}) =>
/^[0-9]+$/.test(source) ? BigInt(source) : val;
const bigIntToRawJSON = (key, val) =>
typeof val === "bigint" ? JSON.rawJSON(String(val)) : val;
const tooBigForNumber = BigInt(Number.MAX_SAFE_INTEGER) + 2n;
JSON.parse(String(tooBigForNumber), digitsToBigInt) === tooBigForNumber;
// → true
const wayTooBig = BigInt("1" + "0".repeat(1000));
JSON.parse(String(wayTooBig), digitsToBigInt) === wayTooBig;
// → true
const embedded = JSON.stringify({ tooBigForNumber }, bigIntToRawJSON);
embedded === '{"tooBigForNumber":9007199254740993}';
// → true
我目前在做一个 前端训练营 ,主打的就是:1v1 私教,帮大家拿到满意的 offer 。
也可以直接加我微信沟通,备注【训练营】: