1. 初识ethers.js:为什么读合约信息是Web3开发的必修课
在Web3开发里,每天打交道最多的就是链上数据。不管是做DApp前端、写自动化脚本、还是跑链上监控服务,第一步几乎都是"读合约"。读合约这件事,说简单也简单——就是调用链上的只读函数拿数据;但说复杂也复杂——ABI怎么处理、BigNumber怎么转、异常怎么捕获、通证精度怎么换算,这些坑一个接一个。
我最早用的是web3.js,后来全面切到了ethers.js,到现在已经用了快三年。说实话,如果项目不是必须兼容老代码,我建议新项目直接选ethers.js。理由很简单:它的API设计更现代,基于Promise的异步处理更自然,大小写地址校验、BigInt原生支持、gas估算这些细节做得非常到位。最让我印象深刻的是它的代码可读性——同一个合约交互逻辑,web3.js写出来几十行,ethers.js十几行搞定,而且同事维护起来也不费劲。
这篇文章我不打算讲那些官方文档里已经写得很明白的基础API,而是想从一个实战者的角度,把用ethers.js读取合约信息的完整链路拆开揉碎:从环境搭建、ABI处理、Provider选型,到各种数据类型怎么解码、常见坑怎么避开,再到多合约聚合读取和事件监听。不管你是刚接触链上开发的新手,还是已经被合约数据折腾过几轮的老人,这篇文章应该都能给你一些新的思路。
2. 环境准备与核心概念:读懂合约数据前必须搞清的三件事
2.1 环境安装与版本选择
ethers.js目前已经到v6版本,v5还在维护,但新功能基本都加在v6里。如果你跟我一样是个“版本控”,这里有个经验:生产项目直接用v6,老项目没精力升级就锁v5的5.7.x版本,别混用。两个版本在API上有一些破坏性变化,比如v6里BigNumber改用原生BigInt、ethers.utils拆分成了ethers.parseUnits和ethers.formatUnits,混用会导致莫名其妙的类型错误。
安装就一行命令:
npm install ethers如果你在Node.js环境跑脚本,建议顺便装一个dotenv用来管理私钥和RPC URL,千万别把环境变量写在代码里提交到Git仓库,这是链上开发最基本的安全习惯。
2.2 Provider是读取信息的“眼睛”
读合约信息,第一步是拿到一个Provider。Provider的作用可以理解为浏览器,你通过它去浏览区块链上的数据。ethers.js支持很多类型的Provider,最常用的有:
- JsonRpcProvider:连接你自己的节点或第三方RPC服务(如Infura、Alchemy),适合生产环境。
- EtherscanProvider:基于区块浏览器的公开API,适合快速验证,但请求频率受限。
- FallbackProvider:聚合多个Provider,自动容错和加权路由,追求稳定性的时候用它。
我自己的经验是:本地开发用JsonRpcProvider连本地节点(比如Ganache、Hardhat Network),联调测试网就选Alchemy的免费RPC,主网项目至少配两个不同服务商的Provider然后用FallbackProvider包一层,防单点故障。
获取Provider还有个坑是链ID。如果用JsonRpcProvider的默认配置,它会把链ID设为1(主网),你连BSC、Polygon这类链时必须显式传入网络参数:
const provider = new ethers.JsonRpcProvider("https://bsc-dataseed1.binance.org", 56);忘记传链ID的话,后续所有签名和交易都会默认走主网路径,读数据可能能读出来,但一旦涉及签名就铁定出错。
2.3 ABI:合约与代码之间的翻译器
读取合约信息的过程,本质上是按ABI(Application Binary Interface)里定义的接口,构造一个调用请求发给链上合约,然后解码返回值。ABI就是合约和外部代码之间的“协议字典”——它描述了合约里有哪些函数、参数是什么类型、返回值是什么类型。
获取合约ABI的常见方法有三个:
- 从区块浏览器(如Etherscan、BscScan)的“Contract”标签页下载。
- 项目内自带的编译产物,通常在
artifacts/contracts/*.json里,Hardhat和Foundry都会自动生成。 - 自己手写一个最小化的ABI片段,只包含你要调用的那几个函数,避免加载整个ABI文件。
手写最小ABI这个技巧,对于大项目来说非常实用。一个完整的合约ABI可能上百KB,但如果你只是读取余额和名称,几十行JSON就够了,加载更快、内存占用更少。比如读取ERC20通证信息的ABI只需要:
[ "function name() view returns (string)", "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function totalSupply() view returns (uint256)", "function balanceOf(address account) view returns (uint256)" ]用字符串形式写函数签名,ethers.js会自动解析,比完整的JSON格式简洁得多。
3. 核心实现:从零开始读取合约信息
3.1 最基础的读取——ERC20通证信息
一个ERC20通证的"基本信息"通常包括名称、符号、精度、总供应量,以及某个地址的余额。用ethers.js读取这些信息,代码非常简单:
const { ethers } = require("ethers"); // 1. 创建Provider const provider = new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/YOUR_KEY"); // 2. 定义合约地址和ABI const tokenAddress = "0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"; // USDC const erc20Abi = [ "function name() view returns (string)", "function symbol() view returns (string)", "function decimals() view returns (uint8)", "function totalSupply() view returns (uint256)", "function balanceOf(address account) view returns (uint256)" ]; // 3. 创建合约实例 const tokenContract = new ethers.Contract(tokenAddress, erc20Abi, provider); // 4. 调用只读函数 async function readTokenInfo(walletAddress) { const [name, symbol, decimals, totalSupply, balance] = await Promise.all([ tokenContract.name(), tokenContract.symbol(), tokenContract.decimals(), tokenContract.totalSupply(), tokenContract.balanceOf(walletAddress) ]); console.log(`Token: ${name} (${symbol})`); console.log(`Decimals: ${decimals}`); console.log(`Total Supply: ${ethers.formatUnits(totalSupply, decimals)}`); console.log(`Balance: ${ethers.formatUnits(balance, decimals)}`); } readTokenInfo("0xYourWalletAddress");这里有三个值得展开的细节。
第一,Promise.all并发调用。合约上的view函数都是只读的,不消耗gas,所以完全可以并行发起。如果逐个await,网络往返时间会累加,在读取多个字段时差距非常明显。
第二,返回值的数据类型。totalSupply和balanceOf返回的是uint256,在ethers.js v6中对应JavaScript的BigInt类型。你不能直接把它当成Number来用——超过Number.MAX_SAFE_INTEGER(大约9千万亿)的值会丢失精度。所以输出时必须用ethers.formatUnits把原始值按精度换算成人类可读的小数。
第三,ethers.Contract的第三个参数。这里传的是provider,意味着这是一个只读Contract实例。如果传signer,ethers会认为你可能会发起写操作,额外做一些签名相关的工作。只用读功能就传provider,性能更好,语义也更清晰。
3.2 读取自定义业务合约的内部状态
除了ERC20这种标准合约,实际项目中更多时候需要读的是业务合约的状态,比如借贷协议里的用户存款、DEX里的流动性池储备、质押合约的收益计算等。ethers.js读取自定义合约和读ERC20没有本质区别,关键还是ABI得对。
比如,假设我在Uniswap V2风格的去中心化交易所上想查一个交易对的储备量,合约里有getReserves()函数:
const pairAbi = [ "function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast)", "function token0() external view returns (address)", "function token1() external view returns (address)" ]; const pairContract = new ethers.Contract(pairAddress, pairAbi, provider); async function readPoolState() { const [reserve0, reserve1, timestamp] = await pairContract.getReserves(); const token0 = await pairContract.token0(); const token1 = await pairContract.token1(); console.log(`Token0: ${token0}, reserve: ${ethers.formatEther(reserve0)}`); console.log(`Token1: ${token1}, reserve: ${ethers.formatEther(reserve1)}`); }这种读取方式有一个需要注意的地方:确保ABI里的函数签名与链上合约完全一致。有些合约函数在源码里看着是getReserves(),但实际编译后可能有重载(同名不同参),如果你提供的ABI签名不够精确,ethers可能调用到错误的重载版本,甚至直接报错。
还有一点,很多业务合约会返回结构体或嵌套数组。ethers.js对结构体的解码处理得非常智能——它会自动把结构体映射为一个包含命名属性和索引属性的对象。比如getReserves()的返回值:
console.log(reserve0); // 或者 reserve0.reserve0,两者等价 console.log(result[0]); // 索引访问同样可用我个人建议在代码注释里标明返回值的具体含义和数据类型,特别是结构体字段比较多的时候。不然过两个月自己回来看代码,还得重新对着ABI捋一遍。
3.3 带参数的查询:动态传入地址和条件
读取合约信息时,很多时候需要根据用户输入动态查询,比如查任意地址对某个通证的余额。实现方式就是给合约函数传参:
async function getBalanceOf(ownerAddress) { if (!ethers.isAddress(ownerAddress)) { throw new Error("Invalid address format"); } const balance = await tokenContract.balanceOf(ownerAddress); return ethers.formatUnits(balance, 6); // USDC是6位精度 }这里有必要强调ethers.isAddress这个校验。如果你传入的地址格式不合法(比如大小写错误、长度不对),ethers.js会抛出异常。提前校验并给出明确提示,对用户体验影响很大——尤其当地址是由前端用户手输的时候。
还有一种带参场景是查询历史状态,比如某地址在某个区块高度的余额。很多合约提供了带blockNumber参数的查询函数,或者你可以直接用Provider的call在指定区块高度执行:
// 在指定区块高度读取余额 const balanceAtBlock = await tokenContract.balanceOf(walletAddress, { blockTag: 15000000 });这个blockTag参数非常有用。审计、对账、回溯分析都依赖它。注意不是所有RPC节点都支持任意历史区块的查询——像Infura的免费套餐通常只能查最近128个区块,超出就得看节点是否开了eth_getBalance的历史存档模式。遇到“查不到历史数据”的问题,先别怀疑代码,很可能是RPC服务商限制了存档范围。
4. 数据解码与格式化:BigInt、精度和地址的那些坑
4.1 BigInt与精度处理是读取信息的第一道门槛
链上所有的数值类型——uint256、int256、uint112——在ethers.js v6中都对应JavaScript的BigInt。BigInt是ES2020引入的原生类型,可以直接用字面量加n后缀表示,比如1000000n。
为什么不能用普通Number?因为JavaScript的Number是双精度浮点数,最大精确表示整数是2^53-1,即9007199254740991。而EVM里的uint256最大值是2^256-1,差了好几个数量级。如果你把一个超过安全范围的数值直接转成Number,静默的精度丢失会导致后面所有计算都出错。
读取到BigInt之后,格式化输出的标准姿势是:
// 原始值 -> 带精度的小数 const formatted = ethers.formatUnits(rawBalance, tokenDecimals); // 或者换算成ETH单位(18位精度) const formattedEth = ethers.formatEther(rawBalance); // 小数 -> 原始值(写合约、签名时用) const rawValue = ethers.parseUnits("1.5", 18);v5时代还有一个ethers.BigNumber类,v6彻底用原生BigInt替代了。迁移代码时有个常见的类型判断差异:v6里typeof balance === "bigint",v5里balance instanceof ethers.BigNumber。网上很多旧教程还是v5写法,直接抄到v6项目里会报类型错误。
4.2 地址格式的规范与校验
以太坊地址分为两种格式:全小写的0x开头42字符,或者带校验和的EIP-55混合大小写格式。ethers.js默认返回的地址是校验和格式的,比如0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48。
处理地址有几个经验:
- 从合约返回值拿到的地址,直接信任即可。
- 从用户输入拿到的地址,必须用
ethers.isAddress校验,否则后续调用很可能因为大小写问题报“invalid address”错误。 - 如果需要把地址用作对象的key,先统一做一次
ethers.getAddress(address)转成校验和格式,避免同一个地址因大小写不同被当成两个key。
顺便提醒一句,checksum地址转全小写很容易,但全小写转checksum必须靠ethers.getAddress,不能自己拼接字符串。我自己踩过一次坑:写了个快速脚本,为了减少RPC调用次数,手动把地址字符串改成小写后直接比对,结果因为校验和不匹配导致对账脚本漏数据,排查了半天才发现是大小写问题。
4.3 字节类型与复杂结构体的解码
除了常见的数字和地址,合约里还会返回bytes32、bytes、结构体数组等类型。ethers.js对这些类型的解码支持得不错,但有些细节需要留意。
比如一个合约返回bytes32类型的哈希值,直接打印出来是0x开头的十六进制字符串。如果你想把它还原成可读文本(比如字符串被编码成bytes32),可以这样:
const bytes32Value = await contract.getSomeHash(); const decodedString = ethers.toUtf8String(bytes32Value);但注意bytes32定长存储字符串时通常会以\x00结尾,直接用toUtf8String可能得到一串包含空字符的内容。遇到这种情况,可以试试ethers.decodeBytes32String,它会自动处理定长字符串的截断逻辑:
const cleanString = ethers.decodeBytes32String(bytes32Value);对于结构体数组,ethers.js会把它映射成形如[{ field1: value1, field2: value2 }, ...]的对象数组。如果结构体里嵌套了别的结构体,也能保持嵌套关系。这时候建议先打印一次完整结构,确认字段名称再写解析逻辑——你永远不会知道编译器有没有对字段名做特殊处理,先看实际输出比对着ABI猜字段名靠谱得多。
5. 事件日志读取:从合约历史中挖掘链上数据
5.1 事件的本质与查询方式
合约除了状态变量,还会在关键动作发生时抛出事件(Event)。事件的日志存储在一个独立的“日志区”,和合约状态分开。读取事件信息的价值在于:它能告诉你“过去发生了什么”,而合约函数只能告诉你“现在怎么样”。
ethers.js查询历史事件有两种方式:
queryFilter方法:基于eth_getLogs的封装,适合一次性拉取历史事件。on事件监听:持续监听新区块,适合实时通知场景。
比如我的一个链上监控脚本需要监听某个通证的转账事件:
const transferTopic = ethers.id("Transfer(address,address,uint256)"); const events = await tokenContract.queryFilter( tokenContract.filters.Transfer(fromAddress, toAddress), startBlock, endBlock ); for (const event of events) { const { from, to, value } = event.args; console.log(`Block ${event.blockNumber}: ${from} -> ${to}, amount ${ethers.formatUnits(value, 6)}`); }tokenContract.filters.Transfer(...)是一个带类型的日志过滤器构造器,它会自动把参数编码成正确的主题。这里的fromAddress和toAddress可以传null表示不筛选,只按数量过滤。
5.2 事件参数过滤的底层逻辑
理解事件过滤,必须先理解主题(Topic)机制。一个事件的签名会先被Keccak-256哈希,得到一个32字节的“事件主题”,作为日志的第一主题。被indexed标记的参数会作为额外的主题存储(最多3个),非indexed参数直接存放在日志的data字段里。
这就带来一个非常实际的影响:只有indexed参数才能高效过滤。比如ERC20的Transfer事件,from和to都是indexed,value不是。所以你想通过value过滤“转账金额大于1000”的事件,用主题过滤做不到,只能把日志拉下来后遍历data字段再自行过滤。
我统计过,一个热门通证的转账事件每天可能生成几万条日志。如果不加过滤条件地拉全量,响应时间动不动就几十秒,RPC节点还会限流。所以写过滤条件前,先想清楚你到底要筛什么,尽量把from/to这类indexed参数用上。
5.3 实时监听与WebSocket的配合
做实时监控时,JsonRpcProvider(基于HTTP轮询)效率太低,应该用WebSocketProvider:
const wsProvider = new ethers.WebSocketProvider("wss://mainnet.infura.io/ws/v3/YOUR_KEY"); const contract = new ethers.Contract(tokenAddress, erc20Abi, wsProvider); contract.on("Transfer", (from, to, value, event) => { console.log(`New transfer: ${from} -> ${to}, value: ${ethers.formatUnits(value, 6)}`); });用WebSocket要注意的就是连接管理和断线重连。ethers.js的WebSocketProvider自带了一些重连逻辑,但生产环境我建议加一层心跳检测——每隔一段事件发送一次订阅请求,确认连接还活着。另外,所有监听器在不需要时一定要用contract.removeAllListeners()清理,不然事件多了以后内存和回调堆积会非常吓人。
6. 实战案例:聚合读取多个合约的数据
6.1 循环读取多个通证的市值排名
现在串起来做一个能直接落地的实战场景:读取一串ERC20通证的信息,计算各自市值并按从大到小排个序,输出一个表格。这在实际工作里经常用到,比如做钱包资产聚合、做DeFi仪表盘。
假设我们有三个通证地址列表,ETH、USDC、LINK。代码逻辑分三步:
async function getTokenMarketData(tokenList) { const results = await Promise.all( tokenList.map(async (item) => { const tokenContract = new ethers.Contract(item.address, erc20Abi, provider); const [symbol, decimals, totalSupply] = await Promise.all([ tokenContract.symbol(), tokenContract.decimals(), tokenContract.totalSupply() ]); const price = await getPriceFromOracle(item.address); // 假设从预言机拿价格 const supplyFormatted = parseFloat(ethers.formatUnits(totalSupply, decimals)); const marketCap = supplyFormatted * price; return { symbol, marketCap, supply: supplyFormatted, price }; }) ); return results.sort((a, b) => b.marketCap - a.marketCap); }这段代码里有几个关键点值得品味。
一是Promise.all把三个通证的读取并行化。每个通证内部还有三个并发调用(symbol、decimals、totalSupply),总共9个请求并行发出,总耗时大概等于最慢那一个的耗时,而不是逐个累加。但要注意:RPC服务商通常有并发限制,比如免费套餐可能只允许同时20个请求。并发太多会被限流,返回429错误。所以如果你要循环读取几十上百个通证,最好分批,比如每次并发10个,用一个小调度函数控制节奏。
二是市场Mcap计算用到了parseFloat。这里我必须坦白一个妥协:ethers.formatUnits返回的是字符串,用parseFloat转成Number计算市值排序是ok的,因为市值本身不需要高精度,能接受浮点误差。但如果你要精确计算某个通证的余额占比之类的场景,千万别用parseFloat,直接用BigInt运算,最后再格式化输出。
三是输出格式。我会用console.table([...])来打印结果,在Node环境下能输出一张非常直观的表格。你还可以自己拼一个Markdown表格字符串,方便贴到文档或发给同事。
6.2 批量读取用户多通证余额
这个场景来自我帮一个朋友做的资产管理工具:给定一个钱包地址和一批通证,输出该地址在各通证下的余额和美元估值。核心逻辑非常模式化,但有个性能优化点值得展开。
朴素写法是一个个balanceOf调用,通证多了以后非常慢。更优雅的做法是借助“多调用合约”(Multicall)——它把多个静态调用打包进一个交易,让RPC节点一次执行完并返回多个结果。
这里我用一个简化版的Multicall演示思路,完整代码需要你自己去合约部署文档里拿Multicall3的ABI和地址:
const multicallAbi = [ "function aggregate3(tuple(address target, bytes callData)[] calls) view returns (tuple(bool success, bytes returnData)[] returnData)" ]; async function getBatchBalances(walletAddress, tokenList) { const multicall = new ethers.Contract(MULTICALL3_ADDRESS, multicallAbi, provider); const calls = tokenList.map((token) => ({ target: token.address, callData: token.interface.encodeFunctionResult("balanceOf", [walletAddress]) })); const returnData = await multicall.aggregate3.staticCall(calls); return tokenList.map((token, i) => { const [balance] = token.interface.decodeFunctionResult("balanceOf", returnData[i].returnData); return { symbol: token.symbol, balance: ethers.formatUnits(balance, token.decimals) }; }); }等等,这里有个细节我必须修正一下:encodeFunctionResult是解码用的,编码调用数据应该用encodeFunctionData:
callData: token.interface.encodeFunctionData("balanceOf", [walletAddress])如果你直接复制上面那段代码,会在第一步就卡住。这个错误正好说明了一个原则:Multicall虽然好用,但对ABI编码解码的理解要求更高,写之前先自己在REPL里验证一遍每个接口的编码结果,别直接上生产。
为什么Multicall性能提升明显?普通循环N次balanceOf意味着N次HTTP往返,每次约100-300ms;Multicall把N次调用压缩成1次HTTP请求,总耗时基本等于单次请求,从“N倍延迟”降到了“1倍延迟”。在主网这种网络拥堵的环境下,效果尤其明显。
6.3 对比快照与差异检测
还有一个我常用的场景:同一份合约数据,隔一段时间对比一次,看哪些字段发生了变化。比如监控某个预言机喂价合约的报价更新,或者监测某个治理合约的参数调整。
实现思路是用blockTag做时间旅行:
async function snapshotContractState(blockNumber) { const state = {}; state.totalSupply = await tokenContract.totalSupply({ blockTag: blockNumber }); state.balanceOfA = await tokenContract.balanceOf(addressA, { blockTag: blockNumber }); state.balanceOfB = await tokenContract.balanceOf(addressB, { blockTag: blockNumber }); return state; } const [stateBefore, stateAfter] = await Promise.all([ snapshotContractState(BLOCK_A), snapshotContractState(BLOCK_B) ]); const changes = []; for (const key of Object.keys(stateBefore)) { if (stateBefore[key] !== stateAfter[key]) { changes.push({ key, before: stateBefore[key].toString(), after: stateAfter[key].toString() }); } } console.log(`Detected ${changes.length} state changes`);这种做法的好处是支持任意历史区块对比,只要节点有存档。坏处是如果合约状态特别庞大,多次读取会占用不少RPC配额。建议针对性只对比关心的几个字段,不要全量拉取。
7. 常见问题与排查技巧实录
7.1 读不到数据的常见原因
读合约信息失败,99%的情况下问题不在合约,而在调用栈的某一层。我把这三年踩过的坑整理成了一张速查表,你自己排查的时候可以照着对:
| 症状 | 常见原因 | 排查方法 |
|---|---|---|
CALL_EXCEPTION | 合约函数执行中revert | 先确认参数类型,再检查合约是否在目标链部署 |
missing revert data | RPC节点没返回回退信息 | 换一个RPC服务商重试,或改用staticCall并捕获异常详情 |
invalid address | 地址格式错误或校验和不对 | 先ethers.isAddress()校验,再ethers.getAddress()规范化 |
返回0n或0x | 合约不存在或部署地址错误 | 检查合约地址在当前链上是否有代码:provider.getCode(address) |
| 数值莫名变大 | 精度没换算 | 用ethers.formatUnits(value, decimals),确认decimals来自token |
| 事件列表为空 | 过滤主题错误或区块范围不对 | 先用provider.getLogs({ address })不加过滤试拉,再逐步加条件 |
排查思路就一条:从下往上逐层验证。先确认RPC通不通(provider.getBlockNumber()),再确认合约地址有没有代码(provider.getCode),再确认函数签名对不对(用一个已知正确的钱包地址测试),最后才怀疑自己的业务逻辑。
7.2 合约调用报错与Revert原因定位
合约调用revert是最难排查的问题,因为错误信息往往只有一个十六进制字符串,看着像乱码。不过ethers.js提供了定位手段:用staticCall捕获完整的异常信息。
比如我读一个借贷协议的用户数据,合约内部会校验用户是否存在,如果不存在会revert("User not found")。ethers.js在捕获异常时会尝试解析revert原因:
try { const data = await lendingContract.getUserInfo(userAddress); } catch (error) { console.error(error.reason); // 如果合约有revert字符串,这里能打印“User not found” console.error(error.data); // 原始十六进制数据 }error.reason能不能正确解析,取决于ABI里是否包含了相关函数的定义,以及合约是否返回了revert字符串。如果拿到的是原始十六进制数据,可以用ethers.toUtf8String(error.data)来尝试还原可读信息。但有些revert数据是自定义错误(Custom Error),就不在toUtf8String的处理范围内了。
另外一个技巧是查eth_call的完整模拟返回。你可以把合约调用包装成一笔不带签名的交易,发送给节点让它在本地模拟执行,结果要么是成功返回的数据,要么是失败原因。这个操作在ethers里可以通过:
const estimated = await contract.getUserInfo.staticCall(userAddress, { from: "0x0000000000000000000000000000000000000000" });用staticCall模拟执行还有个好处:它完全不消耗gas,并且能拿到和真实调用一致的返回结果。我在很多脚本里都用它做“预演”——在真正触发交易前,先模拟一遍看会不会出错。
7.3 RPC限流与数据一致性
RPC限流是把人逼疯的隐形杀手。你用Infura或Alchemy的免费套餐,每分钟的请求数上限通常只有几十到几百。一旦循环读取几轮,429错误就来了。解决办法很粗暴但有效:
- 合并请求:能Multicall就Multicall,能一个请求拿多个字段就合在一起等
Promise.all完成再统一返回。 - 限速器:自己写一个简单的令牌桶,控制每秒请求数在限制以下。
- 错误重试:遇到429或网络错误,指数退避地重试(比如等500ms、1s、2s、4s),最多重试5次。
数据一致性也有一个隐性坑。假设你一次性读取了通证的totalSupply和某个地址的balanceOf,这两个请求可能被节点执行时打到了不同的区块高度(理论上概率很低,但确实存在),从而导致数据对不上。如果精确性要求高,可以用blockTag把请求固定到同一个区块高度,或者干脆用Multicall保证原子性——它是在同一区块内一步执行所有调用的。
8. 进阶技巧与性能优化
8.1 RPC缓存策略
读合约数据有个天然规律:状态变化频率远低于前端轮询频率。比如一个通证的decimals基本永不变,name和symbol也几乎不变,但很多代码对着这几个字段每次调用都重新请求。简单的做法是封装一个带内存缓存的数据访问层:
const cache = new Map(); async function cachedCall(key, fetchFn, ttlMs = 60000) { const cached = cache.get(key); if (cached && Date.now() - cached.timestamp < ttlMs) { return cached.value; } const value = await fetchFn(); cache.set(key, { value, timestamp: Date.now() }); return value; } // 用法 const decimals = await cachedCall( `${tokenAddress}_decimals`, () => tokenContract.decimals(), 3600000 // 一小时缓存 );这种缓存对name、symbol、decimals这类静态数据非常有效,可以直接缓存几小时甚至一天。对balanceOf这类高频变化的数据,缓存时间设短一点(比如5-10秒),或者干脆不缓存。最终效果是RPC请求量能下降一个数量级,限流的烦恼少了大半。
8.2 错误处理与日志规范
读取合约信息的代码通常埋在业务逻辑深处,一旦出错,调试非常痛苦。我写链上脚本时养成了一个习惯:每个RPC请求都包一层带上下文的错误日志:
async function safeContractCall(contract, method, ...args) { try { return await contract[method](...args); } catch (error) { console.error(`[${contract.target}] ${method}(${args.join(", ")}) failed`, { message: error.message, reason: error.reason, code: error.code }); throw error; } }这个方法虽然简单,但排查问题的效率提升非常明显。因为它把“哪个合约地址、哪个函数、什么参数”这些关键信息全部烙在日志里,而不是让一个光秃秃的CALL_EXCEPTION直接从业务代码里冒出来。
8.3 从读取到写入:一次完整的合约交互流程
读合约信息通常只是第一步,理解了读取的机制,写合约(发起交易)就顺理成章了。虽然这篇文章的主题是“读取”,但我觉得有必要把读写的边界讲清楚,因为很多新手在“读”和“写”之间切换时会犯糊涂。
读操作(view/pure函数)不消耗gas,不改变链上状态,直接用provider就能执行。 写操作(非view函数)需要签名、消耗gas、改变状态,必须用signer创建合约实例:
const signer = await provider.getSigner(); const contractWithSigner = tokenContract.connect(signer); const tx = await contractWithSigner.approve(spenderAddress, amount); await tx.wait();重点来了:v6里的contract.connect(signer)返回的是同一个合约的新连接实例,不会修改原合约对象。如果你在多个上下文里复用了同一个合约实例,注意别把带signer的实例误用在只读场景,反之也一样——带provider的只读实例发起写操作会报missing signer错误。
9. 项目实践中的思考与建议
写到这里,我想分享几个这三年来用ethers.js读合约信息的一些心得体会,算是对这篇文章的一个收尾吧。
第一件事是关于“技术选型要克制”。经常有人问我,ethers.js好还是web3.js好,Viem好还是wagmi好。我的答案一直没变过:工具本身没有绝对好坏,选型要考虑团队熟悉度和生态匹配度。ethers.js经历过多次重大版本升级,API设计相对稳定,第三方库支持也丰富,如果你在做一个中大型Web3项目,它大概率不会成为瓶颈。但你也要有心理准备,v5和v6之间的迁移确实会卡住一些老项目,升级前先把所有BigNumber替换成BigInt的账算清楚。
第二件事是“文档写得再好,测试网验证不能省”。我见过太多人照着文档写代码,结果部署到测试网直接碰壁——原因往往是测试网和主网之间的行为差异(比如某些测试网的RPC不稳定、合约部署地址不同、区块时间不同)。所有读取合约信息的代码,我强烈建议先在测试网跑通一遍,确认返回的数据和预期一致,再切到主网。
第三件事是“保持对数据的质疑”。链上数据绝大多数是可信的,但前提是你读到了正确的合约、正确地解码了返回值。合约升级导致的地址变更、ABI变化、代理合约里的fallback逻辑,都是隐藏的炸弹。每次读取都先打印一份原始返回值和解码后的可读值,确认没有意外,再进业务逻辑。
最后再补充一个小技巧:调试合约信息读取时,别一上来就写完整业务代码。先用一个20行的脚本,把合约地址、ABI、函数名、参数全部硬编码,跑一次拿到原始输出,确认无误后再嵌入正式项目。这个流程看起来多了一步,实际上帮你节省了至少两倍的排查时间——因为绝大部分问题都出在“接口对不上”这个环节,而不是业务逻辑本身。
ethers.js读合约信息这件事,做到极致就是六个字:懂ABI,管好精度。把这两个核心点吃透,剩下的都是熟能生巧。希望这篇文章能帮你少走一些我走过的弯路,祝你在链上数据的世界里越用越顺手。