阅读视图

发现新文章,点击刷新页面。

被The Graph的GraphQL查询坑了三天,我用一个真实DeFi项目把链上数据索引彻底搞懂了

背景

上个月,我在给一个跨链DeFi协议做前端仪表盘。需求很简单:用户登录后,能看到他在以太坊、Polygon和Arbitrum三条链上的所有交易记录、当前质押的LP代币数量和历史收益。最开始我直接用ethers.js的provider.getLogsgetBalance去拉数据,结果发现三个问题:

  1. 每次切换链都要等5-10秒才能刷出数据,用户体验极差
  2. 用户交易一多(超过1000条),前端直接卡死,因为RPC节点一次只返回2000条日志,我得写递归去翻页
  3. 以太坊主网的RPC限流,免费Infura节点一分钟只能请求100次,用户多的时候直接报429

我当时想:不行,必须换个方案。正好团队后端同事提了一嘴The Graph,说可以自己搭子图来索引链上数据。我一开始以为就是把RPC换成GraphQL调用,结果一上手才发现水有多深——从子图定义、映射器写法到前端分页和实时更新,每个环节都有坑。

这篇文章就是我把整个流程走通后的完整记录,希望能帮到同样被链上数据查询折磨的你。

问题分析

最初的思路:直接用RPC + 前端缓存

我的第一版方案是:用ethers.js的getLogs拉取所有Transfer事件,然后在浏览器用localStorage缓存。但很快发现:

  • 以太坊主网一个地址可能有几千笔交易,getLogs一次最多返回2000条,我得写递归循环,每页等1-2秒
  • 跨链时,每个链的RPC节点不同,缓存逻辑要分开写,代码变得极其臃肿
  • 最致命的是:历史收益数据(比如用户某天质押了多少LP)需要聚合计算,RPC返回的是原始事件,我得在前端做大量运算,导致页面卡顿

为什么选择The Graph

The Graph本质上是把链上事件索引到PostgreSQL数据库里,然后通过GraphQL接口提供查询。好处是:

  • 索引完的数据查询速度在毫秒级,比RPC快10倍以上
  • 支持复杂过滤和聚合计算(比如按时间范围统计),这些运算在子图层面完成,前端只需拿结果
  • 有托管服务(Hosted Service)和去中心化网络,不需要自己维护服务器

但坏处是:需要写子图定义(schema.graphql)和映射器(mapping.ts),对前端开发者来说是个新领域。

核心实现:从子图搭建到前端查询

第一步:搭建本地子图开发环境

我第一次踩的坑就是直接在Hosted Service上部署,结果每次改映射器都要等10分钟同步。后来发现应该先在本地跑Graph Node。

# 安装Graph CLI
npm install -g @graphprotocol/graph-cli

# 初始化子图项目(选择以太坊主网)
graph init --product subgraph-studio
# 输入子图名称、合约地址等

初始化后生成的项目结构:

my-subgraph/
├── schema.graphql   # 定义数据模型
├── src/
│   └── mapping.ts   # 事件处理逻辑
├── subgraph.yaml    # 配置文件
└── package.json

这里有个坑graph init会让你选网络,如果选mainnet,它会自动用以太坊主网的RPC。但本地开发时最好用hardhatganache本地节点,或者用测试网。我当时选了mainnet,结果第一次同步花了3小时,因为要扫描整个链的历史事件。

最终方案:用graph init --product subgraph-studio后,手动修改subgraph.yaml里的dataSources.source.addressnetwork为测试网地址,比如Goerli。

第二步:定义数据模型(schema.graphql)

我的需求是记录用户的交易和质押信息。模型设计直接决定了查询效率。

# schema.graphql
type User @entity {
  id: ID!  # 用户地址
  transactions: [Transaction!] @derivedFrom(field: "user")
  totalStaked: BigInt!
  totalRewards: BigInt!
  lastUpdated: BigInt!
}

type Transaction @entity {
  id: ID!  # 交易哈希
  user: User!
  type: String!  # "deposit", "withdraw", "swap"
  amount: BigInt!
  token: Bytes!
  timestamp: BigInt!
  blockNumber: Int!
}

