用 wagmi v2 + WebSocket 硬磕 NFT 上架失败:一个前端开发者踩过的实时状态同步坑

背景:一个让我抓狂的 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.jsprovider.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]);

这个方案有几个致命问题:

  1. 延迟太高:以太坊平均出块时间 12 秒,用户上架后要等 12 秒才能看到变化。如果用户连着上架两个,第一个还没被索引第二个就触发了,列表会回滚到中间状态。
  2. RPC 调用次数爆炸:每个区块都调 RPC,如果用户页面开着不动,一小时调几百次,Infura 直接限流。我们项目用的 Alchemy 免费层,没几天就超量了。
  3. 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>
  );
}

这里有个坑useWatchContractEventargs 过滤参数,在 wagmi v2 中只支持精确匹配,不支持 undefined 表示"不过滤"。如果你写成 args: { seller: undefined },它会直接报错。所以要么不传 args(监听所有事件),要么传具体的地址。我当时传了 seller: address,但用户刚连接钱包时 addressundefined,导致监听器注册失败。后来我加了个条件:

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 和合约地址。

踩坑记录

  1. WebSocket 连接泄漏 :wagmi v2 的 useWatchContractEvent 在组件卸载时不会自动断开 WebSocket。我在开发环境频繁热更新,控制台看到 WebSocket connection to 'wss://...' failed。解决方式是在 lib/wagmi.ts 里用 fallback 确保 HTTP 降级,同时在组件里用 enabled 控制只在需要时监听。

  2. React 18 严格模式导致事件重复useWatchContractEvent 在严格模式下会注册两次监听,但 wagmi 内部做了去重,所以不会触发两次回调。但 onLogs 回调里的状态更新会触发两次渲染。我在 setOrders 里用了函数式更新 prev => [...newOrders, ...prev],避免了重复添加。

  3. 事件参数类型不匹配 :合约事件定义 OrderCreated(uint256 tokenId, uint256 price, address seller),但 wagmi 的 log.args 返回的是 unknown 类型。我需要手动断言 log.args.tokenId as bigint。后来发现用 viemdecodeEventLog 可以更安全地解析。

  4. 链切换后事件监听不更新 :用户从 Sepolia 切到 Mainnet,useWatchContractEvent 会重新注册,但 wagmi 的 webSocket 传输不会自动断开旧链的连接。我加了个 useEffect 监听 chainId,变化时清空列表,但更好的做法是用 wagmi 的 useDisconnect 手动清理。

小结

从 ethers.js 轮询到 wagmi 事件驱动,核心收获是:Web3 前端的状态同步,不要靠定时器,要用合约事件驱动 。wagmi v2 的 useWatchContractEvent 封装了 WebSocket 订阅和清理逻辑,但要注意链切换、严格模式和类型断言这些细节。如果你想继续深挖,可以研究 wagmi 的 useSyncExternalStoreviemcreateEventFilter,实现更精细的事件过滤和批量处理。

相关推荐
豹哥学前端5 小时前
告别割裂式学习:待办清单项目,一次性掌握数组、本地存储与事件委托
前端·javascript
JYeontu5 小时前
照片墙太死板?做一个会随风摇摆的绳串图片交互效果
前端·javascript·css
Yue栎廷5 小时前
邪修:Markdown加粗语法**本土化改造
前端·javascript·人工智能
小歪 | 前端5 小时前
VUE_运行Vue项目Network: unavailable问题解决
前端·javascript·vue.js
吹个口哨写代码5 小时前
小程序图片不显示,直接访问显示,头部配置问题
javascript·css·nginx
林恒smileZAZ6 小时前
宇宙画布:纯 CSS+JS 实现交互式深空艺术
前端·javascript·css
Dxy12393102166 小时前
js如何根据开始位置结束位置在类表中取对应范围的数据
开发语言·javascript·ecmascript
钱端工程师6 小时前
vue自定义一个在线查看文件的组件(.xlsx、.docx、.pdf、图片等)
javascript·vue.js·pdf
p@ssword6 小时前
解决idea-2025.3.3重启项目/停止项目要点两次问题才生效问题
javascript·数据库·intellij-idea