跨语言传整数别丢精度:JSON、JavaScript 与 API 字段约定

浏览器、API 服务与数据库之间传递数字数据的链路

订单号在服务端是合法的 int64,到了浏览器却少了一位;请求仍成功,只是更新错了记录。根因常在“JSON 数字如何解析”没有写进接口契约。先定清取值范围、语义和编码,才能避免静默的数据损坏。

精度是在解析时丢失的

JSON 允许很长的十进制整数,接收端却未必有等宽的数值类型。浏览器中的 JavaScript Number 使用 IEEE 754 双精度浮点数,只有 -90071992547409919007199254740991 之间的整数可以逐个精确表示。服务端常见的 64 位整数则远大于这个范围。

表示方式 可精确表示的整数范围 适合的字段
JavaScript Number ±(2^53 - 1) 页码、毫秒级短时长、普通计数
有符号 64 位整数 -2^632^63 - 1 数据库主键、序号、时间戳
十进制字符串 由协议定义 跨语言 ID、金额最小单位、大整数

例如,下面的值在 JSON 文本里没有问题,但 JSON.parse 返回前已经发生了舍入;之后再转成字符串或 BigInt 都无法恢复原值:

1
2
3
4
const payload = JSON.parse('{"orderId":9007199254740993}')

console.log(payload.orderId)
console.log(Number.isSafeInteger(payload.orderId))

不要用“前端通常不会遇到这么大的 ID”作为约束。自增主键和雪花类标识都会自然越过安全整数边界,且问题往往在系统运行一段时间后才暴露。

大整数在浏览器与服务端交换时跨越精度边界

按字段语义设计编码

最稳妥的规则不是“所有数字都转字符串”,而是先区分字段用途。只用于展示和相等比较的标识符是不可拆分的令牌,应始终编码为字符串。参与计算的量必须明确单位、范围、溢出策略和客户端类型;金额可用最小货币单位的整数,避免让浮点数承担精确结算。

推荐把不安全的整数在 JSON 中直接写成十进制字符串,并在接口文档中标注格式:

1
2
3
4
5
{
"orderId": "9007199254740993",
"createdAtMs": "1785784183000",
"amountMinor": 1250
}

这里 orderId 是不透明 ID,客户端不应对它加减;createdAtMs 可能越界时也应传字符串;amountMinor 只在双方确认范围较小时保留 JSON 数字。字段名中的单位能减少“元还是分”“秒还是毫秒”的另一类隐患。

在边界处做严格校验

字符串不是放弃类型,而是把解析推迟到拥有正确类型的边界。客户端需要大整数运算时,先校验十进制格式,再构造 BigInt;服务端也应拒绝空串、小数、科学计数法和越界值。

1
2
3
4
5
6
7
8
9
function parseUnsignedId(value: string): bigint {
if (!/^(0|[1-9]\d*)$/.test(value)) {
throw new Error('invalid unsigned integer')
}

return BigInt(value)
}

const orderId = parseUnsignedId('9007199254740993')

不要在通用反序列化器里悄悄把字符串 Number(value)。应把转换集中在 DTO、请求校验器或领域适配层,并为临界值建立契约测试:2^53 - 12^53int64 最大值、前导零、负数和非法字符都要覆盖。若接口已发布数字 ID,迁移时可以先新增字符串字段并双写,确认消费者切换后再废弃旧字段,避免一次改动打断所有客户端。

以明确类型契约保护服务之间数据传递

把数字当作协议的一部分

整数精度问题难排查,是因为它通常不抛异常:日志里只是一个“看起来合理”的数字。接口评审时应为每个数值字段写下语义、单位、允许范围和 JSON 编码,再让服务端、浏览器、移动端 SDK 与数据库往返测试同一组边界样例。这样,64 位整数不再是某个语言的内部细节,而是所有消费者都能执行的协议承诺。