type DailyStats @entity {
  id: ID!  # 格式: "userAddress-dayTimestamp"
  user: User!
  date: BigInt!
  depositCount: Int!
  withdrawCount: Int!
  totalVolume: BigInt!
}

设计思路

  • User实体是核心,关联transactionsDailyStats
  • DailyStatsuserAddress-dayTimestamp作为ID,这样查询某用户某天的统计时直接get即可
  • 所有时间字段用BigInt存储(solidity的uint256),前端再转成Date

第三步:编写映射器(mapping.ts)

映射器是子图的核心,把链上事件转换成数据模型。这里我踩了一个大坑:映射器里不能做异步操作,比如不能用fetch请求外部API。

// src/mapping.ts
import { BigInt, Bytes } from "@graphprotocol/graph-ts"
import { 
  Transfer, 
  Deposit, 
  Withdraw 
} from "../generated/MyContract/MyContract"
import { User, Transaction, DailyStats } from "../generated/schema"

export function handleTransfer(event: Transfer): void {
  // 更新发送方和接收方的余额
  updateUserBalance(event.params.from, event.params.value.neg())
  updateUserBalance(event.params.to, event.params.value)
  
  // 记录交易
  let transaction = new Transaction(event.transaction.hash.toHex())
  transaction.user = event.params.from
  transaction.type = "transfer"
  transaction.amount = event.params.value
  transaction.token = event.address
  transaction.timestamp = event.block.timestamp
  transaction.blockNumber = event.block.number.toI32()
  transaction.save()
}

function updateUserBalance(address: Bytes, amount: BigInt): void {
  let userId = address.toHex()
  let user = User.load(userId)
  
  if (user == null) {
    user = new User(userId)
    user.totalStaked = BigInt.fromI32(0)
    user.totalRewards = BigInt.fromI32(0)
    user.lastUpdated = BigInt.fromI32(0)
  }
  
  user.totalStaked = user.totalStaked.plus(amount)
  user.lastUpdated = event.block.timestamp
  user.save()
}

这里有个坑User.load()User.save()在映射器里是同步的,但每次调用都会产生数据库读写。如果一笔交易涉及多个用户(比如Transfer事件有from和to),要避免重复加载同一个用户。我一开始没注意,导致同一个用户被加载两次,数据覆盖了。

解决办法:在handleTransfer里先检查两个地址是否相同,如果相同(自己转给自己),只更新一次。

第四步:部署子图并获取API URL

本地测试通过后,部署到The Graph的托管服务:

# 1. 在The Graph Studio创建子图
# 2. 获取部署密钥
graph auth --product subgraph-studio <YOUR_KEY>

# 3. 部署
graph deploy --product subgraph-studio <SUBGRAPH_NAME>

部署成功后,会得到一个API URL,类似: https://api.studio.thegraph.com/query/12345/my-subgraph/v0.0.1

注意:每次部署都会生成新版本,前端用的URL要更新版本号。我建议在环境变量里配置,方便切换。

第五步:前端接入Apollo Client

前端我用的是React + TypeScript + Apollo Client。这里有个关键点:Apollo默认的缓存策略会导致数据不更新,因为子图索引有延迟(通常几秒到几分钟)。

// graphql/queries.ts
import { gql } from "@apollo/client"

// 查询用户交易记录,支持分页
export const GET_USER_TRANSACTIONS = gql`
  query GetUserTransactions(
    $user: String!
    $first: Int!
    $skip: Int!
    $orderDirection: String!
  ) {
    transactions(
      where: { user: $user }
      first: $first
      skip: $skip
      orderBy: timestamp
      orderDirection: $orderDirection
    ) {
      id
      type
      amount
      token
      timestamp
      blockNumber
    }
  }
`

