ethers.js 中 BigNumber 与数字转换全解析:避免链上精度丢失
发布时间:2026/10/3 3:45:45 作者:尧图编辑部 阅读量:1,286

用 Number 算以太坊账小心钱包里的钱悄悄溜走。在智能合约开发或者做 DApp 数据解析时有个问题几乎每个新手都会撞上明明看着是个普通数字怎么算出来的结果就不对劲了 我第一年写合约交互代码时就因为在 ethers 里直接拿 JavaScript 的 Number 去算 wei导致一笔测试网转账差了整整 1 个 wei。你可能觉得 1 wei 无所谓但换成真实资产那就是肉眼可见的资金损失。今天这篇不是讲 API 手册而是把 ethers.js 中 BigNumber 和数字转换这套玩法彻底拆开从为什么存在、到怎么用、再到坑在哪里一次说透。如果你刚接触合约开发或者写了很久还是对BigNumber发怵这篇适合你。你不需要先把区块链原理学完只需要知道链上凡是涉及金额、余额、数量、gas 费用计算的场景几乎都离不开这个类型。1. 为什么链上数字不能直接用 JavaScript Number1.1 JavaScript Number 的精度天花板先从根本原因讲起。JavaScript 的Number是双精度浮点数它用来表示“安全整数”的范围只有-(2^53 - 1)到2^53 - 1也就是 9007199254740991大约是 9 千万亿。超出这个范围数字会开始丢精度具体表现就是你以为你算出来一个整数实际上末尾几位已经悄悄变成了随机值。举个例子你感受一下。在普通 JS 环境里执行const a 9007199254740992; // 2^53 const b a 1; console.log(a b); // 输出 true两个不同的数学数值在 JS 中比较结果是相等。这就是浮点精度的致命伤。而以太坊上最常用的单位 wei1 ETH 等于10^18wei。随便一笔普通转账就是几十万个 ether换算成 wei 就是几十万后面跟 18 个零这个量级早就把2^53甩开了十万八千里。更不用说合约里常见的uint256最大可以到2^256 - 1。在这么庞大的数值面前直接使用 Number 搞运算结果如同在流沙上盖楼。1.2 智能合约的世界里没有小数只有大整数Solidity 里的整数类型最简单也最严格uint256就是 0 到2^256 - 1的整数集合。链上不会把0.1 ETH存成小数而是存成100000000000000000wei。也就是说所有金额相关操作本质上都是对一串超大整数的加减乘除。这就带来一个转换链前端用户输入 ETH 数量小数→ 换算成 wei超大整数→ 通过合约调用发送到链上反过来链上返回 wei超大整数→ 换算成 ETH 数量小数→ 展示给用户。这两条链路中任何一环用了不安全的数值类型精度就会被破坏轻则显示异常重则交易失败或资金错配。我在一个 NFT 项目中就踩过这种坑从合约读取某个tokenId直接用Number转了一下再传给另一个合约方法结果因为 tokenId 已经超过安全范围导致授权签名校验失败。排查了半天才发现是这行不起眼的转换代码出了问题。1.3 一个反面实例直接用 Number 算会怎样我们模拟一次真实的余额计算看看会发生什么。假设你查询一个地址的余额返回值是1500000000000000000000wei也就是 1500 ETH。当你尝试用原生 JS 把它除以1e18转成 ETHconst balance 1500000000000000000000; const eth balance / 1e18; console.log(eth); // 1500这里看似没问题因为恰好可以被整除。但如果余额是1500123456789012345678wei同样的操作const balance 1500123456789012345678; const eth balance / 1e18; console.log(eth); // 1500.1234567890124你看明明只有 18 位小数结果却出现了一个多余且错误的尾巴...0124。真实场景里合约余额、Uniswap 池子数量、跨链桥验证数据到处都是这种不好整除的数字。用 Number 处理就是在给资产计数埋雷。2. ethers.js 中的 BigNumber 到底是什么2.1 历史版本里的 BigNumber 与 BN.js如果你翻过老项目会看到 ethers v5 时代可以这样引入const { BigNumber } require(ethersproject/bignumber);这个BigNumber底层依赖了一个很有名的库叫bn.js专门用来做任意精度整数运算。bn.js 的特点是非常轻量、性能好而且被大量区块链相关项目采用。ethers.js 在 v5 时期选择它作为内部大数运算引擎是综合考虑了体积、性能和稳定性之后的结果。v5 中很多返回值比如balanceOf的结果、eth.getBalance的结果、gasPrice的结果都是一个BigNumber实例。你打印出来的样式很奇怪console.log(await contract.balanceOf(addr)); // BigNumber { _hex: 0x... }很多初学者在这里就会困惑为什么不是数字也不是字符串而是一个对象这就是因为 ethers 内部遵循“永不丢精度”的原则任何可能超出安全范围的数值都统一封装成这个对象避免你在中间过程中把精度搞丢。2.2 v6 里的全新 BigNumber 实现到了 ethers v6情况变化很大。v6 完全内建了自己的大数库不再直接暴露BigNumber类的引入方式而是通过ethers.BigNumber或直接使用原生BigInt来处理大整数。v6 的做法是能返回bigint的地方尽量返回bigint同时保留了BigNumber用于一些需要链式运算和特定语法糖的场景。这里有一个关键点ethers v6 中很多原本返回BigNumber的 API 改成了返回原生bigint。比如const balance await provider.getBalance(addr); console.log(typeof balance); // bigint原生bigint是 ES2020 引入的标准类型可以表示任意精度的整数也是 JavaScript 官方给大数场景开的一扇门。但bigint无法直接和Number混合运算和BigNumber的方法风格也不同所以你在看不同版本的教程时经常会被搞晕。为了解决这种困惑你只需要记住一句话v5 看 BigNumberv6 看 bigint但转换思路和原理一致。两者的内存模型都是把一个超长整数拆成多个部分用数组来存储再通过统一的算法做加、减、乘、除、幂运算从而保证精度无损。2.3 关键特性不可变性与链式操作BigNumber和原生bigint都是不可变的。这意味着任何运算方法都不会修改原对象而是返回一个新值。例如const a BigNumber.from(100); const b a.add(50); console.log(a.toString()); // 100a 没有被改变 console.log(b.toString()); // 150这个设计非常重要。在复杂的合约交互流程里你可能需要把一个基础值反复用作后续多步计算的输入。如果某个中间步骤偷偷把原值改了整个流程就全乱了。不可变的设计让你可以放心地重复使用同一个初始值。链式操作则是BigNumber比较方便的一点可以一次串联多个操作const result BigNumber.from(100) .add(50) .mul(3) .sub(10) .div(2); // 等价于 (100 50) * 3 - 10) / 2 220对比原生bigint的写法需要用括号把每一步包起来可读性差不少。3. 数字转换的全套实操从各类输入到 BigNumber3.1 使用 BigNumber.from 处理各种输入源无论你是 v5 还是 v6只要还能接触BigNumber.from就需要知道它支持哪几种输入类型。按照我的使用经验最常见的输入源有这么几类十进制数字字符串12345678901234567890这是最常见的形式因为很多 JSON 接口返回的数值都是字符串。十六进制字符串0xde0b6b3a7640000这种带0x前缀的。Number 类型仅限安全整数范围内比如123。BigNumber.from(1e21)这种会直接报错或产生不可预期的结果。bigint原生大整数类型v6 里很多接口直接返回它可以直接传入。Uint8Array字节数组形式主要用在密码学和序列化场景日常开发很少见。代码示例可以这样写const { BigNumber } require(ethers); // 从字符串创建 const a BigNumber.from(1000000000000000000000); console.log(a.toString()); // 1000000000000000000000 // 从十六进制创建 const b BigNumber.from(0x0de0b6b3a7640000); console.log(b.toString()); // 1000000000000000000 // 从 bigint 创建 const c BigNumber.from(1000000000000000000000n); console.log(c.toString()); // 1000000000000000000000 // 从安全的 Number 创建 const d BigNumber.from(2024); console.log(d.toString()); // 2024在实际项目中后端给你的数据多半是字符串因为 JSON 数字类型没法安全表示大整数接口规范成熟的团队会主动用字符串返回数值字段。所以你在对接时最保险的策略是不管来源是什么第一步全部转成字符串再看情况送入BigNumber.from。3.2 反向转换BigNumber 转 string、number、hex、bigint拿到BigNumber之后你又需要把它转成各种目标格式。这里最容易踩坑我拆开来说。转字符串是最安全的展示方式const balance BigNumber.from(123456789012345678901234567890); console.log(balance.toString()); // 123456789012345678901234567890无论多大toString()都可以完整表达不会丢精度。所有需要拼接进请求参数、写入数据库、传给后端处理的场景都应该优先使用字符串形式。转 Number 需要你内心有数const small BigNumber.from(42); console.log(small.toNumber()); // 42如果这个BigNumber超过安全范围toNumber()会直接抛出异常这是 ethers 的自我保护机制。如果你确实只需要展示给用户看不参与后续计算可以考虑先转字符串再转 Number但要注意结果可能不精确。我们后面会专门讲这个问题的处理方式。转十六进制字符串用于链上编码const amt BigNumber.from(1000000000000000000); console.log(amt.toHexString()); // 0xde0b6b3a7640000合约方法的数据编码、事件日志的解析很多时候需要十六进制形式。toHexString()默认不带补零如果你需要固定长度的字节串可能需要自己手动补齐或者用其他编码工具。转 bigint 用于与其他现代库协作const amt BigNumber.from(1000000000000000000); const big amt.toBigInt(); console.log(big); // 1000000000000000000n如果你在 v6 项目里大量使用原生 bigint把BigNumber转成bigint可以无缝接入原生运算。3.3 使用formatUnits与parseUnits完成链上与展示单位互转这部分是日常开发里用得最频繁的也是我最想强调的。parseUnits负责把用户输入的可读数字转成链上的最小单位数值formatUnits反过来把链上最小单位数值格式化成可读数字。const { parseUnits, formatUnits } require(ethers); // 把 1.5 个 ETH 转成 wei const wei parseUnits(1.5, 18); console.log(wei.toString()); // 1500000000000000000 // 把 1500000000000000000 wei 格式化回 ETH const eth formatUnits(wei, 18); console.log(eth); // 1.5这里有两个细节很多人忽略第一parseUnits的第一个参数建议传字符串不要传 Number。原因是parseUnits(0.1, 18)这种写法在 JS 里先把0.1解析为浮点数再转成字符串时已经带上了浮点误差结果就是你会得到类似100000000000000000001这种诡异数值。我见过不少线上的解析问题最后都定位到这一行。所以记住前端用户输入框得到的永远是字符串直接传字符串进去最安全。第二formatUnits返回的是字符串不是 Number。这样设计是有意的因为小数位数可能非常多用 Number 承载会出现科学计数法和精度丢失。你直接把这个字符串渲染到 UI 上就行如果嫌小数位太长可以用parseFloat再做一次性展示。顺带一提ethers 还提供了两个快捷方法parseEther和formatEther它们固定使用 18 位小数。如果你确定操作的是 ETH 主币用这两个方法写起来更简洁const wei parseEther(0.05); console.log(wei.toString()); // 50000000000000000 const eth formatEther(wei); console.log(eth); // 0.054. 实战演练从余额查询到交易组装4.1 场景一查询地址余额并格式化展示这个场景中你要解决的核心问题是链上返回的余额数值很大且来自不同网络精度可能不同。比如某些代币只有 6 位小数USDT主币才是 18 位。const { ethers } require(ethers); const provider new ethers.JsonRpcProvider(https://你的RPC节点地址); const address 0x你的目标地址; async function getBalance() { const balanceWei await provider.getBalance(address); console.log(原始wei, balanceWei.toString()); // 转成 ETH 展示主币精度 18 const ethDisplay ethers.formatEther(balanceWei); console.log(ETH 余额, ethDisplay); }如果你是查 ERC20 代币余额需要额外拿到代币精度async function getTokenBalance(tokenAddress, holderAddress) { const abi [function balanceOf(address) view returns (uint256), function decimals() view returns (uint8)]; const contract new ethers.Contract(tokenAddress, abi, provider); const rawBalance await contract.balanceOf(holderAddress); const decimals await contract.decimals(); const display ethers.formatUnits(rawBalance, decimals); return display; }这个场景的注意点在于decimals()方法的返回值是链上存储的小数位数字所以必须动态获取不能写死。如果你的合约只对接自家项目精度固定为 18那可以省略这步但凡要兼容 USDT、USDC 这类 6 位小数的代币动态获取就是必须的。4.2 场景二组装一笔转账交易的 data 字段在钱包工具或者批量转账工具中我们需要手动把函数选择器和参数编码到 calldata 里。此时 BigNumber 和十六进制的转换是关键环节。const { Interface } require(ethers); const iface new Interface([ function transfer(address to, uint256 amount) public returns (bool) ]); const toAddress 0x接收地址; const amountWei ethers.parseUnits(12.5, 18); const data iface.encodeFunctionData(transfer, [toAddress, amountWei]); console.log(data); // 0xa9059cbb 地址补齐32字节 amount补齐32字节这里的核心要点是encodeFunctionData接受的是一个BigNumber或bigint而不是字符串。ethers 内部在 ABI 编码时会把大整数统一编码成 32 字节的十六进制这个过程如果传入 Number 且超出安全范围就会编译失败或得到错误 data。所以组装交易时建议始终走parseUnits获取目标值。4.3 场景三对比两个大数并计算差值在 DeFi 数据分析和套利策略中比较大小和计算差值是家常便饭。直接用 Number 判断很容易出问题我写过一个监控脚本里面到处是这样的逻辑const { BigNumber } require(ethers); const priceA BigNumber.from(2000000000000000000); // 2 ETH const priceB BigNumber.from(1999999999999999999); // 1.999... ETH if (priceA.gt(priceB)) { const diff priceA.sub(priceB); console.log(价差(wei), diff.toString()); } else { console.log(无套利机会); }BigNumber提供了完整比较方法gt、gte、lt、lte、eq、isZero()实际使用中比原生和更清晰尤其是在处理 bigint 和 BigNumber 混用的时候可以省去很多类型转换。4.4 小技巧gas 费计算的安全加法一次链上交互往往需要预估 gas而 gas 费计算涉及gasPrice × gasLimit这两个值都是 BigNumber 或 bigint。很多人图省事直接写成const gasCost gasPrice * gasLimit;如果这俩都是普通 Number且数值较大结果会很难看。正确做法const gasCost gasPrice.mul(gasLimit);或者用 bigintconst gasCost gasPrice * gasLimit; // bigint 支持 * 操作符最终传给交易对象的gasPrice、gasLimit以及value字段也应当是大整数类型否则 ethers 可能在发送交易前进行隐式转换带来意想不到的失败。5. 常见问题与排查技巧实录5.1toNumber()抛错integer overflow这是个高频报错。报错信息类似const big BigNumber.from(123456789012345678901234567890); big.toNumber(); // Uncaught Error: integer overflow原因很简单这个值超出了 JS Number 的安全范围。解决方案就是从设计上避免使用toNumber()做业务逻辑只用在你知道数值很小的场景比如tokenDecimals、chainId、tokenId这类明确处于安全范围的字段。如果你确实需要一个大数给用户展示正确流程是// 错误示范 const display balance.toNumber() / 1e18; // 正确示范 const display ethers.formatEther(balance);当然formatEther的结果是字符串。如果你后续要排序或者计算建议转成 number 后只在展示层使用用它做交易逻辑会有精度风险。5.2 字符串大数被 JS 自动转成科学计数法这个问题最容易发生在从 API 拿数据时。比如后端返回的 JSON 字段里写的是1500000000000000000000但如果你用JSON.parse解析时字段没加引号const data JSON.parse({amount: 1500000000000000000000}); console.log(data.amount); // 1.5e21 被当成 Number 处理精度全丢实际上JSON 标准里超过安全范围的数字直接就是有问题的。所以靠谱的后端接口在这种字段上都会返回字符串{amount: 1500000000000000000000}。如果你在对接时发现后端返回的是数字类型要尽快推动对方改掉否则无论前端怎么补救原始精度已经丢了。这在数字资产场景里是不可接受的。5.3 v6 里混合使用 bigint 和 BigNumberv6 的 API 有的返回bigint有的返回BigNumber新手混用时会报类似这样的错BigNumber.from(100n).add(BigNumber.from(200));这实际上是可以的两种情况都可以传入。但如果你直接写BigNumber.from(100) 200n;这里就会直接抛类型错误因为运算符不能混用 BigInt 和普通类型。我个人的建议是在对 ethers 内部 API 操作时就统一用BigNumber.from把它们都包一层在写业务逻辑时如果用 bigint 就用ethers.toBigInt统一转尽量避免在同一个表达式里混用两种类型。5.4 十六进制字符串带不带 0x 的差别BigNumber.from(0x10)会正常解析成 16而BigNumber.from(10)会解析成 10。如果你手上的数据有两种来源一个习惯带0x一个不加建议统一做一次预处理如果是纯数字字符串就按十进制处理如果是十六进制字符串确认有0x前缀。这里有个很基础但容易忽略的点不要用parseInt(0x10)去做十六进制转换因为parseInt的进制参数是需要显式指定的默认可能猜错。在 ethers 环境下直接用BigNumber.from(hexString)就够稳定。5.5 单位搞混导致的数量级错误这是一个典型的“看着没问题实际上差了一个宇宙”的错误。你从合约方法拿到一个返回值可能是 6 位小数也可能是 18 位小数如果不加区分直接parseEther或者formatEther就会把数量级搞错。举个例子某合约的decimals()返回 6实际余额是2000000000即 2000 USDT。如果你误认为 18 位用formatEther格式化得到的就是0.000000002完全对不上。排查这种问题的时候第一步永远是打印原始大整数值然后确认decimals再套用对应的formatUnits。5.6 浮点数中间计算结果如何保留精度有时候你确实需要在客户端做除法比如计算收益率、份额占比。BigNumber的div是整数除法直接a.div(b)会把小数部分全部丢掉。比如const a BigNumber.from(100); const b BigNumber.from(3); console.log(a.div(b).toString()); // 33小数部分 0.333... 直接丢失如果你需要保留小数位就需要先放大再除或者借助formatUnits配合parseUnits的思路。比如计算 70% 的份额const total BigNumber.from(1000000000000000000); const rate BigNumber.from(70); const result total.mul(rate).div(100); console.log(result.toString()); // 700000000000000000正是 70%这个技巧在计算价格滑点、手续费比例时非常实用。核心逻辑就是先乘一个足够大的数如百分比基数再做除法避免中间结果的精度损失。如果你要更高精度的浮点运算应该引入专门的十进制浮点库千万不要拿Number直接处理。5.7 地址和 tokenId 超过 53 位也需要 BigNumber很多非金融场景同样要注意。比如 NFT 的tokenId很多时候会大于2^53如果你用普通 Number 去处理可能在调用合约方法时因为参数类型不匹配而失败。我在一次批量查询 NFT 元数据的脚本中就遇到过tokenId在 JS 里显示为科学计数法导致后续请求全部 404。后来定位到问题是tokenId被隐式转成了浮点数加个toString()就恢复了。所以判断是否该用 BigNumber/BigInt 的标准很简单这个值是否有可能超过 9007199254740991如果有可能一律用大整数类型不管它看起来像不像金额。6. 从经验出发整理一套稳妥的数字处理方案总结我自己在多个项目里沉淀下来的处理规范你照着做基本能避开绝大多数坑。先确定输入链路。凡是来自链上、来自后端接口、来自用户输入的数值默认都当字符串处理。进入 ethers 环境后统一调用BigNumber.from或ethers.toBigInt不要在这个环节用 Number。业务计算全用 BigNumber/BigInt。无论是加、减、乘、除、比较全部在 BigNumber 的体系里完成不要中途转 Number 再转回去。中间任何一次 Number 转换都可能造成不可逆的精度丢失。只在展示层做格式化。格式化用formatUnits/formatEther得到字符串后再交给 UI 渲染。确需参与图表展示或前端排序时才考虑把字符串转 Number且只能说“展示用”不能回流到交易逻辑。遇到单位差异先问精度。任何 ERC20 代币操作第一步是读取decimals()不要假设所有代币都是 18 位。主币是 18 位没错但稳定币常有 6 位有些定制合约甚至用 8 位或 2 位。v5 和 v6 的差异做到心里有数。如果你维护老项目v5 的BigNumber用ethersproject/bignumber引入新项目直接用 v6 内置的ethers.BigNumber或原生bigint。倒不是说必须二选一而是你要清楚当前项目用的是哪个版本别照搬 v5 的代码到 v6 里。把常见转换封装成工具函数。经验丰富的团队都会在前端项目里维护一个format.ts或者utils.ts里面统一封装parseAmount、formatAmount、parseBigIntToDisplay这类函数。这样整个项目只用这一套转换逻辑不会出现每个人各写各的、一半代码用 Number 一半用 BigNumber 的混乱状态。我在一个跨链桥项目里就做过这种事把所有的数值输入输出全部收敛到统一工具层其他模块只能调用工具函数禁止直接操作 BigNumber。效果非常显著上线半年多没有再出现一起因为精度问题导致的资产类 bug。7. 一个容易被忽视的小点console.log 看不明白时的调试法很多人在处理 BigNumber 时习惯直接console.log看结果。但打印出来的是BigNumber { _hex: 0x... }不够直观看多了容易眼花。我建议调试时统一这样输出console.log(balance:, balance.toString());关键节点全部转成字符串再打印一眼就能看出值对不对。如果涉及单位转换就直接打印格式化后的结果console.log(balance eth:, ethers.formatEther(balance));另外在写合约测试脚本时我经常会对 BigNumber 做一种“边界测试”把最小值和最大值都跑一遍比如0、1 wei、2^256 - 1这三个极值如果都不报错基本可以证明你的代码对数值范围是安全的。如果调试中怀疑某个值是 Number 还是 BigNumber最快速的判断方法是console.log(typeof value); console.log(BigNumber.isBigNumber(value));BigNumber.isBigNumber是官方提供的静态判断方法很实用。用 bigint 的话直接看typeof value bigint即可。最后再多说一句。我在处理完这些转换问题后最大的体会是区块链开发里最危险的不是合约漏洞而是前端开发者在不知不觉中把精度丢掉的这一层。BigNumber 不是一种束缚而是加密世界里保护你资产安全的基本常识。你只要把它用顺手后面无论遇到 DeFi、NFT 还是跨链工具都会轻松很多。