3、Ethers.js入门:Contract与EventFilter、on与once监听、事件监听器的生命周期管理
好,咱们进入Ethers.js的实战环节。说实话,很多同学写智能合约时觉得事件监听很简单,不就是等个通知嘛。但真正上了链,你会发现——嗯,坑还挺多的。我最早做DApp时,监听器写得太随意,结果页面一刷新,事件全丢了,用户跑来问我“为什么我的转账没反应?”……从那以后,我对事件监听器的管理就格外上心。
今天咱们就掰开揉碎,把Ethers.js里监听事件的几个核心概念讲清楚。你想想看,如果连事件都抓不准,那合约跟前端之间不就断了线吗?
3.1 Contract对象:你与链上合约的“翻译官”
Ethers.js里的Contract,说白了就是一个封装好的接口。你给它一个合约地址、一个ABI,它就能帮你调用合约方法、监听事件。我个人习惯把Contract实例叫做“链上代理”——你操作它,就等于在操作链上的合约。
// 创建一个Contract实例
const contract = new ethers.Contract(
contractAddress,
contractABI,
signerOrProvider
);
这里有个细节:第三个参数传signer还是provider,决定了你能做什么。传signer(钱包签名者)可以写操作(比如转账),传provider只能读。我在项目中遇到过有人传错了参数,结果调用写入方法时报错“missing signer”,排查了半天……
provider就够了。需要写操作时再换成signer。这样职责清晰,也避免不小心把私钥暴露在不安全的环境里。
3.2 EventFilter:精准捕获你关心的事件
链上事件那么多,你总不能全收吧?EventFilter就是帮你做筛选的。你可以按事件名、按参数、甚至按区块范围来过滤。
举个例子,假设你的合约有个Transfer事件:
// 定义事件过滤器
const filter = contract.filters.Transfer(
fromAddress, // 可选:过滤发送方
toAddress, // 可选:过滤接收方
null // 可选:过滤金额(null表示不限制)
);
注意,参数传null表示“不关心这个字段”。如果你只关心某个地址的转账,可以这样:
// 只监听发给我的转账
const myFilter = contract.filters.Transfer(null, myAddress);
我曾经在做一个NFT市场时,需要监听某个特定NFT的挂单事件。如果不加过滤,每次挂单都会触发回调,前端卡得不行。加上EventFilter后,性能提升非常明显。
3.3 用on与once监听事件:持续监听 vs 一次性监听
Ethers.js提供了两种监听方式:on和once。区别很简单:
- on:持续监听,每次事件触发都会执行回调。
- once:只监听一次,触发后自动移除监听器。
看代码:
// 持续监听Transfer事件
contract.on("Transfer", (from, to, value, event) => {
console.log(`转账事件:从${from}到${to},金额${value}`);
});
// 只监听一次
contract.once("Transfer", (from, to, value, event) => {
console.log("第一次转账来了,之后不再监听");
});
你可能会问:什么时候用once?我举个例子:用户发起一笔交易后,你只需要等它确认一次。用once监听交易完成事件,确认后自动停止监听,省心又省资源。
另外,on的回调函数最后一个参数是event对象,里面包含了event.log、event.blockNumber等元数据。我建议你养成习惯,把event也打印出来,调试时特别有用。
3.4 事件监听器的生命周期管理:别让监听器“野”了
这是很多新手容易忽略的地方。监听器一旦创建,就会一直存在,直到你手动移除。如果你在React组件里每次渲染都创建一个新的监听器,那就会产生大量重复监听——内存泄漏就是这么来的。
Ethers.js提供了几个管理方法:
| 方法 | 作用 |
|---|---|
contract.removeAllListeners() |
移除该合约实例上的所有监听器 |
contract.removeListener(eventName, callback) |
移除指定事件名的某个回调 |
contract.off(eventName, callback) |
与removeListener功能相同,更符合现代写法 |
我个人习惯在组件卸载时,调用removeAllListeners()清场。比如在React中:
useEffect(() => {
const handleTransfer = (from, to, value) => {
// 处理转账事件
};
contract.on("Transfer", handleTransfer);
// 组件卸载时清理
return () => {
contract.off("Transfer", handleTransfer);
};
}, []);
你看,这样就不会有“野”监听器了。我曾经接手过一个项目,里面监听器没清理,页面切了几个路由后,事件回调被触发了十几次,用户界面直接卡死。排查后发现,每个路由都创建了新的监听器,旧的还在跑……
on监听,一定要在合适的时机调用off或removeAllListeners。否则,你的DApp会像“僵尸”一样,不断消耗内存和网络资源。
3.5 实战小技巧:如何优雅地处理事件日志
事件监听的回调里,除了事件参数,还有一个event对象。这个对象里藏着很多有用信息:
contract.on("Transfer", (from, to, value, event) => {
console.log("事件日志详情:", {
blockNumber: event.blockNumber,
transactionHash: event.transactionHash,
logIndex: event.logIndex,
args: event.args // 事件参数数组
});
});
我个人建议,在正式项目中,把事件日志存到本地数据库或状态管理里。因为链上事件是“流式”的,如果你不存,页面一刷新就没了。我通常的做法是:
- 监听事件,解析出关键字段
- 存入本地缓存(比如IndexedDB或Redux)
- UI从缓存读取,而不是每次都从链上拉
这样用户体验会好很多——毕竟链上查询再快,也比不过本地读取。
好,这一章就到这里。下一章咱们会深入解析事件日志的原始数据,看看log里到底藏了什么秘密。到时候我会分享一个我踩过的坑——解析日志时索引参数和非索引参数的区别,搞错了数据全乱套。嗯,到时候细说。