// 查询用户每日统计
export const GET_USER_DAILY_STATS = gql`
  query GetUserDailyStats(
    $user: String!
    $fromDate: BigInt!
    $toDate: BigInt!
  ) {
    dailyStats(
      where: { 
        user: $user
        date_gte: $fromDate
        date_lte: $toDate
      }
      orderBy: date
      orderDirection: asc
    ) {
      id
      date
      depositCount
      withdrawCount
      totalVolume
    }
  }
`
// hooks/useTransactions.ts
import { useQuery } from "@apollo/client"
import { GET_USER_TRANSACTIONS } from "../graphql/queries"

export function useTransactions(userAddress: string, page: number, pageSize: number = 20) {
  const { data, loading, error, refetch } = useQuery(GET_USER_TRANSACTIONS, {
    variables: {
      user: userAddress.toLowerCase(),  // 注意:地址必须小写!
      first: pageSize,
      skip: (page - 1) * pageSize,
      orderDirection: "desc"
    },
    // 关键:设置轮询,每30秒刷新一次,应对子图索引延迟
    pollInterval: 30000,
    // 关闭缓存,保证每次查询都从网络获取最新数据
    fetchPolicy: "network-only"
  })

  return {
    transactions: data?.transactions || [],
    loading,
    error,
    refetch
  }
}

这里有个坑:The Graph的查询中,地址字段必须是小写。如果用户输入的地址是大写或有校验和(EIP-55),查询会返回空结果。我一开始没做.toLowerCase(),debug了两小时才发现。

第六步:处理索引延迟和实时更新

子图索引不是实时的,通常有2-30秒的延迟。这意味着用户刚发起一笔交易,前端可能查不到。

我的解决方案是混合策略

  1. 对于历史数据(超过1分钟的交易),直接走The Graph查询
  2. 对于刚发生的交易(用户通过钱包确认后),先用ethers.js监听事件,等确认后再触发子图刷新
// hooks/useRealtimeTransactions.ts
import { useContractEvent } from "wagmi"
import { useTransactions } from "./useTransactions"

export function useRealtimeTransactions(userAddress: string) {
  const { transactions, loading, refetch } = useTransactions(userAddress, 1, 50)
  
  // 监听合约的Transfer事件
  useContractEvent({
    address: contractAddress,
    abi: contractABI,
    eventName: "Transfer",
    listener(from, to, value) {
      // 如果事件涉及当前用户,触发子图刷新
      if (from.toLowerCase() === userAddress.toLowerCase() || 
          to.toLowerCase() === userAddress.toLowerCase()) {
        // 延迟3秒,给子图索引留时间
        setTimeout(() => refetch(), 3000)
      }
    },
  })

  return { transactions, loading }
}

完整代码(可直接复制运行)

以下是一个完整的React组件,展示用户交易列表,支持分页和实时更新:

// components/TransactionList.tsx
import React, { useState } from "react"
import { useAccount } from "wagmi"
import { useRealtimeTransactions } from "../hooks/useRealtimeTransactions"
import { formatEther } from "ethers/lib/utils"

const PAGE_SIZE = 20

export function TransactionList() {
  const { address } = useAccount()
  const [page, setPage] = useState(1)
  
  const { transactions, loading } = useRealtimeTransactions(address || "")
  
  if (!address) return <p>请连接钱包</p>
  if (loading) return <p>加载中...</p>
  
  const totalPages = Math.ceil(transactions.length / PAGE_SIZE)
  const pageTransactions = transactions.slice(
    (page - 1) * PAGE_SIZE,
    page * PAGE_SIZE
  )

  return (
    <div>
      <h2>交易记录</h2>
      <table>
        <thead>
          <tr>
            <th>类型</th>
            <th>金额</th>
            <th>时间</th>
            <th>区块</th>
          </tr>
        </thead>
        <tbody>
          {pageTransactions.map((tx) => (
            <tr key={tx.id}>
              <td>{tx.type}</td>
              <td>{formatEther(tx.amount)}</td>
              <td>{new Date(tx.timestamp * 1000).toLocaleString()}</td>
              <td>{tx.blockNumber}</td>
            </tr>
          ))}
        </tbody>
      </table>
      
      <div>
        <button 
          disabled={page <= 1} 
          onClick={() => setPage(p => p - 1)}
        >
          上一页
        </button>
        <span>第 {page} / {totalPages} 页</span>
        <button 
          disabled={page >= totalPages} 
          onClick={() => setPage(p => p + 1)}
        >
          下一页
        </button>
      </div>
    </div>
  )
}

