JavaScript BigInt 精确传输:从字符串入口到 JSON 往返都保住整数

10-01 4阅读

转换成大整数之前,原始值必须仍然完整

后端发来一个很长的计数值,前端先转成 Number,再转成 BigInt,结果尾数已经变了。后一次转换不会找回前一次舍入掉的数字:它只能把当前保存的数值变成大整数。精度约定必须从输入边界开始,等到计算或展示阶段再处理,往往已经太晚。

本例把原始十进制字符串作为可信格式边界,先检查字符规则,再交给 BigInt。这里讨论非负计数器,因此只接受零或不以零开头的十进制数字串。负数、加号、空白、小数与指数表示都不属于这份协议;这种限制来自应用约定,并不是在描述构造函数支持的全部语法。

用同一个值证明丢失、保存和恢复

下面代码可保存为 bigint-demo.cjs,再运行 node bigint-demo.cjs;需要支持 BigInt 和 Object.hasOwn 的 Node.js,无须安装依赖。原始数值选在安全整数范围之外,先展示经过 Number 的损失,再将正确的大整数写入带协议版本的普通 JSON 对象,最后按已知字段恢复。示例只操作内存数据,不修改任何内建原型。

JavaScript BigInt 精确传输:从字符串入口到 JSON 往返都保住整数

AI生成概念示意图,非真实界面

const assert = require("node:assert/strict");

function parseCounter(text) {
  if (typeof text !== "string" || text.length > 40 ||
      !/^(0|[1-9][0-9]*)$/.test(text)) {
    throw new TypeError("invalid decimal counter");
  }
  return BigInt(text);
}

function encodeCounter(value) {
  if (typeof value !== "bigint" || value < 0n) {
    throw new TypeError("counter must be a nonnegative bigint");
  }
  const counter = value.toString();
  parseCounter(counter);
  return JSON.stringify({ schema: "counter.v1", counter });
}

function decodeCounter(json) {
  const record = JSON.parse(json);
  if (record === null || typeof record !== "object" ||
      Array.isArray(record) || Object.keys(record).length !== 2 ||
      record.schema !== "counter.v1" ||
      !Object.hasOwn(record, "counter")) {
    throw new TypeError("invalid counter record");
  }
  return parseCounter(record.counter);
}

const text = "9007199254740993";
const exact = parseCounter(text);
const unsafe = Number(text);
assert.equal(Number.isSafeInteger(unsafe), false);
assert.notEqual(BigInt(unsafe), exact);
assert.throws(() => JSON.stringify({ counter: exact }), TypeError);

const wire = encodeCounter(exact);
const restored = decodeCounter(wire);
assert.equal(typeof restored, "bigint");
assert.equal(restored, exact);
assert.equal(encodeCounter(restored), wire);
assert.equal(restored + 1n, 9007199254740994n);

const invalid = [
  '{"schema":"counter.v1","counter":9007199254740993}',
  '{"schema":"counter.v1","counter":"0012"}',
  '{"schema":"counter.v2","counter":"12"}',
  '{"schema":"counter.v1","counter":"-1"}'
];
for (const payload of invalid) {
  assert.throws(() => decodeCounter(payload), TypeError);
}
console.log("exact:", exact.toString());
console.log("via Number:", BigInt(unsafe).toString());
console.log("wire:", wire);
console.log("restored:", restored === exact);
console.log("rejected:", invalid.length);

第一行应输出完整的 9007199254740993,第二行经 Number 中转后成为 9007199254740992。第三行的 counter 值带双引号,表示传输层保存字符串。最后应显示 restored: true 和 rejected: 4,说明恢复值精确相等,四类不符合协议的输入也确实遭到拒绝。

把类型恢复写进字段协议

普通 JSON 没有独立的大整数类型。直接序列化包含 BigInt 的对象会报错,示例用断言保留这个边界,再由编码函数主动将计数器变成十进制字符串。版本字段明确接收方应该采用哪套规则,解码时也检查对象形状,避免错误字段或意外数据被默默接受。

仅仅使用字符串还不够,接收方必须知道哪个字段代表整数。订单编号、电话号码和展示文本即使全部由数字组成,也可能需要保留前导零,不能统统恢复成数值。示例只转换已知的 counter 字段,其他用途应建立自己的结构和验证规则,不要对整份数据递归猜测。

解码函数设置最多四十位,是为了给这个计数协议一个清楚的规模边界,编码函数也遵守同样上限。这个数字是示例选择,不是语言极限。正式接口应依据业务容量设定字段长度,并在解析前限制请求体大小;通过字符检查之后,仍需要验证计数值是否属于允许的业务范围。

已经变成不安全数字的输入应回到源头修正

如果服务端发送的是未加引号的超大 JSON 数字,常规 JSON.parse 会走普通数字路径,可能在解码时丢失精度。随后根据字段名改成 BigInt 也只能得到已经舍入的结果。应该调整生产方的字段协议,或采用双方明确支持的无损解析方案,不能通过补零或格式化猜测原始整数。

算术过程中也要继续守住类型边界。给大整数加一使用大整数常量,直接混用普通数字进行算术通常会报错。为了展示而调用 toString 可以保留精确值;转回 Number 则必须确认值在安全范围内。把大整数交给只接受普通数字的库之前,应先确认接口能力及允许的损失。

这份验收不只比较打印出来的字符串,还检查恢复后的类型、数值相等和重新编码结果。读写两端应共享字段说明与反例,包含空串、前导零、负数、超长输入和错误版本。这样以后扩展协议时,旧消费者才能明确拒绝不理解的数据,而不是悄悄接受含义已经变化的内容。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。