原始响应里的 ID 是 9007199254740993,进入 JavaScript 后变成 9007199254740992,再拼详情链接就找不到记录。这时先检查数字解析阶段:字节尚未拿去做摘要,值就可能已经变了。
上一篇参数字节文章讨论编码和序列化差异;本篇补充更早的一层:同一段 JSON 在不同语言中解析,得到的值是否仍是同一个业务标识。本文用固定样本验证,不请求真实站点。
一、合法 JSON 数字不等于 JavaScript 安全整数
JavaScript 的 Number 使用双精度浮点表示。安全整数范围上界是 2 ** 53 - 1,即 9007199254740991。超过安全范围后,不能再保证相邻整数都能区分;这不意味着范围外每一个整数都无法精确表示。MDN:MAX_SAFE_INTEGER。
对于业务 ID,问题比"末尾差一位"更严重:两个原本不同的 ID 可能变成相同 Number,进入 Set 或作为 Map 的键时被合并,之后即便请求全部成功,最终实体数也会变少。
二、Node.js 完整实验:找出信息丢失的时刻
运行环境:Node.js v24.19.0,日期 2026-09-11。把下方完整代码保存为 json_integer_demo.mjs,执行 node json_integer_demo.mjs。
javascript
import assert from 'node:assert/strict';
const raw = '{"id":9007199254740993,"count":2}';
const parsed = JSON.parse(raw);
assert.equal(String(parsed.id), '9007199254740992');
assert.equal(Number.isSafeInteger(parsed.id), false);
console.log('PASS ordinary parse rounds id to', String(parsed.id));
const tooLate = JSON.parse(raw, (key, value) =>
key === 'id' ? BigInt(value) : value);
assert.equal(tooLate.id, 9007199254740992n);
console.log('PASS BigInt(value) in reviver is too late');
const exact = JSON.parse('{"id":"9007199254740993","count":2}');
assert.equal(exact.id, '9007199254740993');
assert.equal(typeof exact.count, 'number');
console.log('PASS string ID survives JSON round trip');
assert.throws(() => JSON.stringify({ id: BigInt(exact.id) }), TypeError);
assert.equal(JSON.parse(JSON.stringify({ id: BigInt(exact.id).toString() })).id,
exact.id);
console.log('PASS BigInt output needs explicit JSON conversion');
let sourceSupported = false;
JSON.parse('0', (key, value, context) => {
sourceSupported = context?.source === '0';
return value;
});
if (sourceSupported) {
const recovered = JSON.parse(raw, (key, value, context) => {
// This fixture's contract: id is a non-negative decimal integer token.
if (key === 'id') {
assert.match(context.source, /^(0|[1-9][0-9]*)$/);
return BigInt(context.source);
}
return value;
});
assert.equal(recovered.id, 9007199254740993n);
console.log('PASS context.source preserves original integer token');
} else {
console.log('SKIP context.source is unavailable in this runtime');
}
const left = JSON.parse('{"id":9007199254740992}').id;
const right = JSON.parse('{"id":9007199254740993}').id;
assert.equal(new Set([left, right]).size, 1);
assert.equal(new Set(['9007199254740992', '9007199254740993']).size, 2);
console.log('PASS rounded numeric IDs collide in a Set');
assert.equal(JSON.parse('{"id":"000123"}').id, '000123');
assert.equal(String(BigInt('000123')), '123');
console.log('PASS string IDs preserve leading zeros');
console.log('Node', process.version);
实际输出:
text
PASS ordinary parse rounds id to 9007199254740992
PASS BigInt(value) in reviver is too late
PASS string ID survives JSON round trip
PASS BigInt output needs explicit JSON conversion
PASS context.source preserves original integer token
PASS rounded numeric IDs collide in a Set
PASS string IDs preserve leading zeros
Node v24.19.0
这里有两个容易混淆的 reviver 用法。BigInt(value) 接收的是已经解析过的 Number,所以只能把错误值准确地转换为 BigInt;支持 context.source 的运行时则可以从该属性拿回当前值的 JSON 原始表示。本轮 Node 支持并通过了测试,但代码仍做能力检测,不能把它当成所有旧浏览器都可用。JSON.parse 官方机制说明。
这个 reviver 仅适用于示例的已知字段约定:id 是非负十进制整数字面量。指数、小数、负数、嵌套字段的不同语义需要另行设计,不能把按键名判断的小样例当成通用无损 JSON 解析器。也不要用一个正则给整份 JSON 中的数字随意加引号。
三、Python 读取没问题,跨到 JavaScript 仍可能丢
本轮 Python 3.13.13 的标准 json.loads() 将整数 token 解析为 Python int,样本得以保留。完整代码如下,保存为 json_integer_demo.py 后运行。
python
import json
import platform
raw = '{"id":9007199254740993,"count":2}'
data = json.loads(raw)
assert data["id"] == 9007199254740993
assert isinstance(data["id"], int)
print("PASS Python int preserves integer token:", data["id"])
all_integer_strings = json.loads(raw, parse_int=str)
assert all_integer_strings == {"id": "9007199254740993", "count": "2"}
print("PASS parse_int=str changes count too:", all_integer_strings)
# Known schema: convert only the ID field before it crosses into JavaScript.
data["id"] = str(data["id"])
encoded = json.dumps(data, separators=(",", ":"))
assert json.loads(encoded) == {"id": "9007199254740993", "count": 2}
print("PASS explicit ID schema:", encoded)
print("Python", platform.python_version())
实际输出:
text
PASS Python int preserves integer token: 9007199254740993
PASS parse_int=str changes count too: {'id': '9007199254740993', 'count': '2'}
PASS explicit ID schema: {"id":"9007199254740993","count":2}
Python 3.13.13
parse_int=str 会影响所有整数 token,样例中的 count 也会变成字符串。若下游需要 count 为数字,更清楚的做法是依据字段契约仅把 ID 转为字符串。这个转换必须在精度仍然完整的阶段进行;如果上游已经输出舍入后的 ID,Python 不会自动恢复它。Python json 参数说明。
四、怎样选择字符串与 BigInt
只用于标识、拼 URL、关联和去重的 ID,优先保持字符串语义。 这样还能保留 000123 的前导零。数字大小通常不是 ID 的业务含义;若确实需要排序,另行约定排序规则。
需要精确整数运算时再使用 BigInt。 JSON 默认不能直接序列化 BigInt,出边界时要约定字符串或其他明确格式,不能让发送逻辑意外报 TypeError。此处采用显式 .toString(),不修改全局原型。MDN:BigInt 与 JSON。
已经是 Number 的大 ID,发现不安全就保留异常状态。 Number.isSafeInteger() 可作为一项检查,但无法证明上游从未丢失信息。不要按某个"看着像正确值"的规则补末位,也不要让 AI 猜回原 ID。
五、给采集与逆向脚本加一条纵向核对
在调试样本中并排记录:原始响应中的字段表示、解析后的类型与值、生成的详情 URL,以及最终存储值。找到第一个发生变化的边界,再修改对应模块。
跨语言回归至少放入安全整数上界、紧邻的两个超界 ID、字符串 ID、前导零和真实业务允许的空值。数据库、消息队列、表格导出也应按字段约定检查;本文没有验证这些外部组件,不宣称整条链路已经无损。
如果 ID 在 JSON.parse 后就合并了,后面再调整 URL 编码或请求头无法解决根因。把字段类型契约守住,才能让后续字节比对有意义。
本文使用 AI 辅助起草与校核。所附 Node.js 和 Python 实验均于上述日期在本地实际运行;未把样例结果写成真实接口或生产项目的测试成绩。