踩坑记录

坑1:子图部署后数据为0

现象:部署成功,GraphQL查询能返回实体结构,但所有数据都是空的。 原因:子图配置文件subgraph.yaml里的startBlock设置得太早,合约在那个区块还没部署。或者eventHandlers里的事件签名写错了。 解决:检查startBlock是否大于等于合约部署区块,用graph codegen重新生成类型,然后重新部署。

坑2:Apollo查询返回null但GraphQL Playground正常

现象:在The Graph Studio的Playground里查询正常,但前端Apollo返回null。 原因:Apollo的缓存策略。默认是cache-first,如果之前缓存过相同变量的查询,它不会重新请求网络。 解决:设置fetchPolicy: "network-only",或者每次查询时加一个随机变量(比如timestamp)来绕过缓存。

坑3:地址大小写导致查询失败

现象:用户输入0xAbC...,查询无结果。但Playground里用小写可以。 原因:The Graph的字符串比较是大小写敏感的,而以太坊地址的校验和格式(EIP-55)包含大小写。 解决:前端所有地址在传入查询前统一.toLowerCase()。子图映射器里存储地址时也要用小写。

坑4:映射器里循环调用save导致超时

现象:一笔交易涉及多个用户(比如批量转账),映射器执行超过50ms,子图索引报错。 原因:映射器有执行时间限制(AssemblyScript环境),循环里多次调用save()会累积时间。 解决:尽量合并写操作。比如批量转账事件,先收集所有用户更新,然后在事件处理函数最后一次性调用save()。或者用store.set()替代entity.save(),性能更好。

小结

用The Graph做链上数据查询,核心是把计算压力从前端转移到索引层。子图的schema设计要围绕查询场景来,不要试图把所有数据都塞进去。如果你需要更实时的数据(秒级),可以考虑结合ethers.js的事件监听做混合方案。下一步可以研究如何用The Graph的去中心化网络(Decentralized Network)替换托管服务,避免单点故障。

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

用 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。代码大概长这样:

// 第一版:轮询方案(已废弃)
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:

npm install wagmi viem @tanstack/react-query

然后配置 wagmi 客户端。这里有个坑:wagmi v2 默认用 HTTP 传输,要启用 WebSocket 监听事件,必须显式指定 transports 为 WebSocket 地址。

// 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

transports: {
  [mainnet.id]: fallback([
    webSocket('wss://...'),
    http('https://...'),
  ]),
}

这样 wagmi 会优先用 WebSocket,如果断开自动降级到 HTTP。我当时没加这个,结果 WebSocket 偶尔断连后,所有事件监听都失效了,页面直接卡死。

第二步:实现上架表单 + 实时监听

核心组件 ListNFTForm:用户选择 NFT,输入价格,点击上架。上架成功后,自动监听 OrderCreated 事件更新列表。

// 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,导致监听器注册失败。后来我加了个条件:

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

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 等待交易确认,确认后再主动查一次列表。同时事件监听作为辅助,提前展示“上架中”的占位状态。

// 等待交易确认
const { data: receipt } = useWaitForTransactionReceipt({
  hash: txHash, // 从 writeContractAsync 返回的哈希
});

useEffect(() => {
  if (receipt) {
    // 交易已确认,重新查询列表
    refetchOrders();
  }
}, [receipt]);

这样用户体验更好:上架后立刻看到占位,交易确认后列表自动刷新,事件监听作为实时更新的补充。

完整代码:一个可运行的 NFT 上架模块

我把上述逻辑整合成一个完整的组件,可以直接复制到 Next.js 14 项目中运行。前提是你已经配好了 wagmi 客户端(参考前面的 lib/wagmi.ts)。

// 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,实现更精细的事件过滤和批量处理。

❌