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个参数,你传了2个,Ethers.js不会报错,但过滤结果可能不对。我曾经踩过这个坑,调试了一下午才发现是参数数量不匹配。

3.3 用on与once监听事件:持续监听 vs 一次性监听

Ethers.js提供了两种监听方式:ononce。区别很简单:

  • 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.logevent.blockNumber等元数据。我建议你养成习惯,把event也打印出来,调试时特别有用。

核心区别: on是“长期订阅”,once是“一次性订阅”。用错了场景,要么内存泄漏,要么事件漏掉。

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监听,一定要在合适的时机调用offremoveAllListeners。否则,你的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  // 事件参数数组
    });
});

我个人建议,在正式项目中,把事件日志存到本地数据库或状态管理里。因为链上事件是“流式”的,如果你不存,页面一刷新就没了。我通常的做法是:

  1. 监听事件,解析出关键字段
  2. 存入本地缓存(比如IndexedDB或Redux)
  3. UI从缓存读取,而不是每次都从链上拉

这样用户体验会好很多——毕竟链上查询再快,也比不过本地读取。

总结一下: Contract是桥梁,EventFilter是筛子,on/once是开关,生命周期管理是安全锁。把这四样玩明白,事件监听这块你就拿下了。

好,这一章就到这里。下一章咱们会深入解析事件日志的原始数据,看看log里到底藏了什么秘密。到时候我会分享一个我踩过的坑——解析日志时索引参数和非索引参数的区别,搞错了数据全乱套。嗯,到时候细说。