背景:一个让我抓狂的 NFT 上架问题
去年秋天,我在帮一个 NFT 交易市场做前端重构。项目用的是 Next.js 14 App Router,合约是 OpenSea 兼容的 Seaport 协议,但前端完全不依赖 OpenSea SDK------因为我们自己改了上架逻辑,用了个简化版的 ListOrder 函数。用户上架 NFT 时,先调用合约的 approve,再调用 createOrder,然后前端得立刻显示这个 NFT 已经上架,状态从"可出售"变成"已挂单"。
问题来了:用户用 MetaMask 确认交易后,前端列表死活不更新。我一开始想,那简单,交易确认后重新 fetch 一下用户挂单列表不就行了?但我天真了------用户上架后,合约事件还没被索引服务(比如 The Graph)同步,直接调 RPC 查 userOrders 映射,返回的还是空数组。更糟的是,用户连续上架两个 NFT,第一个刚确认第二个又触发,状态全乱套。
当时项目排期紧,PM 每天问我"上架按钮点了怎么没反应",我嘴上说"链上确认需要时间",心里知道这锅不能全甩给区块链。我必须找到一个方案:用户钱包确认交易后,前端能实时 监听到 OrderCreated 事件,然后自动刷新列表,而不是靠用户手动刷新页面。
问题分析:轮询为什么不行?
我的第一版方案很简单:用 ethers.js 的 provider.on("block") 监听新区块,每出一个块就去查一次 userOrders。代码大概长这样:
typescript
// 第一版:轮询方案(已废弃)
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(MARKET_ADDRESS, MARKET_ABI, provider);
useEffect(() => {
const handleBlock = async (blockNumber: number) => {
const orders = await contract.getUserOrders(userAddress);
setOrders(orders);
};
provider.on("block", handleBlock);
return () => provider.off("block", handleBlock);
}, [userAddress]);
这个方案有几个致命问题:
- 延迟太高:以太坊平均出块时间 12 秒,用户上架后要等 12 秒才能看到变化。如果用户连着上架两个,第一个还没被索引第二个就触发了,列表会回滚到中间状态。
- RPC 调用次数爆炸:每个区块都调 RPC,如果用户页面开着不动,一小时调几百次,Infura 直接限流。我们项目用的 Alchemy 免费层,没几天就超量了。
- React 18 严格模式 :
useEffect在开发环境会执行两次,导致事件监听注册两次,然后清理函数只执行一次,最后监听器泄漏。我当时在控制台看到一堆Event listener added警告,查了半天才发现是严格模式的锅。
后来我试过用 setInterval 每 5 秒轮询,但用户体验更差------列表明明没变化,却在不停闪烁。而且用户钱包切换链时,旧的 provider 没清理,监听还在老链上跑,数据全错。
核心实现:从轮询到事件驱动的迁移
第一步:用 wagmi 的 useWatchContractEvent 替换轮询
我决定彻底放弃 ethers.js 的轮询方案,改用 wagmi v2 的事件监听。wagmi 的 useWatchContractEvent 本质上是对 eth_subscribe 的封装,通过 WebSocket 直接监听合约事件,不用自己管理 provider 和清理逻辑。
先安装 wagmi v2 和 viem:
bash
npm install wagmi viem @tanstack/react-query
然后配置 wagmi 客户端。这里有个坑:wagmi v2 默认用 HTTP 传输,要启用 WebSocket 监听事件,必须显式指定 transports 为 WebSocket 地址。
typescript
// lib/wagmi.ts
import { createConfig, http, webSocket } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
export const config = createConfig({
chains: [mainnet, sepolia],
transports: {
[mainnet.id]: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
[sepolia.id]: webSocket('wss://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'),
},
});
注意这个细节 :如果你同时需要 HTTP 请求(比如读合约状态),可以在 transports 里用 fallback:
typescript
transports: {
[mainnet.id]: fallback([
webSocket('wss://...'),
http('https://...'),
]),
}
这样 wagmi 会优先用 WebSocket,如果断开自动降级到 HTTP。我当时没加这个,结果 WebSocket 偶尔断连后,所有事件监听都失效了,页面直接卡死。
第二步:实现上架表单 + 实时监听
核心组件 ListNFTForm:用户选择 NFT,输入价格,点击上架。上架成功后,自动监听 OrderCreated 事件更新列表。
typescript
// components/ListNFTForm.tsx
'use client';
import { useState } from 'react';
import { useAccount, useWriteContract, useWatchContractEvent } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';
export default function ListNFTForm() {
const { address } = useAccount();
const [tokenId, setTokenId] = useState('');
const [price, setPrice] = useState('');
const [isPending, setIsPending] = useState(false);
// 1. 写合约:上架 NFT
const { writeContractAsync } = useWriteContract();
const handleList = async () => {
if (!tokenId || !price) return;
setIsPending(true);
try {
const tx = await writeContractAsync({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
functionName: 'createOrder',
args: [BigInt(tokenId), parseEther(price)],
});
// 这里不立刻刷新列表,等事件回调
console.log('交易已发送:', tx);
} catch (err) {
console.error('上架失败:', err);
} finally {
setIsPending(false);
}
};
// 2. 实时监听 OrderCreated 事件
const [recentOrders, setRecentOrders] = useState<bigint[]>([]);
useWatchContractEvent({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
eventName: 'OrderCreated',
// 只监听当前用户的事件
args: { seller: address },
onLogs(logs) {
// 这里有个坑:logs 可能包含多个事件,需要过滤
const newTokenIds = logs
.filter(log => log.args.seller === address)
.map(log => log.args.tokenId);
setRecentOrders(prev => [...new Set([...newTokenIds, ...prev])]);
},
});
return (
<div>
<input value={tokenId} onChange={e => setTokenId(e.target.value)} placeholder="Token ID" />
<input value={price} onChange={e => setPrice(e.target.value)} placeholder="价格 (ETH)" />
<button onClick={handleList} disabled={isPending}>
{isPending ? '上架中...' : '上架 NFT'}
</button>
<h3>最近上架的 NFT (ID):</h3>
<ul>
{recentOrders.map(id => <li key={id}>{id.toString()}</li>)}
</ul>
</div>
);
}
这里有个坑 :useWatchContractEvent 的 args 过滤参数,在 wagmi v2 中只支持精确匹配,不支持 undefined 表示"不过滤"。如果你写成 args: { seller: undefined },它会直接报错。所以要么不传 args(监听所有事件),要么传具体的地址。我当时传了 seller: address,但用户刚连接钱包时 address 是 undefined,导致监听器注册失败。后来我加了个条件:
typescript
const { address } = useAccount();
// 只有 address 存在时才注册监听
const enabled = !!address;
useWatchContractEvent({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
eventName: 'OrderCreated',
args: enabled ? { seller: address } : undefined,
onLogs(logs) { /* ... */ },
enabled, // wagmi v2 支持这个选项
});
第三步:处理跨链和多钱包切换
NFT 市场通常支持多链。用户如果在 Sepolia 上架,然后切换到 Ethereum Mainnet,之前的事件监听必须清理。wagmi 的 useWatchContractEvent 会自动根据当前链切换,但有个问题:它不会清理旧链上的订阅。
我踩了个坑:用户在 Sepolia 上架了一个 NFT,然后切换到 Mainnet,Mainnet 上也监听到了 OrderCreated 事件------因为 WebSocket 连接还在老链上跑。后来发现是 wagmi 的 webSocket 传输在切换链时不会自动断开旧连接。
解决方案:在组件卸载或链切换时手动清理。但 wagmi v2 没有暴露 unwatch 方法,我只好用 useEffect 的清理函数配合 useChainId:
typescript
import { useChainId } from 'wagmi';
export default function ListNFTForm() {
const chainId = useChainId();
// 每次链变化时,重新挂载组件
useEffect(() => {
// 清理旧状态
setRecentOrders([]);
}, [chainId]);
// ... 其余代码
}
这个方案不完美,但至少能保证链切换后列表清空,不会显示错误的数据。更优雅的方案是用 wagmi 的 useSyncExternalStore 自己写一个订阅管理器,但对我们项目来说够用了。
第四步:处理交易确认和事件延迟
用户上架后,writeContractAsync 返回的是交易哈希,不是确认状态。useWatchContractEvent 在交易被打包进区块时就触发,但此时交易可能还没被最终确认(比如 L2 的即时确认 vs L1 的 12 秒)。如果列表在交易刚打包时就更新,用户看到 NFT 已经上架,但几秒后区块重组,事件消失,列表又变回未上架状态。
我当时的解决方案:在 onLogs 回调里不直接更新状态,而是先存到本地,等交易确认后再正式更新。但这样太复杂,而且 wagmi 的 useWaitForTransactionReceipt 可以解决这个问题。
最终方案:把事件监听和交易确认分开。用户上架后,用 useWaitForTransactionReceipt 等待交易确认,确认后再主动查一次列表。同时事件监听作为辅助,提前展示"上架中"的占位状态。
typescript
// 等待交易确认
const { data: receipt } = useWaitForTransactionReceipt({
hash: txHash, // 从 writeContractAsync 返回的哈希
});
useEffect(() => {
if (receipt) {
// 交易已确认,重新查询列表
refetchOrders();
}
}, [receipt]);
这样用户体验更好:上架后立刻看到占位,交易确认后列表自动刷新,事件监听作为实时更新的补充。
完整代码:一个可运行的 NFT 上架模块
我把上述逻辑整合成一个完整的组件,可以直接复制到 Next.js 14 项目中运行。前提是你已经配好了 wagmi 客户端(参考前面的 lib/wagmi.ts)。
typescript
// components/NFTMarketplace.tsx
'use client';
import { useState, useEffect } from 'react';
import { useAccount, useChainId, useWriteContract, useWatchContractEvent, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';
import { MARKET_ABI, MARKET_ADDRESS } from '@/lib/contract';
export default function NFTMarketplace() {
const { address, isConnected } = useAccount();
const chainId = useChainId();
// 上架表单状态
const [tokenId, setTokenId] = useState('');
const [price, setPrice] = useState('');
const [txHash, setTxHash] = useState<`0x${string}` | undefined>(undefined);
// 订单列表状态
const [orders, setOrders] = useState<Array<{ tokenId: bigint; price: bigint; seller: string }>>([]);
// 写合约
const { writeContractAsync } = useWriteContract();
// 等待交易确认
const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
hash: txHash,
});
// 交易确认后刷新列表
useEffect(() => {
if (isConfirmed) {
setTxHash(undefined);
// 这里可以调用 RPC 重新查询列表
// 但为了演示,我们直接用事件数据
}
}, [isConfirmed]);
// 实时监听 OrderCreated 事件
const enabled = !!address && !!isConnected;
useWatchContractEvent({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
eventName: 'OrderCreated',
args: enabled ? { seller: address } : undefined,
onLogs(logs) {
const newOrders = logs.map(log => ({
tokenId: log.args.tokenId as bigint,
price: log.args.price as bigint,
seller: log.args.seller as string,
}));
setOrders(prev => [...newOrders, ...prev]);
},
enabled,
});
// 上架处理
const handleList = async () => {
if (!tokenId || !price || !address) return;
try {
const hash = await writeContractAsync({
address: MARKET_ADDRESS,
abi: MARKET_ABI,
functionName: 'createOrder',
args: [BigInt(tokenId), parseEther(price)],
});
setTxHash(hash);
} catch (err) {
console.error('上架失败:', err);
}
};
// 链切换时清空列表
useEffect(() => {
setOrders([]);
}, [chainId]);
if (!isConnected) return <div>请连接钱包</div>;
return (
<div style={{ padding: '20px' }}>
<h2>NFT 上架</h2>
<div>
<input
value={tokenId}
onChange={e => setTokenId(e.target.value)}
placeholder="Token ID"
style={{ marginRight: '8px' }}
/>
<input
value={price}
onChange={e => setPrice(e.target.value)}
placeholder="价格 (ETH)"
style={{ marginRight: '8px' }}
/>
<button onClick={handleList} disabled={isConfirming}>
{isConfirming ? '确认中...' : '上架'}
</button>
</div>
<h3 style={{ marginTop: '30px' }}>我的挂单</h3>
{orders.length === 0 ? (
<p>暂无挂单</p>
) : (
<ul>
{orders.map((order, i) => (
<li key={i}>
Token #{order.tokenId.toString()} - {order.price.toString()} wei
</li>
))}
</ul>
)}
</div>
);
}
这个组件可以直接放到 Next.js 14 的 app/page.tsx 里运行,前提是你已经配置好 wagmi provider 和合约地址。
踩坑记录
-
WebSocket 连接泄漏 :wagmi v2 的
useWatchContractEvent在组件卸载时不会自动断开 WebSocket。我在开发环境频繁热更新,控制台看到WebSocket connection to 'wss://...' failed。解决方式是在lib/wagmi.ts里用fallback确保 HTTP 降级,同时在组件里用enabled控制只在需要时监听。 -
React 18 严格模式导致事件重复 :
useWatchContractEvent在严格模式下会注册两次监听,但 wagmi 内部做了去重,所以不会触发两次回调。但onLogs回调里的状态更新会触发两次渲染。我在setOrders里用了函数式更新prev => [...newOrders, ...prev],避免了重复添加。 -
事件参数类型不匹配 :合约事件定义
OrderCreated(uint256 tokenId, uint256 price, address seller),但 wagmi 的log.args返回的是unknown类型。我需要手动断言log.args.tokenId as bigint。后来发现用viem的decodeEventLog可以更安全地解析。 -
链切换后事件监听不更新 :用户从 Sepolia 切到 Mainnet,
useWatchContractEvent会重新注册,但 wagmi 的webSocket传输不会自动断开旧链的连接。我加了个useEffect监听chainId,变化时清空列表,但更好的做法是用 wagmi 的useDisconnect手动清理。
小结
从 ethers.js 轮询到 wagmi 事件驱动,核心收获是:Web3 前端的状态同步,不要靠定时器,要用合约事件驱动 。wagmi v2 的 useWatchContractEvent 封装了 WebSocket 订阅和清理逻辑,但要注意链切换、严格模式和类型断言这些细节。如果你想继续深挖,可以研究 wagmi 的 useSyncExternalStore 和 viem 的 createEventFilter,实现更精细的事件过滤和批量处理。