ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ethers.js中BigNumber与数字转换详解:避免精度丢失的实战指南

ethers.js中BigNumber与数字转换详解:避免精度丢失的实战指南 写合约交互的时候经常遇到一个情况在浏览器里console.log一个余额结果打印出来一串带有很多位数的数字后面还跟着一个[BigNumber]的警告。如果这时候直接把这个值当成普通 JS 数字传给ethers的某个方法有时候竟然会报错有时候钱算错了却找不到原因。这个问题的根源就在于ethers里的BigNumber和普通 JavaScriptNumber之间的转换关系。这篇文章我就从一个实际项目出发把ethers里bignumber和数字转换的来龙去脉、常用写法、坑和排查思路完整地拆一遍适合正在做 DApp 开发、写链上交互脚本或者刚接触 ethers 的读者参考。1. 绕不开的精度问题为什么链上数字不能直接用 Number1.1 一个真实的“归零”案例之前我在写一个 DeFi 数据监控脚本的时候需要读取某个地址的 ERC-20 代币余额。当时很自然地写了类似这样的代码const balance await token.balanceOf(userAddress); console.log(balance);在 ethers v5 里balanceOf返回的是一个BigNumber对象但如果你不处理直接把它丢进某些数学运算里问题就慢慢显现了。比如const amount balance / 1e18;这样写不是不行而是因为balance本质上是 BigNumber 对象对象做除法会自动调用toString()得到的可能是科学计数法或者不完整的值最后金额精度丢失甚至变成错误的数字。更典型的是当你把一个余额字符串“94873284928374832749832”直接传给 JS 的parseInt结果会丢失精度因为 JavaScript 的Number能表示的安全整数范围只有-2^53 1到2^53 - 1也就是约 900 多万亿。链上的代币余额动辄 1e18 甚至更大恰好远超这个范围。1.2 ethers 为什么绕不开 BigNumber以太坊生态里一个 ETH 的最小单位是 wei1 ETH 10^18 wei。这也意味着很多链上返回的值天然就是“超大整数”。如果用 JS 的Number去存任何超过安全整数范围的值都会出现“末位不准”的情况。ethers把这种情况直接挡在了外面凡是和链上交互的数值要么传BigNumber要么传符合规范的十进制字符串要么传十六进制字符串。它不会接受可能丢失精度的Number作为交易参数。换句话说如果你还在用parseFloat、toFixed去处理链上余额迟早会碰到资金数额算错的问题。我自己的经验是只要涉及链上数值一律先明确“当前值是不是 BigNumber”再决定能否直接进行算术运算。如果拿不准就用BigNumber.from()强制包装一层。这个习惯能帮你避免绝大多数精度相关的隐蔽 bug。2. 核心概念BigNumber 是什么新版旧版有什么差异2.1 ethers v5 中的 BigNumber 类在 ethers v5 里BigNumber是一个独立的工具类通常从ethers包的ethers.BigNumber或ethersproject/bignumber导入。它解决的核心问题就是“任意大整数的精确表示和运算”。常用的构造方式有const { ethers } require(ethers); // 方式一从十进制字符串 const a ethers.BigNumber.from(123456789012345678901234567890); // 方式二从十六进制字符串 const b ethers.BigNumber.from(0xde0b6b3a7640000); // 方式三从普通 JS 数字注意必须是安全整数范围内 const c ethers.BigNumber.from(1024); // 方式四从大整数 BigInt const d ethers.BigNumber.from(12345678901234567890n);在 v5 中BigNumber.from是所有转换的入口。它接收类型比较宽容数字、字符串、十六进制字符串、字节数组、BigInt 都可以。但对超出安全范围的 JSNumberBigNumber.from会直接报错比如ethers.BigNumber.from(1000000000000000000000000000000); // Error: unsafe number这其实是好事因为 JS Number 本身已经存不准了再包装也救不回来。2.2 为什么 ethers v6 改成了 bigint到了 ethers v6情况发生了变化。v6 的 API 直接把底层的数值类型切换成了 JavaScript 原生的bigint。所以合约方法返回的余额默认就是bigint而不是一个BigNumber对象。这意味着// ethers v6 const balance await token.balanceOf(userAddress); console.log(typeof balance); // bigintbigint本身是 JS 原生类型不需要额外的类库性能更好也减少了BigNumber和原生类型之间来回转换的心智负担。但 v6 里依然保留了BigNumber.from()这样的兼容接口只不过它底层大多也是转为bigint。另外v6 的工具方法位置也变了。原来在ethers.utils.parseEther和ethers.utils.formatEther新版本改成了ethers.parseEther和ethers.formatEther。这是一个很容易踩的迁移坑拿着 v5 的ethers.utils去 v6 里调用直接找不到方法。2.3 新老版本迁移时的关键差异我自己从 v5 往 v6 迁移过整理几个最影响日常代码的点项目ethers v5ethers v6链上数值返回类型BigNumber 对象bigint工具 APIethers.utils.formatEtherethers.formatEtherBigNumber 构造需要导入类支持但倾向直接使用 bigint15 位小数精度单位formatUnits(value, ether)formatUnits(value, 18)数字转 BigNumberBigNumber.from()推荐直接 String / BigInt 包装迁移的时候最省事的策略是不要把BigNumber当作核心类型去理解而是把“精确整数”当作核心概念。管它是 BigNumber 还是 bigint本质都是在表达“一个任意大小的精确整数”。在代码里统一用字符串去桥接就能大幅减少兼容问题。3. 数字转换全景从输入到输出一次讲透3.1 字符串转 BigNumber / bigint与链上交互时最稳妥的输入方式是“十进制字符串”因为字符串不会像 Number 一样丢精度。在 v5 中const amount ethers.BigNumber.from(1000000000000000000);在 v6 中甚至不需要显式转换直接传字符串也能被大部分 API 识别const amount 1000000000000000000;但如果你想把字符串统一成 bigint直接用BigInt(1000000000000000000)就行。这里有一个细节BigInt不能接收带小数点的字符串比如BigInt(1.5)会直接抛异常。所以字符串输入之前要保证是“整数格式”否则先做一次校验或转换。3.2 数字转 BigNumber 的边界条件普通的 JS 数字能不能直接转能但必须符合安全整数范围。比如const small 42; const bn ethers.BigNumber.from(small);如果你传入一个超过安全范围的数字比如10 ** 30在 v5 中会报错在 v6 中直接用BigInt(10 ** 30)得到的结果也不是最精确的。因为在执行10 ** 30的时候它已经先变成了一个不精确的 Number。所以我的习惯是如果是手动填写的常量直接写字符串如果是代码计算出来的数量先转字符串再连接。比如const total BigInt(String(parseInt(displayAmount) * 1e18));3.3 BigNumber / bigint 转成字符串和数字从 BigNumber 到普通值的转换常见的几个方法// v5 const bn ethers.BigNumber.from(1000000000000000000); bn.toString(); // 1000000000000000000 bn.toNumber(); // 注意只适合安全范围内 bn.toBigInt(); // 转成 biginttoString()是使用频率最高的方法。它输出的是十进制字符串可以安全用于 JSON 序列化、接口传输和前端显示前的过渡。toNumber()虽然方便但一旦数值超过安全范围结果就不精确。你应该只在“确认这个值很小”的场景下使用比如区块号、序号等。在 v6 中因为值本身就是 bigint直接const balance await token.balanceOf(userAddress); balance.toString(); // 十进制字符串 Number(balance); // 谨慎使用任何时候把一个 bigint 转成 Number都要先心里有数这个值在 2^53 以内吗不确定就先转字符串。3.4 单位换算wei 与 ether 的正确转换做转账时最核心的转换就是“用户输入的多少个 ETH”和“链上的 wei”之间的换算。v5 写法const { ethers } require(ethers); const toWei ethers.utils.parseEther(1.5); // BigNumber { hex: 0x14d1120d7b160000 } const fromWei ethers.utils.formatEther(toWei); // 1.5v6 写法import { parseEther, formatEther } from ethers; const toWei parseEther(1.5); // 1500000000000000000n const fromWei formatEther(toWei); // 1.5这里有几个很实际的经验parseEther接收的是字符串或数字都可以但传入数字会有精度风险建议传字符串。formatEther输出的是十进制字符串适合直接展示给用户。formatEther的结果是带小数的字符串不能直接再用来做parseEther的输入可以但两次转换之间如果走到“浮点”了要小心舍入误差。除了 ether还经常遇到其他位数的资产。比如 USDT 是 6 位小数其他 ERC-20 可能是 18 位也可能自定义。可以使用parseUnits和formatUnits// v6 const amountInUSDT ethers.parseUnits(12.34, 6); // 12340000n const display ethers.formatUnits(12340000n, 6); // 12.34这里第二个参数可以是数字也可以是单位名称字符串比如ether、gwei、wei。更严谨的写法是直接把 token 的 decimals 读出来再传进去const decimals await token.decimals(); const amount ethers.parseUnits(userInput, decimals);即使某个代币不是标准的 18 位也能正确转换。3.5 精确显示大数字格式化与千分位链上返回的原始值往往是“一长串整数”比如123456789123456789123456789。直接展示给用户很不友好。经常有人直接用Number(rawBalance / 1e18).toFixed(4)这个写法在数值大到一定程度后就不准确了。我推荐的做法是基于字符串来截断避免经过 Number 中转。在 v6 中formatEther已经帮我们做了大部分工作返回类似1234.567891234567891234的字符串。如果需要保留固定小数位可以这样写function formatDisplay(value, decimals, displayDecimals 4) { const formatted ethers.formatUnits(value, decimals); const [intPart, fracPart ] formatted.split(.); if (fracPart.length displayDecimals) { return formatted; } return ${intPart}.${fracPart.slice(0, displayDecimals)}; }这里不需要先转成 Number 再toFixed避免了精度问题。如果还想加千分位再对整数部分做正则处理即可。4. 实操过程一个转账工具的完整实现4.1 需求与设计为了把上面的知识点串起来我直接分享一个实际写过的“批量转账脚本”的核心逻辑。需求很简单读取一个 CSV 文件里面有“地址、金额”两列金额以 ETH 为单位脚本给这些地址转账。这个需求看起来简单但真正实现时会涉及不少转换细节。因为这个脚本需要处理用户输入、调用合约、监听结果所以我决定使用 ethers v6 配合 Node.js。整体流程是读取 CSV解析地址和金额。将金额从“用户友好的ETH字符串”转换为链上最小单位。构造交易参数批量发送。格式化打印结果。4.2 核心代码与解析先看读取和解析部分的代码import { parseEther } from ethers; import fs from fs; const csv fs.readFileSync(./transfer-list.csv, utf-8); const lines csv.trim().split(\n).slice(1); const transfers lines.map((line) { const [address, amountStr] line.split(,); if (!address || !amountStr) return null; const amountInWei parseEther(amountStr.trim()); return { address: address.trim(), amountInWei, }; }).filter(Boolean);这里我在每个转换点都明确类型address是字符串amountInWei是 bigint。parseEther会直接返回 bigint不需要额外套一层。接下来是发送交易的代码import { Wallet, JsonRpcProvider } from ethers; const provider new JsonRpcProvider(http://127.0.0.1:8545); const wallet new Wallet(0x...私钥..., provider); for (const tx of transfers) { const txResponse await wallet.sendTransaction({ to: tx.address, value: tx.amountInWei, }); const receipt await txResponse.wait(); console.log(${tx.address} 转账成功hash: ${receipt.hash}); }这里value字段直接接收 bigint。在 ethers v6 中交易参数里的value可以直接用 bigint这在 v5 中则需要一个 BigNumber。侧边说明一下sendTransaction内部对参数有较严格的校验如果你传入的是 Number 或者带小数的字符串它会报错或者给出警告。4.3 边界校验与结果格式化批量转账最怕“单笔失败全部重来”所以我加了几个边界校验地址合法性用isAddress判断。金额大于 0 且不超过钱包余额。金额的小数位数不超过 18 位。金额小数位校验的代码function validEtherAmount(amountStr) { const parts amountStr.split(.); if (parts.length 1) return true; if (parts[1].length 18) return false; return true; }这个校验很重要。因为parseEther(1.123456789123456789123456789)会直接抛异常或者parseEther会对超长小数做截断。如果你不提前拦截用户就会收到一串看不懂的报错信息。转账完成后我还需要把剩余余额格式化输出const remaining await provider.getBalance(wallet.address); console.log(剩余余额:, ethers.formatEther(remaining), ETH);getBalance返回的是 bigintformatEther输出字符串。这样打印出来就能直接看到剩余多少 ETH不需要手动除以 1e18。5. 实战中那些让人头皮发麻的坑5.1 比较运算的误解BigNumber 和 bigint 在比较时的差异坑了很多新手。在 v5 中BigNumber 对象的比较不能直接用比如const a ethers.BigNumber.from(1000); const b ethers.BigNumber.from(1000); console.log(a b); // false必须用.eq()方法console.log(a.eq(b)); // true到了 v6因为底层是 bigint又回到了普通比较逻辑可以直接用但要注意类型必须一致const a 1000n; const b BigInt(1000); console.log(a b); // true混合类型比较是个高发问题const balance 1000n; const amount 1000; // Number console.log(balance amount); // false这种“看起来一样但比较失败”的问题在解析接口数据时很常见。所以一定要约定所有链上数值统一用 bigint 或字符串进行比较不要一会儿 number 一会儿 bigint。5.2 序列化JSON.stringify 直接丢精度现在很多项目会把链上数据传到后端缓存或给前端展示如果直接用JSON.stringifyconst data { balance: 123456789123456789n }; JSON.stringify(data); // {balance:123456789123456789}bigint 会被自动转成字符串这其实没问题。但如果数据里还有 BigNumber 对象比如 v5 中返回的const data { balance: ethers.BigNumber.from(123456789123456789) }; JSON.stringify(data); // {balance:{type:BigNumber,hex:0x...}}这样传给后端后端要解析hex字段很麻烦。所以我的建议是在序列化之前把所有 BigNumber 或 bigint 统一转成十进制字符串再进入传输层。写一个最简单的清洗函数function serializeBalance(data) { return Object.fromEntries( Object.entries(data).map(([key, value]) [ key, typeof value bigint ? value.toString() : value, ]) ); }5.3 除法与舍入方向这是一个比想象中更隐晦的坑。BigNumber的除法是整数除法直接砍掉小数const result ethers.BigNumber.from(100).div(ethers.BigNumber.from(3)); // BigNumber { 33 }bigint 也一样const result 100n / 3n; // 33n如果你期望得到 33.33直接除是做不到的。需要先放大再除法最后格式化const result (100n * 1000n) / 3n; // 33333n // 再结合 decimals 手动格式化所以涉及百分比、单价这类场景需要先想好“保留多少位小数”再决定放大系数。这在计算交易滑点和手续费分摊时尤其重要。比如计算一笔 1.2 ETH 的 0.5% 手续费const amount parseEther(1.2); const fee (amount * 5n) / 1000n; console.log(formatEther(fee)); // 0.006算出来后依然是 bigint可以再传给合约。整个过程没有经过浮点精度不会丢。5.4 常见问题速查表结合我自己的开发经历把高频问题整理成一张速查表场景错误示例推荐做法余额展示balance / 1e18formatEther(balance)或formatUnits构造参数value: 1.5value: parseEther(1.5)比较大小两个 BigNumber 用用eq()或统一转 bigint 再比较精确计算Number(n) * 1.05全部转 bigint / BigNumber 运算JSON 序列化直接放 BigNumber 对象先toString()再传输处理小数位parseFloat(0.1).toFixed(4)用formatUnits/ 字符串处理这个表格是我在给新人 review 代码时最常贴出去的清单。照着检查一遍能挡住很多低级但致命的 bug。最后再分享一个我自己坚持的原则在ethers的世界里做好三件事就够了——输入统一解析成精确整数类型运算全程使用精确整数类型输出统一转成字符串或格式化字符串。只要不在这三个环节之间混用 Number就基本不会再出现精度问题。
返回列表