背景
上个月,我接了一个 DeFi 仪表板项目的开发需求。这个仪表板需要实时展示用户钱包地址下的各种 ERC-20 代币余额,并且当用户进行转账、交易等操作时,余额要能够立即更新,而不是让用户手动刷新页面。
项目用的是 React + TypeScript 技术栈,Web3 部分我们选择了目前比较主流的 wagmi(v2 版本)和 viem 组合。一开始,我图省事,直接在每个代币卡片组件里用 setInterval 定时轮询用户的余额,大概每 10 秒查询一次。上线测试时问题就来了:当用户添加的代币比较多(比如超过 10 个),页面上同时有十几个定时器在跑,浏览器性能明显下降,而且网络请求暴增。更糟糕的是,用户完成一笔转账后,可能要等好几秒甚至错过一个轮询周期,余额才会更新,体验很差。
产品经理拿着测试反馈来找我:"这个延迟用户接受不了,咱们能不能像 UniSwap 那样,转账成功页面上的数字'唰'一下就变了?" 我知道,是时候抛弃轮询,上真正的"事件监听"了。
问题分析
我的第一反应是:"监听合约事件,那不就是用 ethers.js 的 contract.on 吗?" 我之前在小项目里用过,看起来挺简单的。于是我在项目里试了一下:
typescript
import { ethers } from 'ethers';
import { tokenABI } from './abi';
const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/your-key');
const contract = new ethers.Contract(tokenAddress, tokenABI, provider);
// 监听 Transfer 事件
contract.on('Transfer', (from, to, value, event) => {
console.log(`${from} 向 ${to} 转账 ${ethers.formatUnits(value, 18)} 个代币`);
});
代码跑起来了,控制台也能看到事件日志。但我很快发现了几个严重问题:
- 连接管理混乱:我的应用支持用户切换钱包(MetaMask、WalletConnect 等),也支持切换网络(Ethereum、Polygon、Arbitrum)。用上面这种方式,我需要手动管理不同 provider 和 contract 实例的创建与销毁,很容易出现内存泄漏或者监听错链的情况。
- 类型安全缺失 :
ethers.Contract对 TypeScript 的支持不够友好,事件参数的类型推断基本靠猜,容易写错。 - 与 React 生命周期脱节 :我需要把监听逻辑放在
useEffect里,并在组件卸载时手动移除监听器(contract.off)。如果忘了移除,或者移除的时机不对,监听器就会一直存在,导致奇怪的问题。
就在我头疼的时候,我重新审视了我们项目已经在用的 wagmi 和 viem。我意识到,它们作为一套更现代的 Web3 开发工具,应该对事件监听有更好的封装和支持。我决定放弃直接操作 ethers.js 的底层 API,转而研究如何用 wagmi + viem 优雅地解决这个问题。
核心实现
第一步:理解 wagmi v2 的事件监听范式
wagmi v2 的核心是 "钩子(hooks)" 和 "配置(config)" 。对于读取链上数据,它有 useReadContract;对于写入,有 useWriteContract。但对于事件监听,它并没有一个叫 useWatchContractEvent 的钩子(注:wagmi v1 有,但 v2 的设计哲学变了)。
在 wagmi v2 中,事件监听被整合到了更底层的 "观察(watch)" 能力中。具体来说,是通过 useWatchContractEvents 这个钩子来实现的。它的设计思路是:你定义好你要监听哪个合约的哪个事件(或所有事件),以及过滤条件,当链上产生匹配的新事件时,你的回调函数就会被触发。
这里有个关键点:这个钩子本身并不返回数据,它只负责设置和清理监听器。你需要提供一个回调函数来处理接收到的事件。这其实更符合 React 的响应式思维------数据变化触发副作用,而不是主动去获取。
第二步:定义合约 ABI 与配置
要监听事件,首先得让 wagmi 知道合约的接口。我选择在项目中集中管理 ABI。我为常用的 ERC-20 标准创建了一个 TypeScript 文件:
typescript
// src/abis/erc20.ts
export const erc20Abi = [
// ... 其他函数和事件
{
type: 'event',
name: 'Transfer',
inputs: [
{ name: 'from', type: 'address', indexed: true },
{ name: 'to', type: 'address', indexed: true },
{ name: 'value', type: 'uint256', indexed: false },
],
},
{
type: 'event',
name: 'Approval',
inputs: [
{ name: 'owner', type: 'address', indexed: true },
{ name: 'spender', type: 'address', indexed: true },
{ name: 'value', type: 'uint256', indexed: false },
],
},
] as const; // 注意这里的 `as const`,对类型推断至关重要
注意这个细节 :as const 断言将数组字面量变为只读元组,这样 viem 才能精确推断出每个输入/输出的类型,后面用的时候会有完美的 TypeScript 提示。
然后,在 wagmi 的配置文件(通常是 src/wagmi.ts)中,确保你的客户端配置正确,特别是公共客户端(publicClient)的 RPC URL 要稳定,因为事件监听依赖于它建立的 WebSocket 连接(如果 RPC 提供商支持的话)或长轮询。
第三步:实现核心监听钩子
接下来,我在一个显示特定代币余额的组件中实现监听。假设这个组件已经通过 props 拿到了 tokenAddress 和 userAddress。
typescript
// src/components/TokenBalanceCard.tsx
import React, { useCallback } from 'react';
import { useWatchContractEvents, usePublicClient } from 'wagmi';
import { erc20Abi } from '@/abis/erc20';
import { formatUnits } from 'viem';
interface TokenBalanceCardProps {
tokenAddress: `0x${string}`;
userAddress: `0x${string}`;
}
export const TokenBalanceCard: React.FC<TokenBalanceCardProps> = ({
tokenAddress,
userAddress,
}) => {
const publicClient = usePublicClient();
// 定义事件处理回调
const handleTransferEvent = useCallback(
(logs: any[]) => { // 暂时用 any,后面会优化类型
console.log('检测到 Transfer 事件:', logs);
logs.forEach((log) => {
const { args } = log;
// 判断事件是否与当前用户相关
if (
args?.from?.toLowerCase() === userAddress.toLowerCase() ||
args?.to?.toLowerCase() === userAddress.toLowerCase()
) {
// 触发余额重新获取的逻辑,例如设置一个状态让 useReadContract 重新查询
// 或者直接在这里根据事件参数计算新的余额(如果前端有缓存的话)
console.log(`用户 ${userAddress} 余额可能已变更,需更新UI`);
// 在实际项目中,这里通常会触发一个状态更新或重新获取
setNeedsRefresh(true); // 假设有一个状态
}
});
},
[userAddress] // 依赖项
);
// 设置事件监听
useWatchContractEvents({
address: tokenAddress,
abi: erc20Abi,
eventName: 'Transfer', // 监听特定事件
args: {
// 可选:过滤条件,只监听与用户地址相关的事件,大幅减少不必要回调
// 注意:由于 `from` 和 `to` 是 indexed 参数,可以这样过滤
// 但这里逻辑是"或",wagmi/viem 的 args 过滤似乎是"与",所以可能需要分开监听或不过滤
// 我这里先注释掉,后面在踩坑部分会讲这个问题
// from: userAddress,
// to: userAddress,
},
onLogs: handleTransferEvent,
poll: true, // 如果 RPC 不支持 WebSocket,则启用轮询作为降级方案
// pollInterval: 4_000, // 轮询间隔,默认是 4 秒
});
// ... 组件其他部分,例如用 useReadContract 显示余额
return <div>{/* ... */}</div>;
};
这里有个坑 :我一开始以为 args 过滤可以像上面注释里那样写,实现"来自用户或发给用户"的过滤。但实际上,args 对象里的多个条件是"与(AND)"关系。也就是说 { from: userAddress, to: userAddress } 只会匹配 from 和 to 都是 userAddress 的事件(这通常是销毁代币的操作)。所以,如果你想监听所有与用户相关的事件,要么不设置 args 过滤(在回调函数里做判断),要么需要更复杂的配置。我选择了在回调函数里判断,更灵活。
第四步:优化类型与性能
上面的 handleTransferEvent 参数用了 any[],这失去了 TypeScript 的优势。我们可以利用 viem 提供的类型工具来获得完美类型提示。
typescript
import { GetContractEventsParameters } from 'viem';
import { erc20Abi } from '@/abis/erc20';
// 从 ABI 和事件名推断出日志类型
type TransferLog = GetContractEventsParameters<
typeof erc20Abi,
'Transfer'
>['logs'];
const handleTransferEvent = useCallback(
(logs: TransferLog) => { // 现在 logs 有类型了!
logs.forEach((log) => {
// 现在 log.args 的类型是 { from: `0x${string}`; to: `0x${string}`; value: bigint; }
const { args } = log;
if (!args) return;
const { from, to, value } = args;
// ... 后续逻辑,from, to, value 都有明确类型
const formattedValue = formatUnits(value, 18); // 假设代币精度是 18
console.log(`转账详情: ${from} -> ${to}, 金额: ${formattedValue}`);
});
},
[userAddress]
);
性能方面,useWatchContractEvents 钩子会自动处理监听器的生命周期。当组件卸载,或者 address、abi、eventName、args 等依赖项发生变化时,旧的监听器会被清理,新的监听器会建立。这比我手动管理 contract.on 和 contract.off 要可靠得多。
第五步:处理多链与钱包切换
这是 wagmi 方案最省心的地方。我的应用通过 WagmiProvider 包裹,并配置了多个链。useWatchContractEvents 内部会使用 usePublicClient() 获取当前活跃链的公共客户端。当用户通过 RainbowKit(我们集成的钱包连接器)切换网络时,wagmi 的状态会更新,当前链的客户端也会变。useWatchContractEvents 钩子检测到 chainId 变化后,会自动销毁旧链的监听器,并在新链上建立新的监听器。整个过程对开发者是透明的,我完全不用写 if (chainId === 1) { ... } else if ... 这样的代码。
完整代码示例
下面是一个简化但可运行的组件示例,展示了如何监听事件并触发余额更新:
typescript
// src/components/TokenBalanceWithListener.tsx
import React, { useState, useCallback } from 'react';
import { useWatchContractEvents, useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from '@/abis/erc20';
import { formatUnits } from 'viem';
interface Props {
tokenAddress: `0x${string}`;
}
export const TokenBalanceWithListener: React.FC<Props> = ({ tokenAddress }) => {
const { address: userAddress } = useAccount();
const [nonce, setNonce] = useState(0); // 用于强制重新获取余额
// 1. 读取用户当前余额
const { data: balance, refetch } = useReadContract({
address: tokenAddress,
abi: erc20Abi,
functionName: 'balanceOf',
args: userAddress ? [userAddress] : undefined,
query: {
enabled: !!userAddress,
},
});
// 2. 监听 Transfer 事件
useWatchContractEvents({
address: tokenAddress,
abi: erc20Abi,
eventName: 'Transfer',
poll: true, // 确保即使没有 WebSocket 也能工作
onLogs: useCallback(
(logs) => {
if (!userAddress) return;
logs.forEach((log) => {
const { args } = log;
if (!args) return;
const { from, to } = args;
// 如果事件涉及当前用户
if (
from.toLowerCase() === userAddress.toLowerCase() ||
to.toLowerCase() === userAddress.toLowerCase()
) {
console.log('检测到与用户相关的事件,更新余额');
// 方法一:触发重新获取
refetch();
// 方法二:通过改变 nonce 使 useReadContract 的 query key 变化,也会重新获取
// setNonce((prev) => prev + 1);
}
});
},
[userAddress, refetch] // 依赖项
),
});
if (!userAddress) return <div>请连接钱包</div>;
if (balance === undefined) return <div>加载中...</div>;
return (
<div className="token-balance-card">
<h3>代币余额</h3>
<p>地址: {tokenAddress.slice(0, 6)}...{tokenAddress.slice(-4)}</p>
<p>
余额: {formatUnits(balance, 18)} {/* 再次提醒,这里精度应根据实际代币调整 */}
</p>
<p>监听非ce: {nonce}</p>
<button onClick={() => refetch()}>手动刷新</button>
</div>
);
};
踩坑记录
-
args过滤逻辑误解 :如前所述,我一开始以为args: { from: userAddress, to: userAddress }是"或"的关系,结果只监听到了self-transfer。解决方法 :仔细阅读 viem 文档,发现过滤是精确匹配且为"与"。对于"或"逻辑,要么不过滤在回调里判断,要么用更高级的topics参数手动构造过滤条件(比较麻烦)。 -
RPC 提供商不支持 WebSocket :我在测试网上用的一个免费 RPC 端点只提供 HTTP,不支持 WebSocket。导致监听器根本收不到事件。解决方法 :在
useWatchContractEvents的配置中显式设置poll: true。这样 wagmi 会使用轮询(默认 4 秒一次)来检查新区块中的事件,作为降级方案。虽然实时性稍差,但保证了功能的可用性。 -
监听所有事件导致回调过多 :有一次我忘了写
eventName: 'Transfer',结果监听了合约的所有事件。当这个代币合约活跃时,每个区块都会产生大量Approval等其他事件,回调函数被疯狂调用,页面卡死。解决方法 :务必指定要监听的特定eventName。如果确实需要监听多个事件,可以写多个useWatchContractEvents钩子。 -
类型错误:
as const缺失 :在定义 ABI 时,一开始没加as const,导致在useWatchContractEvents里abi属性的类型报错,提示"类型过于宽泛"。解决方法 :在 ABI 数组声明末尾加上as const,将其锁定为字面量类型,这样 TypeScript 和 viem 才能进行精确的类型推断。
小结
这次从轮询升级到事件监听的实践让我深刻体会到,利用好 wagmi 和 viem 这样的现代工具,能让我们更专注于业务逻辑,而不是底层连接和生命周期管理的细枝末节。核心收获是:在 React 生态中,将链上事件视为一种数据源的变化,通过声明式的钩子去响应它,是最贴合框架思维、也最不容易出错的方式。 后续可以继续深挖如何优化大量事件监听时的性能,或者如何将事件数据流与状态管理库(如 Zustand、Redux)更优雅地集成。