背景
去年下半年,我参与了一个跨链借贷协议的前端开发。这个协议允许用户在不同链之间抵押资产并借出稳定币,前端需要实时监听用户抵押品价格的变化,一旦触发清算线就要立刻更新 UI,给用户弹窗提醒。
一开始我图省事,直接用 setInterval 每 10 秒调用一次合约的 getUserCollateral 方法,虽然能用,但体验很差------用户明明已经补仓了,UI 还要等好几秒才更新。而且频繁 RPC 调用还容易被节点限流。
后来我决定改成事件驱动:监听合约的 CollateralUpdated 事件,只要有新的抵押操作或清算操作,前端就立刻更新。听起来很简单对吧?我当时也是这么想的,结果一动手才发现全是坑。
问题分析:为什么轮询不行,事件监听也有坑?
最初的思路:用 ethers.js 的 contract.on
我第一个想到的是用 ethers.js 的 contract.on("CollateralUpdated", callback)。在 React 组件里用 useEffect 初始化监听:
typescript
useEffect(() => {
const contract = new ethers.Contract(address, abi, provider);
const handleEvent = (user, amount, event) => {
console.log("收到事件:", user, amount);
// 更新 UI
};
contract.on("CollateralUpdated", handleEvent);
return () => {
contract.off("CollateralUpdated", handleEvent);
};
}, []);
看起来没问题,但跑起来发现:事件经常收不到。特别是用户快速操作(比如连续两次抵押),第二次事件大概率会丢失。更诡异的是,在开发环境用本地节点(Anvil)时一切正常,一上 Goerli 测试网就出问题。
排查了两天,我发现了几个问题:
- ethers.js v5 的
contract.on底层用的是 WebSocket 连接,但很多公共 RPC 节点(比如 Infura 的免费层)对 WebSocket 连接数有限制,而且连接不稳定会自动断开,ethers.js 不会自动重连。 - React 组件卸载时,如果 cleanup 不彻底,事件监听会残留,导致多次触发或内存泄漏。
- 事件回调里直接调用
setState,在 React 18 的严格模式下会触发两次渲染,导致状态混乱。
转向 wagmi v2 + viem
当时正好 wagmi 发布了 v2 版本,底层从 ethers.js 换成了 viem。viem 的 watchContractEvent 支持自动重连,而且 wagmi 的 useWatchContractEvent hook 是 React 原生的,能自动处理组件生命周期。
我心想:这下总该稳了吧?结果又踩了第二个大坑。
核心实现:一步一步搭建可靠的事件监听
1. 用 wagmi 的 useWatchContractEvent 替代原生监听
先安装依赖:
bash
npm install wagmi viem @tanstack/react-query
然后配置 wagmi 客户端。这里注意,publicClient 必须指定一个稳定的 RPC 节点,我推荐用 Alchemy 或 QuickNode 的专用节点,不要用公共节点:
typescript
// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, goerli } from 'wagmi/chains';
export const config = createConfig({
chains: [mainnet, goerli],
transports: {
[mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
[goerli.id]: http('https://eth-goerli.g.alchemy.com/v2/YOUR_KEY'),
},
});
接着在组件里使用 useWatchContractEvent:
typescript
import { useWatchContractEvent } from 'wagmi';
function CollateralMonitor({ userAddress }: { userAddress: string }) {
const { data: events, isError, isLoading } = useWatchContractEvent({
address: '0x你的合约地址',
abi: [...你的ABI],
eventName: 'CollateralUpdated',
args: { user: userAddress }, // 只监听当前用户的事件
onLogs(logs) {
console.log('收到事件:', logs);
// 这里不要直接 setState,后面会讲
},
});
// ...
}
这里有个坑 :useWatchContractEvent 的 args 参数必须和合约事件定义完全一致。如果事件定义是 event CollateralUpdated(address indexed user, uint256 amount),那 args 必须是 { user: userAddress },不能写成 { userAddress }。我因为少写了 user: 字段名,浪费了半天。
2. 处理事件数据,更新 UI 状态
事件回调里不能直接调用 setState,因为 wagmi 的事件处理是在 React 的 effect 之外执行的,直接 setState 会导致 React 报 warning。正确的做法是用 useEffect 监听事件列表的变化:
typescript
import { useState, useEffect, useCallback } from 'react';
import { useWatchContractEvent } from 'wagmi';
interface CollateralEvent {
user: string;
amount: bigint;
timestamp: bigint;
}
function CollateralMonitor({ userAddress }: { userAddress: string }) {
const [collateralHistory, setCollateralHistory] = useState<CollateralEvent[]>([]);
// 用 useCallback 包装事件处理函数,避免重复创建
const handleEvent = useCallback((logs: any[]) => {
const newEvents = logs.map(log => ({
user: log.args.user,
amount: log.args.amount,
timestamp: log.args.timestamp,
}));
// 用函数式更新,避免闭包陷阱
setCollateralHistory(prev => [...prev, ...newEvents]);
}, []);
useWatchContractEvent({
address: '0x你的合约地址',
abi: [...你的ABI],
eventName: 'CollateralUpdated',
args: { user: userAddress },
onLogs: handleEvent,
});
// 用 useEffect 处理 UI 更新逻辑
useEffect(() => {
if (collateralHistory.length === 0) return;
const latest = collateralHistory[collateralHistory.length - 1];
// 检查是否触发清算
if (latest.amount < 100n) {
alert('抵押品不足,请及时补仓!');
}
}, [collateralHistory]);
return (
<div>
<h3>最近事件数: {collateralHistory.length}</h3>
{/* 渲染事件列表 */}
</div>
);
}
注意这个细节 :handleEvent 必须用 useCallback 包裹,否则每次渲染都会创建新函数,导致 useWatchContractEvent 重新订阅,浪费资源。
3. 处理多链场景:切换链时重新监听
我们的协议支持以太坊和 Polygon 两条链,用户切换链时,监听必须跟着切换。wagmi v2 的 useWatchContractEvent 会自动感知链的变化,但有个前提:你的 config 里必须配置了所有支持的链,并且 address 参数在不同链上要对应不同的合约地址。
typescript
import { useChainId } from 'wagmi';
function CollateralMonitor({ userAddress }: { userAddress: string }) {
const chainId = useChainId();
// 根据链 ID 获取合约地址
const contractAddress = chainId === 1
? '0x以太坊主网合约地址'
: '0xPolygon合约地址';
useWatchContractEvent({
address: contractAddress as `0x${string}`,
abi: [...你的ABI],
eventName: 'CollateralUpdated',
args: { user: userAddress },
onLogs(logs) {
// ...
},
});
// ...
}
这里有个大坑 :当 chainId 变化时,useWatchContractEvent 会先取消旧链的监听,再开始新链的监听。但如果在取消和开始之间有短暂的时间窗口,用户的操作可能会被遗漏。解决方法是在切换链时,先手动拉取一次历史事件,再开始监听:
typescript
import { usePublicClient } from 'wagmi';
function CollateralMonitor({ userAddress }: { userAddress: string }) {
const chainId = useChainId();
const publicClient = usePublicClient();
const [isSyncing, setIsSyncing] = useState(true);
// 链切换时,先拉取历史事件
useEffect(() => {
async function syncHistory() {
setIsSyncing(true);
const events = await publicClient.getContractEvents({
address: contractAddress,
abi: [...你的ABI],
eventName: 'CollateralUpdated',
args: { user: userAddress },
fromBlock: 'earliest',
toBlock: 'latest',
});
setCollateralHistory(events.map(...));
setIsSyncing(false);
}
syncHistory();
}, [chainId]);
// 然后开始监听新事件
useWatchContractEvent({
address: contractAddress,
abi: [...你的ABI],
eventName: 'CollateralUpdated',
args: { user: userAddress },
onLogs(logs) {
setCollateralHistory(prev => [...prev, ...logs]);
},
// 在历史同步完成前不开始监听
enabled: !isSyncing,
});
}
完整代码:一个可运行的事件监听组件
下面是一个完整的 React 组件,包含历史事件同步、实时监听、UI 更新和错误处理:
typescript
// CollateralMonitor.tsx
import { useState, useEffect, useCallback } from 'react';
import { useWatchContractEvent, usePublicClient, useChainId } from 'wagmi';
import { parseAbiItem } from 'viem';
const COLLATERAL_ABI = [
'event CollateralUpdated(address indexed user, uint256 amount, uint256 timestamp)',
];
interface CollateralEvent {
user: string;
amount: bigint;
timestamp: bigint;
txHash: string;
}
export function CollateralMonitor({ userAddress }: { userAddress: string }) {
const chainId = useChainId();
const publicClient = usePublicClient();
const [events, setEvents] = useState<CollateralEvent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 根据链 ID 获取合约地址
const contractAddress = chainId === 1
? '0x...' // 主网地址
: '0x...'; // 测试网地址
// 链切换时同步历史事件
useEffect(() => {
async function syncHistory() {
setIsLoading(true);
setError(null);
try {
const logs = await publicClient.getLogs({
address: contractAddress as `0x${string}`,
event: parseAbiItem(COLLATERAL_ABI[0]),
args: { user: userAddress as `0x${string}` },
fromBlock: 0n,
toBlock: 'latest',
});
const parsedEvents = logs.map(log => ({
user: log.args.user,
amount: log.args.amount,
timestamp: log.args.timestamp,
txHash: log.transactionHash,
}));
setEvents(parsedEvents);
} catch (err) {
setError('同步历史事件失败: ' + (err as Error).message);
} finally {
setIsLoading(false);
}
}
syncHistory();
}, [chainId, userAddress]);
// 实时监听新事件
const handleNewEvent = useCallback((logs: any[]) => {
const newEvents = logs.map(log => ({
user: log.args.user,
amount: log.args.amount,
timestamp: log.args.timestamp,
txHash: log.transactionHash,
}));
setEvents(prev => [...prev, ...newEvents]);
}, []);
useWatchContractEvent({
address: contractAddress as `0x${string}`,
abi: COLLATERAL_ABI,
eventName: 'CollateralUpdated',
args: { user: userAddress as `0x${string}` },
onLogs: handleNewEvent,
enabled: !isLoading, // 历史同步完成后再开始监听
});
if (isLoading) return <div>正在同步历史数据...</div>;
if (error) return <div style={{ color: 'red' }}>{error}</div>;
return (
<div>
<h3>抵押品变动记录(共 {events.length} 条)</h3>
<ul>
{events.slice(-10).reverse().map((event, idx) => (
<li key={event.txHash + idx}>
<span>金额: {event.amount.toString()}</span>
<span>时间: {new Date(Number(event.timestamp) * 1000).toLocaleString()}</span>
<a href={`https://etherscan.io/tx/${event.txHash}`} target="_blank">查看交易</a>
</li>
))}
</ul>
</div>
);
}
踩坑记录
-
事件丢失 :最开始用 ethers.js 的
contract.on,在 Goerli 测试网上事件经常丢失。后来发现是 WebSocket 连接不稳定,而且 ethers.js 不会自动重连。换成 wagmi v2 + viem 后,底层用的是watchContractEvent,支持自动重连和失败重试,问题解决。 -
React 状态更新错误 :在
onLogs回调里直接调用setState,控制台报"Cannot update a component while rendering a different component"。原因是 wagmi 的事件回调在 React 的 effect 之外执行。解决方案:用useCallback包装回调,并在回调里使用函数式更新。 -
链切换时的事件遗漏 :用户从以太坊切换到 Polygon 时,中间有几十秒的事件没有被监听到。原因是
useWatchContractEvent在链切换时会取消旧监听并创建新监听,但中间有窗口期。解决方法:在切换链时先手动调用getLogs拉取历史事件,等同步完成后再开始监听。 -
BigInt 序列化问题 :事件返回的
amount是bigint类型,直接渲染会报错。需要调用.toString()或Number()转换。注意Number()可能丢失精度,建议用toString()或使用viem的formatUnits工具函数。
小结
事件监听的核心坑不在于 API 怎么用,而在于如何保证不丢事件 。我的最终方案是:wagmi v2 + viem 做实时监听 + 链切换时手动同步历史 + 用 useCallback 和函数式更新处理 React 状态。如果项目需要更高级的功能(比如多链聚合、历史事件分页),可以继续研究 viem 的 getLogs 和 createContractEventFilter。