前端如何监听区块链上的事件?
前端监听区块链事件,核心思路是:通过 Provider 连接链上节点,用合约 ABI 实例化 Contract 对象,再调用事件订阅方法捕获链上日志,最后在回调中更新 UI。整个过程涉及三个关键角色——Provider(网络连接)、ABI(合约接口描述)、Contract(事件订阅入口)。
事件日志是什么
智能合约用 event 关键字定义事件,emit 触发后写入交易收据的 logs 字段。事件日志不参与状态机回放,但一旦上链就不可篡改,且存储成本远低于合约状态变量。
solidity// Solidity 侧定义 event Transfer(address indexed from, address indexed to, uint256 value); function transfer(address to, uint256 amount) external { // ... 业务逻辑 ... emit Transfer(msg.sender, to, amount); // 触发事件 }
indexed 参数存入日志的 topics 数组,可用于前端高效过滤;非 indexed 参数存入 data 字段。一条事件日志最多有 3 个 indexed 参数(topics[0] 固定为事件签名哈希)。
Ethers.js v6 监听事件
Ethers.js v6 是当前新项目的首选库,API 比 v5 有较大调整:
javascriptimport { ethers } from "ethers"; // 连接节点(v6 使用 BrowserProvider) const provider = new ethers.BrowserProvider(window.ethereum); // 实例化合约 const abi = [ "event Transfer(address indexed from, address indexed to, uint256 value)" ]; const contract = new ethers.Contract(contractAddress, abi, provider); // 监听实时事件 contract.on("Transfer", (from, to, value, event) => { console.log(`${from} -> ${to}: ${ethers.formatEther(value)} ETH`); updateUI(from, to, value); }); // 查询历史事件 const filter = contract.filters.Transfer(userAddress); const events = await contract.queryFilter(filter, startBlock, endBlock); events.forEach((e) => { console.log(e.args.from, e.args.to, e.args.value.toString()); }); // 移除监听 contract.removeAllListeners("Transfer");
v6 与 v5 的关键区别:Web3Provider 改名为 BrowserProvider,BigNumber 替换为原生 BigInt,事件回调参数直接是解码后的值而非 Result 对象。
Web3.js 监听事件
Web3.js 4.x 是当前维护版本,事件订阅 API 如下:
javascriptimport Web3 from "web3"; // HTTP Provider(不支持实时推送,只能轮询) const web3 = new Web3("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"); // WebSocket Provider(支持实时推送) const wsWeb3 = new Web3("wss://eth-mainnet.g.alchemy.com/ws/v2/YOUR_KEY"); const contract = new wsWeb3.eth.Contract(abi, contractAddress); // 实时监听 contract.events.Transfer({ filter: { from: userAddress } }) .on("data", (event) => { const { from, to, value } = event.returnValues; updateUI(from, to, value); }) .on("error", (error) => { console.error("监听异常:", error.message); reconnect(); }); // 查询历史事件 const pastEvents = await contract.getPastEvents("Transfer", { filter: { to: userAddress }, fromBlock: 0, toBlock: "latest" });
HTTP vs WebSocket:HTTP Provider 无法推送实时事件,contract.events 会退化为轮询模式,延迟高且耗资源。生产环境必须使用 WebSocket Provider。
viem:更现代的替代方案
viem 是 2023 年起快速崛起的 TypeScript 库,由 Wagmi 团队维护,类型安全且 Tree-shakable:
typescriptimport { createPublicClient, http, parseAbiItem } from "viem"; import { mainnet } from "viem/chains"; const client = createPublicClient({ chain: mainnet, transport: http(), }); // 监听事件 const unwatch = client.watchEvent({ address: contractAddress, event: parseAbiItem("event Transfer(address indexed from, address indexed to, uint256 value)"), onLogs: (logs) => { logs.forEach((log) => { console.log(log.args); updateUI(log.args); }); }, }); // 停止监听 unwatch(); // 查询历史事件 const logs = await client.getLogs({ address: contractAddress, event: parseAbiItem("event Transfer(address indexed, address indexed, uint256)"), fromBlock: BigInt(startBlock), toBlock: "latest", });
viem 的优势:原生 TypeScript 类型推导、无 Provider 实例副作用、与 React(Wagmi)和 Vue(useWagmi)生态深度集成。
React 中封装事件监听 Hook
实际项目中,事件监听必须处理组件生命周期、连接断开重连、重复订阅等问题:
typescriptimport { useEffect, useRef } from "react"; import { ethers } from "ethers"; function useContractEvent( contract: ethers.Contract, eventName: string, handler: (...args: any[]) => void ) { const handlerRef = useRef(handler); handlerRef.current = handler; useEffect(() => { const listener = (...args: any[]) => handlerRef.current(...args); contract.on(eventName, listener); return () => { contract.off(eventName, listener); }; }, [contract, eventName]); } // 使用 function TransferList({ contract }) { const [transfers, setTransfers] = useState([]); useContractEvent(contract, "Transfer", (from, to, value) => { setTransfers((prev) => [...prev, { from, to, value: value.toString() }]); }); return <div>{/* 渲染转账列表 */}</div>; }
关键点:用 useRef 保持 handler 引用稳定,避免每次渲染重新绑定监听器;在 cleanup 函数中 off 移除监听,防止内存泄漏。
WebSocket 断线重连策略
WebSocket 连接不稳定是生产环境最大的坑。Alchemy/Infura 的 WS 连接在空闲 10-20 分钟后会主动断开:
typescriptclass ResilientWSProvider { private provider: ethers.WebSocketProvider; private reconnectAttempts = 0; private maxReconnectAttempts = 5; constructor(private url: string) { this.connect(); } private connect() { this.provider = new ethers.WebSocketProvider(this.url); this.provider.on("error", () => { this.attemptReconnect(); }); this.provider.websocket.onclose = () => { this.attemptReconnect(); }; } private attemptReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error("超过最大重连次数,放弃重连"); return; } const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000); this.reconnectAttempts++; setTimeout(() => this.connect(), delay); } getProvider() { return this.provider; } }
指数退避重连是标准做法,重连后需要重新绑定所有事件监听器,因为旧 Provider 实例已失效。
生产环境的架构选择
前端直接订阅链上事件只适合低频场景(如个人钱包转账通知)。高频场景(NFT 交易平台、DEX)必须引入中间层:
| 方案 | 适用场景 | 延迟 | 复杂度 |
|---|---|---|---|
| 前端直连 WS | 低频、用户级 | <1s | 低 |
| 后端监听 + WS 推送 | 中频、多用户 | 1-2s | 中 |
| The Graph 索引 | 高频、复杂查询 | 5-30s | 高 |
| 自建 indexer (Ponder/Indexer) | 高频、定制需求 | 2-10s | 高 |
The Graph 通过 subgraph 定义索引规则,前端用 GraphQL 查询,是目前最成熟的链上数据索引方案。Ponder 和 Shovel 是更新的自托管替代品。
常见踩坑总结
- MetaMask 不支持 WebSocket:
window.ethereum只提供 HTTP Provider,实时监听必须单独创建 WS 连接 - 事件丢失:节点重启或网络抖动会导致 WebSocket 推送中断,关键业务必须做历史事件补查
- fromBlock: 0 性能灾难:查询历史事件时从区块 0 开始扫描,主网上会超时,应使用部署合约的区块号作为起点
- 链重组导致假事件:新区块可能被叔块替换,监听到的临时事件会被标记为
removed: true,UI 需要处理回滚 - ABI 不匹配解析失败:事件签名必须与合约完全一致(包括参数类型和 indexed 标记),否则数据解码为 null
- 内存泄漏:单页应用路由切换时未移除监听器,导致回调堆积,Chrome DevTools 的 Event Listeners 面板可排查