普通视图

发现新文章,点击刷新页面。
昨天 — 2026年4月11日首页

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

作者 竹林818
2026年4月11日 18:02

从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战

背景

上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。

我的第一反应是:“简单,直接用 ethers.jsviem 去读合约的 public 变量和事件不就行了?” 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具——The Graph。

问题分析

The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。

我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。

最初的尝试是直接用 fetchaxios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更“React”的、类型友好的解决方案。

核心实现

第一步:环境搭建与GraphQL客户端选择

首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/clientgraphql。Apollo Client 是一个强大的GraphQL状态管理库,它提供了React Hook(如 useQuery)、缓存、错误处理等开箱即用的功能,能极大简化前端与The Graph的交互。

npm install @apollo/client graphql

接下来,我需要初始化Apollo Client,并配置其连接到Uniswap V3的Hosted Service端点。

这里有个坑:The Graph的Hosted Service端点URL结构是 https://api.thegraph.com/subgraphs/name/<用户名>/<子图名称>。对于Uniswap V3以太坊主网,用户名是 uniswap,子图名称是 uniswap-v3。千万别去官方文档里找“API Key”,Hosted Service在查询限额内是免费的,直接使用即可。

我创建了一个文件 lib/apolloClient.ts 来配置客户端:

// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

// Uniswap V3 以太坊主网 Subgraph 端点
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

// 创建 Apollo Client 实例
// 注意:默认缓存策略可能不适合实时性极高的数据,对于交易量等数据可以考虑调整fetchPolicy
export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network', // 优先返回缓存,同时在后台更新
    },
    query: {
      fetchPolicy: 'network-only', // 对于主动查询,总是从网络获取
    },
  },
});

第二步:编写GraphQL查询并生成类型

这是核心步骤。我需要去 The Graph Explorer 找到 uniswap/uniswap-v3 子图,研究其数据模式(Schema)。我需要的池子(Pool)数据,在Schema中对应 Pool 实体,里面包含了 id(合约地址)、totalValueLockedUSDvolumeUSDfeesUSDtoken0token1 等字段。

为了获取24小时数据,子图通常会有类似 poolDayData 的时间序列实体。经过探索,我发现查询最近24小时数据的最佳方式是:先查询 Pool 实体本身获取当前快照数据(如TVL),再关联查询其最新的 poolDayData(按日期排序取第一条)来获取过去24小时的交易量和手续费。

我创建了一个GraphQL查询文件 queries/poolData.graphql

# queries/poolData.graphql
query PoolData($poolId: String!) {
  # 查询池子基础信息
  pool(id: $poolId) {
    id
    totalValueLockedUSD
    feeTier
    token0 {
      id
      symbol
      decimals
    }
    token1 {
      id
      symbol
      decimals
    }
    # 关联查询最近的日数据(过去24小时)
    poolDayData(first: 1, orderBy: date, orderDirection: desc) {
      volumeUSD
      feesUSD
      date
    }
  }
}

注意这个细节$poolId 是池子的合约地址,但在The Graph中,id 字段通常是全小写的地址字符串。所以从链上获取的地址,在传入查询变量前最好先 .toLowerCase() 处理一下,避免查不到数据。

接下来,为了让TypeScript认识查询返回的数据结构,我使用GraphQL Code Generator来自动生成类型。这需要额外配置,但一劳永逸。简单起见,我也可以手动定义类型,但对于复杂查询,自动生成更可靠。这里我展示手动定义的方式,更贴近快速上手的场景:

// types/poolData.ts
export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

第三步:创建自定义React Hook

为了让数据获取逻辑可以在组件中优雅复用,我决定将其封装成一个自定义Hook:usePoolData

// hooks/usePoolData.ts
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

// 直接在Hook中定义GraphQL查询,避免额外文件
// 注意:gql`...` 是Apollo Client的模板标签函数,用于解析GraphQL查询字符串
const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined; // 池子合约地址
  skip?: boolean; // 是否跳过查询
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  // 使用 useQuery Hook
  // 它自动处理 loading, error 状态,并返回 data
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(), // 关键:地址转小写
      },
      skip: !poolId || skip, // 如果没有poolId或主动跳过,则不执行查询
      // fetchPolicy: 'network-only' // 可以根据需要覆盖默认策略
    }
  );

  // 对返回的数据进行简单处理和类型断言
  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch, // 用于手动刷新数据
  };
};

这个Hook的设计非常“React”:它接收依赖项(poolId),管理内部状态,并返回一个清晰的状态对象。在组件中使用时,我可以轻松地根据 loading 显示加载框,根据 error 显示错误信息,用 poolData 渲染UI。

第四步:在组件中集成并使用

最后,我在一个React组件中使用这个Hook。假设我要显示USDC/ETH 0.05%费率的池子(一个非常常见的池)。

// components/PoolCard.tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

// 已知的 Uniswap V3 USDC/ETH 0.05% 池地址(以太坊主网)
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) {
    return <div className="p-4 border rounded-lg">加载池数据中...</div>;
  }

  if (error) {
    return (
      <div className="p-4 border rounded-lg bg-red-50 text-red-700">
        查询失败: {error.message}
      </div>
    );
  }

  if (!poolData) {
    return <div className="p-4 border rounded-lg">未找到池子数据</div>;
  }

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div className="p-4 border rounded-lg shadow-sm bg-white">
      <h3 className="font-bold text-lg">
        {poolData.token0.symbol} / {poolData.token1.symbol} Pool
      </h3>
      <p className="text-sm text-gray-500">费率: {Number(poolData.feeTier) / 10000}%</p>
      <div className="mt-3 space-y-2">
        <div>
          <span className="text-gray-600">总锁定价值 (TVL): </span>
          <span className="font-semibold">
            ${Number(tvl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div>
          <span className="text-gray-600">24小时交易量: </span>
          <span className="font-semibold">
            ${Number(dailyVolume).toLocaleString(undefined, { maximumFractionDigits: 0 })}
          </span>
        </div>
        <div className="text-xs text-gray-400">
          池地址: {poolData.id}
        </div>
      </div>
    </div>
  );
};

export default PoolCard;

至此,一个完整的、从The Graph获取Uniswap V3池数据并展示的前端功能就实现了。代码清晰、类型安全、且易于维护和扩展。

完整代码

以下是关键文件的完整代码汇总,你可以复制到一个新的React + TypeScript项目中运行测试:

1. 安装依赖:

npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
npm install @apollo/client graphql

2. 配置 Apollo Client (src/lib/apolloClient.ts):

import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: UNISWAP_V3_GRAPH_ENDPOINT,
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
    query: {
      fetchPolicy: 'network-only',
    },
  },
});

3. 定义类型 (src/types/poolData.ts):

export interface Token {
  id: string;
  symbol: string;
  decimals: string;
}

export interface PoolDayData {
  volumeUSD: string;
  feesUSD: string;
  date: number;
}

export interface PoolData {
  id: string;
  totalValueLockedUSD: string;
  feeTier: string;
  token0: Token;
  token1: Token;
  poolDayData: PoolDayData[];
}

export interface GraphQLPoolResponse {
  pool: PoolData | null;
}

4. 创建自定义Hook (src/hooks/usePoolData.ts):

import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';

const POOL_DATA_QUERY = gql`
  query PoolData($poolId: String!) {
    pool(id: $poolId) {
      id
      totalValueLockedUSD
      feeTier
      token0 {
        id
        symbol
        decimals
      }
      token1 {
        id
        symbol
        decimals
      }
      poolDayData(first: 1, orderBy: date, orderDirection: desc) {
        volumeUSD
        feesUSD
        date
      }
    }
  }
`;

interface UsePoolDataProps {
  poolId: string | undefined;
  skip?: boolean;
}

export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
  const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
    POOL_DATA_QUERY,
    {
      variables: {
        poolId: poolId?.toLowerCase(),
      },
      skip: !poolId || skip,
    }
  );

  const poolData = data?.pool;

  return {
    loading,
    error,
    poolData,
    refetch,
  };
};

5. 创建展示组件 (src/components/PoolCard.tsx):

import React from 'react';
import { usePoolData } from '../hooks/usePoolData';

const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';

const PoolCard: React.FC = () => {
  const { loading, error, poolData } = usePoolData({
    poolId: USDC_ETH_POOL_ADDRESS,
  });

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;
  if (!poolData) return <div>无数据</div>;

  const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
  const tvl = poolData.totalValueLockedUSD;

  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
      <h3>{poolData.token0.symbol} / {poolData.token1.symbol} Pool</h3>
      <p>费率: {Number(poolData.feeTier) / 10000}%</p>
      <div>
        <div>TVL: ${Number(tvl).toLocaleString()}</div>
        <div>24h Volume: ${Number(dailyVolume).toLocaleString()}</div>
      </div>
      <small>地址: {poolData.id}</small>
    </div>
  );
};

export default PoolCard;

6. 在应用入口集成 (src/App.tsx):

import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import PoolCard from './components/PoolCard';
import './App.css';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 池数据看板 (The Graph)</h1>
        <PoolCard />
        {/* 可以在这里添加更多 PoolCard,传入不同的 poolId */}
      </div>
    </ApolloProvider>
  );
}

export default App;

运行 npm start,你应该能看到一个显示USDC/ETH池数据的卡片。

踩坑记录

  1. “池子找不到 (Pool not found)”:这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回 null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址 id 字段全是小写。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法:在将地址作为变量传入查询前,务必执行 .toLowerCase()

  2. 查询超时或响应慢:第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。

  3. 数据类型不匹配:GraphQL查询返回的数字,即使是 BigInt 在Subgraph中,通过API返回时也是字符串格式。直接用于计算会出错。解决方法:在前端使用前,用 Number()parseFloat() 或更适合大数的库如 BigNumber.js (ethers.js自带) 进行转换。我的示例中用了 Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用 ethers.BigNumberBigInt 来处理。

  4. “Cannot read property ‘symbol’ of null”:在测试时,我传了一个非Uniswap V3池的地址,查询返回的 pool 不为 null,但内部的 token0token1 可能为 null(如果子图索引不完整)。解决方法:在组件渲染中使用可选链操作符 ?. 或进行严格的空值检查,就像我在示例中处理 poolDayData[0] 一样。

小结

这次实战让我彻底把The Graph从“听说过”变成了“上手用过”。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。

昨天以前首页

从“连接失败”到丝滑登录:我用 ethers.js v6 搞定 MetaMask 钱包连接的全过程

作者 竹林818
2026年4月10日 18:01

背景

上个月,我接手了一个 NFT 艺术平台的 MVP 开发。核心功能很简单:用户连接钱包,查看自己的 NFT,并进行铸造。产品经理说:“登录就用最经典的 MetaMask 连接,简单点。” 我想,这还不简单?用 ethers.js 几行代码的事。结果,从“简单连接”到“稳定可用的登录流程”,我花了整整一天半的时间,踩了好几个意想不到的坑。这篇文章,就是把我解决问题的过程原原本本地记录下来。

问题分析

我的第一版代码非常“教科书”:

import { ethers } from 'ethers';

const connectWallet = async () => {
  if (window.ethereum) {
    const provider = new ethers.BrowserProvider(window.ethereum);
    const signer = await provider.getSigner();
    const address = await signer.getAddress();
    console.log('Connected:', address);
    return address;
  } else {
    alert('请安装 MetaMask!');
  }
};

看起来没问题,对吧?但在实际测试中,问题接踵而至:

  1. 第一次点击连接,弹窗一闪而过,但状态没更新。 第二次点击才能成功。
  2. 用户如果拒绝了连接请求,页面没有任何反馈,就像什么都没发生。
  3. 用户在 MetaMask 里切换了账户或网络,我的前端页面完全感知不到,显示的还是旧信息。
  4. 代码里到处是 window.ethereum 的类型断言 as any,TypeScript 疯狂报红。

我意识到,我实现的只是一个“一次性连接动作”,而不是一个“可持续管理的钱包连接状态”。真正的生产环境需要的是一个健壮的、能应对各种用户操作和钱包状态变化的登录系统。

核心实现

第一步:安全地获取 Provider 和 处理类型

首先,要解决 window.ethereum 的类型问题。直接使用 any 会丢失类型安全和 IDE 提示。ethers.js v6 推荐从 window.ethereum 创建 BrowserProvider

这里有个坑: window.ethereum 可能不存在(用户没装钱包),也可能是数组(多个钱包注入)。我们需要安全地处理。

// utils/ethers.ts
import { BrowserProvider, Eip1193Provider } from 'ethers';

// 声明全局的 ethereum 类型
declare global {
  interface Window {
    ethereum?: Eip1193Provider;
  }
}

/**
 * 获取安全的 Ethers BrowserProvider
 * @returns {BrowserProvider | null} 返回 Provider 或 null
 */
export const getEthersProvider = (): BrowserProvider | null => {
  // 检查 window.ethereum 是否存在
  if (typeof window !== 'undefined' && window.ethereum) {
    try {
      // ethers v6 使用 BrowserProvider 包装 window.ethereum
      return new BrowserProvider(window.ethereum);
    } catch (error) {
      console.error('创建 Provider 失败:', error);
      return null;
    }
  }
  console.warn('未检测到钱包扩展(如 MetaMask)。');
  return null;
};

第二步:实现核心连接函数,处理用户拒绝

连接钱包的核心是请求账户访问权限。provider.send('eth_requestAccounts', []) 这个方法会触发 MetaMask 弹窗。这里有个关键细节: 必须妥善处理用户点击“拒绝”的情况。

// hooks/useWallet.ts
import { useState, useCallback } from 'react';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null); // 清除旧错误

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask 钱包扩展。');
      setIsConnecting(false);
      return;
    }

    try {
      // 关键步骤:请求账户访问,这会弹出 MetaMask 授权窗口
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        console.log('钱包连接成功:', currentAccount);
      } else {
        setError('未获取到有效账户。');
      }
    } catch (err: any) {
      // **重点:处理用户拒绝等错误**
      console.error('连接钱包失败:', err);
      if (err.code === 4001) {
        // 4001 是用户拒绝连接的错误码
        setError('您拒绝了钱包连接请求。');
      } else {
        setError(`连接失败: ${err.message || '未知错误'}`);
      }
    } finally {
      setIsConnecting(false);
    }
  }, []);

  return { account, isConnecting, error, connectWallet };
};

第三步:监听账户和网络变化

用户不会一直待在同一个账户或网络上。他们可能在 MetaMask 里切换账户,或者从以太坊主网切换到 Polygon。我们的前端必须能实时响应这些变化。

注意这个细节: 监听事件要在连接成功后设置,并且在组件卸载时清理,防止内存泄漏。

// 在 useWallet 的 connectWallet 函数成功连接后,添加监听逻辑
const setupEventListeners = useCallback((provider: BrowserProvider) => {
  // 注意:ethers v6 的 provider 底层是 EIP-1193 的 provider
  const ethereum = window.ethereum;
  if (!ethereum) return;

  // 监听账户变化
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('账户变化:', accounts);
    if (accounts.length === 0) {
      // 用户锁定了钱包或切换了所有账户
      setAccount(null);
      setError('钱包已断开连接。');
    } else if (accounts[0] !== account) {
      // 切换到新账户
      setAccount(accounts[0]);
    }
  };

  // 监听链ID变化(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 根据规范,当链发生变化时,应重置页面状态或重新加载
    // 一个常见的做法是提示用户或自动刷新
    console.log('网络已切换,链ID:', _chainId);
    // 简单处理:直接重置账户,需要用户重新连接(或设计更优雅的流程)
    setAccount(null);
    window.location.reload(); // 许多 DApp 选择刷新页面
  };

  // 添加监听
  ethereum.on('accountsChanged', handleAccountsChanged);
  ethereum.on('chainChanged', handleChainChanged);

  // 返回清理函数
  return () => {
    ethereum.removeListener('accountsChanged', handleAccountsChanged);
    ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [account]);

// 然后在 connectWallet 成功连接后调用
// const cleanup = setupEventListeners(provider);
// 注意:需要在 React useEffect 或组件卸载逻辑中执行 cleanup()

在实际的 React Hook 实现中,我们需要使用 useEffect 来管理这些副作用的生命周期。

第四步:整合成可复用的 React Hook

将以上所有逻辑整合到一个完整的、易于使用的自定义 Hook 中。

// hooks/useWallet.ts (完整版)
import { useState, useCallback, useEffect, useRef } from 'react';
import { BrowserProvider } from 'ethers';
import { getEthersProvider } from '../utils/ethers';

export const useWallet = () => {
  const [account, setAccount] = useState<string | null>(null);
  const [chainId, setChainId] = useState<bigint | null>(null);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  // 使用 ref 存储清理函数,避免重复绑定事件
  const cleanupRef = useRef<(() => void) | null>(null);

  // 1. 初始化:检查是否已授权连接
  useEffect(() => {
    const checkIfWalletIsConnected = async () => {
      const provider = getEthersProvider();
      if (!provider) return;

      try {
        const accounts = await provider.send('eth_accounts', []); // 静默获取,不弹窗
        if (accounts.length > 0) {
          setAccount(accounts[0]);
          const network = await provider.getNetwork();
          setChainId(network.chainId);
          // 为已连接的账户设置监听
          setupEventListeners(provider);
        }
      } catch (err) {
        console.warn('检查已连接账户时出错:', err);
      }
    };

    checkIfWalletIsConnected();
  }, []);

  // 2. 设置事件监听器的函数
  const setupEventListeners = useCallback((provider: BrowserProvider) => {
    const ethereum = window.ethereum;
    if (!ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('accountsChanged:', accounts);
      if (accounts.length === 0) {
        setAccount(null);
        setError('钱包已断开。');
      } else {
        setAccount(accounts[0]);
        setError(null);
      }
    };

    const handleChainChanged = (_chainId: string) => {
      console.log('chainChanged:', _chainId);
      // 网络切换后,建议刷新页面或重新获取所有数据
      window.location.reload();
    };

    ethereum.on('accountsChanged', handleAccountsChanged);
    ethereum.on('chainChanged', handleChainChanged);

    // 存储清理函数
    cleanupRef.current = () => {
      ethereum.removeListener('accountsChanged', handleAccountsChanged);
      ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, []);

  // 3. 核心连接函数
  const connectWallet = useCallback(async () => {
    setIsConnecting(true);
    setError(null);

    const provider = getEthersProvider();
    if (!provider) {
      setError('请安装 MetaMask。');
      setIsConnecting(false);
      return;
    }

    // 先清理旧监听(如果存在)
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }

    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      
      if (currentAccount) {
        setAccount(currentAccount);
        const network = await provider.getNetwork();
        setChainId(network.chainId);
        // 设置新监听
        setupEventListeners(provider);
      }
    } catch (err: any) {
      console.error('连接失败:', err);
      if (err.code === 4001) {
        setError('连接请求被拒绝。');
      } else {
        setError(err.message || '未知连接错误');
      }
    } finally {
      setIsConnecting(false);
    }
  }, [setupEventListeners]);

  // 4. 断开连接(对于 MetaMask,更多是前端状态清除)
  const disconnectWallet = useCallback(() => {
    setAccount(null);
    setChainId(null);
    setError(null);
    if (cleanupRef.current) {
      cleanupRef.current();
      cleanupRef.current = null;
    }
    console.log('钱包已断开(前端状态)');
    // 注意:MetaMask 无法通过代码真正“断开”,只能前端清除状态。
    // 用户需要自己在 MetaMask 中切换账户或锁定钱包。
  }, []);

  // 5. 组件卸载时清理监听
  useEffect(() => {
    return () => {
      if (cleanupRef.current) {
        cleanupRef.current();
      }
    };
  }, []);

  return {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected: !!account, // 便捷的布尔状态
  };
};

完整代码

这是一个可以直接在 React 项目中使用的完整示例组件。

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';

const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
    isConnected,
  } = useWallet();

  // 格式化地址:0x1234...5678
  const formatAddress = (addr: string | null) => {
    if (!addr) return '';
    return `${addr.substring(0, 6)}...${addr.substring(addr.length - 4)}`;
  };

  return (
    <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2>Web3 钱包连接示例</h2>
      
      {error && (
        <div style={{ color: 'red', marginBottom: '10px', padding: '10px', background: '#ffe6e6' }}>
          <strong>错误:</strong> {error}
        </div>
      )}

      <div style={{ marginBottom: '15px' }}>
        <strong>连接状态:</strong> 
        {isConnected ? (
          <span style={{ color: 'green' }}>已连接</span>
        ) : (
          <span style={{ color: 'orange' }}>未连接</span>
        )}
      </div>

      {isConnected && account ? (
        <div>
          <div style={{ marginBottom: '10px' }}>
            <strong>账户地址:</strong> 
            <code>{formatAddress(account)}</code> ({account})
          </div>
          <div style={{ marginBottom: '15px' }}>
            <strong>当前链ID:</strong> 
            <code>{chainId?.toString() || '未知'}</code>
          </div>
          <button
            onClick={disconnectWallet}
            style={{
              padding: '10px 20px',
              background: '#ff6b6b',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: 'pointer',
            }}
          >
            断开连接
          </button>
          <p style={{ fontSize: '0.9em', color: '#666', marginTop: '10px' }}>
            (提示:此操作仅清除前端状态。如需完全断开,请在 MetaMask 中锁定钱包。)
          </p>
        </div>
      ) : (
        <button
          onClick={connectWallet}
          disabled={isConnecting}
          style={{
            padding: '12px 24px',
            background: isConnecting ? '#ccc' : '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: isConnecting ? 'not-allowed' : 'pointer',
            fontSize: '16px',
          }}
        >
          {isConnecting ? '连接中...' : '连接 MetaMask 钱包'}
        </button>
      )}

      {!window.ethereum && (
        <div style={{ marginTop: '20px', padding: '15px', background: '#fff3cd', borderRadius: '4px' }}>
          <p>⚠️ 未检测到 Web3 钱包。</p>
          <p>
            请安装 <a href="https://metamask.io/" target="_blank" rel="noopener noreferrer">MetaMask</a> 或其他兼容的以太坊钱包扩展。
          </p>
        </div>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. ethers.providers.Web3Provider 在 v6 中已废弃

    • 报错: ethers.providers.Web3Provider is not a constructor
    • 原因: 我一开始照着 v5 的文档写,但项目安装的是 v6。
    • 解决: 在 ethers.js v6 中,应使用 new ethers.BrowserProvider(window.ethereum)
  2. 用户拒绝连接后,再次连接无反应

    • 现象: 用户第一次点击“拒绝”后,再次点击连接按钮,MetaMask 不再弹窗。
    • 原因: MetaMask 会“记住”用户的拒绝操作。eth_requestAccounts 在用户拒绝后,短时间内再次调用不会触发弹窗。
    • 解决: 在 UI 上明确提示用户“您已拒绝,如需连接请刷新页面或手动在 MetaMask 中授权”,或者引导用户点击 MetaMask 扩展图标重新授权。这是一个产品层面的设计选择。
  3. 事件监听器重复绑定导致内存泄漏和多次触发

    • 现象: 切换账户时,控制台打印了多次 accountsChanged 日志。
    • 原因: 每次调用 connectWallet 或组件重新渲染时,没有清理旧的事件监听器,导致同一个函数被绑定了多次。
    • 解决: 使用 useRef 存储清理函数,在绑定新监听前执行旧的清理函数,并在组件卸载时确保清理。
  4. TypeScript 类型 window.ethereum 报错

    • 报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis'.
    • 解决: 在全局声明文件中(或当前文件顶部)使用 declare global 扩展 Window 接口,并赋予其 Eip1193Provider 类型(这是 ethers v6 推荐的类型)。这提供了完美的类型安全和代码提示。

小结

通过这次实战,我深刻体会到,一个生产级的 Web3 钱包连接,远不止调用一个 API 那么简单。它需要健壮的错误处理、实时的状态监听、清晰的用户反馈和安全的类型定义。现在,我把这个打磨好的 useWallet Hook 放进了我的项目工具箱里,下次遇到类似需求,就能从容应对了。当然,这只是一个起点,后续还可以在此基础上集成更多功能,比如自动切换至指定测试网、获取用户签名消息、与后端进行登录验证等。

从零到一连接Solana:我在React项目中集成@solana/web3.js的实战与踩坑

作者 竹林818
2026年4月9日 18:01

背景

上个月,团队决定切入Solana生态,开发一个轻量级的NFT铸造平台。作为前端负责人,我的任务很明确:快速搭建一个能与Solana区块链交互的DApp界面。我之前的主要经验都在EVM链上,用惯了ethers.jswagmi,那一套流程已经刻在DNA里了。本以为切换到Solana,换个库@solana/web3.js应该大同小异,结果从项目初始化开始,就发现“水土不服”的情况比想象中多得多。最大的挑战不是理解概念,而是在真实的React组件中,如何稳定、优雅地实现连接钱包、读取链上数据、发送交易这一整套流程,并处理好各种边界情况和用户反馈。

问题分析

一开始,我的思路很“EVM”:找个类似wagmiRainbowKit的Solana一站式解决方案。我确实找到了@solana/wallet-adapter系列库,它提供了钱包连接器和React上下文。然而,在集成基础库@solana/web3.js时,我直接按照官方文档最简示例写,遇到了第一个拦路虎:连接对象(Connection)的创建与RPC节点的稳定性。文档里简单一句new Connection(clusterApiUrl('devnet')),在实际使用中频繁出现响应缓慢甚至超时,导致页面加载卡住,用户体验极差。我意识到,不能直接使用公共RPC,需要更可控的连接策略。同时,在尝试发送一笔简单的转账交易时,我遇到了各种序列化和签名错误,控制台报错信息对于新手来说并不友好,我需要拆解出从创建交易到广播的每一步,并找到其中容易出错的关键点。

核心实现

第一步:建立稳定且可配置的RPC连接

我放弃了直接使用clusterApiUrl,因为它指向的公共节点在流量大时很不稳定。解决方案是使用一个可靠的RPC服务提供商(如QuickNode、Helius等)的私有端点,并封装一个可重试、可降级的连接创建函数。

这里有个关键点:@solana/web3.jsConnection构造函数第二个参数可以设置commitment级别和WebSocket配置。commitment决定了节点确认数据的程度,对于查询余额,‘confirmed’通常就够了,但对于交易,有时需要‘finalized’。另外,启用disableRetryOnRateLimit对于私有端点通常设为false。

import { Connection, clusterApiUrl } from '@solana/web3.js';

// 配置你的RPC端点,优先使用环境变量中的私有端点
const getRpcUrl = (): string => {
  // 从环境变量读取,如果没有则降级到公共开发网节点(不推荐生产环境)
  return process.env.REACT_APP_SOLANA_RPC_URL || clusterApiUrl('devnet');
};

export const createConnection = (): Connection => {
  const rpcUrl = getRpcUrl();
  console.log(`Connecting to RPC: ${rpcUrl}`);
  
  return new Connection(rpcUrl, {
    commitment: 'confirmed', // 默认确认级别
    disableRetryOnRateLimit: false, // 启用速率限制重试
    confirmTransactionInitialTimeout: 60000, // 增加交易确认超时时间
  });
};

// 在应用中作为单例使用
export const connection = createConnection();

第二步:集成钱包适配器与连接状态管理

我选择了@solana/wallet-adapter-react@solana/wallet-adapter-wallets来管理钱包连接UI和状态。这一步相对顺畅,但需要注意钱包插件的动态导入以避免首屏加载过大。

注意这个细节WalletAdapterNetwork用于指定网络,但如果你用的私有RPC,需要确保钱包插件(如Phantom)也切换到对应网络(如Devnet)。

// WalletProvider.tsx
import React, { useMemo } from 'react';
import { ConnectionProvider, WalletProvider as SolanaWalletProvider } from '@solana/wallet-adapter-react';
import { WalletAdapterNetwork } from '@solana/wallet-adapter-base';
import { PhantomWalletAdapter, SolflareWalletAdapter } from '@solana/wallet-adapter-wallets';
import { WalletModalProvider } from '@solana/wallet-adapter-react-ui';
import { clusterApiUrl } from '@solana/web3.js';

// 导入默认样式
import '@solana/wallet-adapter-react-ui/styles.css';

// 使用我们上面创建的稳定连接
import { connection } from './utils/connection';

export const WalletProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // 可以根据需要动态设置网络,这里我们与connection保持一致(假设是devnet)
  const network = WalletAdapterNetwork.Devnet;

  // 动态初始化钱包适配器
  const wallets = useMemo(
    () => [
      new PhantomWalletAdapter(),
      new SolflareWalletAdapter({ network }),
      // 可以添加更多钱包
    ],
    [network]
  );

  return (
    <ConnectionProvider endpoint={connection.rpcEndpoint}>
      <SolanaWalletProvider wallets={wallets} autoConnect>
        <WalletModalProvider>{children}</WalletModalProvider>
      </SolanaWalletProvider>
    </ConnectionProvider>
  );
};

index.tsxApp.tsx中用WalletProvider包裹你的应用。

第三步:获取账户余额与代币信息

连接钱包后,第一件事就是显示用户的SOL余额。这里需要用到connection.getBalance方法,参数是用户的公钥(PublicKey)。

这里有个坑getBalance返回的值是以lamports为单位的,1 SOL = 10^9 lamports。需要手动转换。另外,余额查询是一个异步操作,需要考虑加载和错误状态。

import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useState, useEffect } from 'react';

export const BalanceDisplay: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, connected } = useWallet();
  const [balance, setBalance] = useState<number | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchBalance = async () => {
      if (!connected || !publicKey) {
        setBalance(null);
        return;
      }
      setLoading(true);
      setError(null);
      try {
        const balanceInLamports = await connection.getBalance(publicKey);
        const balanceInSOL = balanceInLamports / LAMPORTS_PER_SOL;
        setBalance(balanceInSOL);
      } catch (err: any) {
        console.error('Failed to fetch balance:', err);
        setError(err.message);
        setBalance(null);
      } finally {
        setLoading(false);
      }
    };

    fetchBalance();
    // 可以设置一个定时器轮询余额,或者监听区块变化来更新,这里简单处理
    const intervalId = setInterval(fetchBalance, 30000); // 每30秒更新一次
    return () => clearInterval(intervalId);
  }, [connection, publicKey, connected]);

  if (!connected) return <p>请连接钱包</p>;
  if (loading) return <p>查询余额中...</p>;
  if (error) return <p>错误: {error}</p>;
  return <p>余额: {balance !== null ? `${balance.toFixed(4)} SOL` : 'N/A'}</p>;
};

第四步:构造并发送一笔SOL转账交易

这是最核心也最容易出错的部分。一笔基本的SOL转账涉及:创建交易指令SystemProgram.transfer,将指令添加到交易中,获取最近区块哈希(recent blockhash),设置手续费支付者,最后由钱包签名并发送。

踩坑预警

  1. 区块哈希(blockhash):每笔交易都需要一个最近的区块哈希,用于交易过期和防止重放。必须通过connection.getLatestBlockhash()获取,不能使用过期的。
  2. 签名者数组sendTransaction需要传入签名者数组。对于简单的转账,只有付款人(即连接的钱包)需要签名,但必须将其钱包适配器对象转换成Signer接口要求的格式。@solana/wallet-adapter-reactuseWallet钩子提供了signTransaction方法,但更简单的方式是使用钱包适配器实例本身。
  3. 交易确认:发送交易后,sendTransaction返回的是交易签名(txid)。这并不代表交易成功,必须调用connection.confirmTransaction来等待网络确认。
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import { SystemProgram, Transaction, PublicKey, LAMPORTS_PER_SOL } from '@solana/web3.js';
import { useState } from 'react';

export const TransferSol: React.FC = () => {
  const { connection } = useConnection();
  const { publicKey, sendTransaction } = useWallet();
  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');
  const [status, setStatus] = useState<'idle' | 'sending' | 'confirming' | 'success' | 'error'>('idle');
  const [txSignature, setTxSignature] = useState<string>('');
  const [errorMsg, setErrorMsg] = useState('');

  const handleTransfer = async () => {
    if (!publicKey || !recipient || !amount) {
      setErrorMsg('请填写完整信息并确保钱包已连接');
      return;
    }
    setStatus('sending');
    setErrorMsg('');
    setTxSignature('');

    try {
      // 1. 验证接收地址
      const toPubkey = new PublicKey(recipient);
      // 2. 转换金额为lamports
      const lamports = parseFloat(amount) * LAMPORTS_PER_SOL;
      if (isNaN(lamports) || lamports <= 0) {
        throw new Error('请输入有效的金额');
      }

      // 3. 获取最新的区块哈希和区块高度
      const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');

      // 4. 创建转账指令
      const transferInstruction = SystemProgram.transfer({
        fromPubkey: publicKey,
        toPubkey: toPubkey,
        lamports,
      });

      // 5. 创建交易并添加指令
      const transaction = new Transaction({
        feePayer: publicKey,
        recentBlockhash: blockhash,
      }).add(transferInstruction);

      // 6. 发送交易并获取签名
      const signature = await sendTransaction(transaction, connection);
      setTxSignature(signature);
      setStatus('confirming');
      console.log(`交易已发送,签名: ${signature}`);

      // 7. 确认交易
      const confirmation = await connection.confirmTransaction({
        signature,
        blockhash,
        lastValidBlockHeight,
      }, 'confirmed');

      if (confirmation.value.err) {
        throw new Error(`交易确认失败: ${JSON.stringify(confirmation.value.err)}`);
      }
      setStatus('success');
      console.log('交易成功确认!');

    } catch (error: any) {
      console.error('转账失败:', error);
      setStatus('error');
      setErrorMsg(error.message || '未知错误');
    }
  };

  return (
    <div>
      <h3>转账SOL</h3>
      <div>
        <input
          type="text"
          placeholder="接收方地址"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          type="number"
          step="0.001"
          placeholder="金额 (SOL)"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button onClick={handleTransfer} disabled={status === 'sending' || status === 'confirming'}>
          {status === 'sending' ? '发送中...' : status === 'confirming' ? '确认中...' : '转账'}
        </button>
      </div>
      {status === 'success' && <p style={{ color: 'green' }}>转账成功!签名: {txSignature}</p>}
      {status === 'error' && <p style={{ color: 'red' }}>错误: {errorMsg}</p>}
      {txSignature && status !== 'error' && (
        <p>
          交易签名: <a href={`https://explorer.solana.com/tx/${txSignature}?cluster=devnet`} target="_blank" rel="noreferrer">在浏览器查看</a>
        </p>
      )}
    </div>
  );
};

完整代码示例

以下是一个整合了以上所有功能的简化版App.tsx

// App.tsx
import React from 'react';
import { WalletMultiButton } from '@solana/wallet-adapter-react-ui';
import { BalanceDisplay } from './components/BalanceDisplay';
import { TransferSol } from './components/TransferSol';
import { WalletProvider } from './providers/WalletProvider';
import './App.css';

// 主应用组件,需要被WalletProvider包裹
const AppContent: React.FC = () => {
  return (
    <div className="App">
      <header>
        <h1>Solana NFT平台(演示)</h1>
        <WalletMultiButton />
      </header>
      <main>
        <section>
          <h2>账户信息</h2>
          <BalanceDisplay />
        </section>
        <section>
          <h2>转账功能</h2>
          <TransferSol />
        </section>
        {/* 后续可以添加NFT查询、铸造等组件 */}
      </main>
    </div>
  );
};

// 顶层App,提供钱包上下文
const App: React.FC = () => {
  return (
    <WalletProvider>
      <AppContent />
    </WalletProvider>
  );
};

export default App;

踩坑记录

  1. Transaction recent blockhash required 错误:这是我最早遇到的错误。我一开始手动写死了一个区块哈希,或者忘记设置recentBlockhash解决方法:必须通过connection.getLatestBlockhash()动态获取,并确保这个哈希在交易被确认前是有效的(通过lastValidBlockHeight判断)。

  2. Signature verification failedWallet not connected 错误:在调用sendTransaction时,虽然钱包连接着,但交易签名失败。排查发现:我错误地尝试自己用私钥签名,或者没有正确使用钱包适配器提供的sendTransaction方法。解决方法:在React组件中,始终使用useWallet钩子暴露出的sendTransaction方法,它会自动处理与钱包扩展的交互和签名。

  3. 交易发送成功但一直不确认(Pending):在Devnet上,有时交易会卡住。原因:可能是RPC节点问题,或者手续费不足(虽然SOL转账手续费极低且固定)。解决方法:首先检查使用的RPC节点是否健康;其次,在confirmTransaction时使用更长的超时时间(如上面代码中在创建Connection时设置confirmTransactionInitialTimeout);最后,可以尝试重新获取一个全新的区块哈希并重新构建交易。

  4. 类型错误:Property ‘publicKey’ does not exist on type ‘WalletContextState’:在使用useWallet()的解构时,publicKey可能是null解决方法:在代码中始终对publicKeyconnected状态进行判空处理,使用可选链操作符?.或条件渲染。TypeScript的严格模式会强制你处理这些可能为null的情况,这是好事。

小结

通过这个项目,我深刻体会到不同区块链生态的前端开发虽有共通模式,但魔鬼藏在细节里。@solana/web3.js的核心在于对交易结构(Transaction, Instruction)和网络状态(Blockhash, Commitment)的精细控制。下一步,我可以在此基础上深入代币(SPL Token)操作、NFT元数据获取与铸造等更复杂的交互场景,并考虑引入状态管理库(如Zustand)来更好地管理全局的链上数据和交易状态。

从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

作者 竹林818
2026年4月8日 18:02

背景

我负责维护一个已经运行两年的DeFi项目前端,技术栈是React + TypeScript + ethers.js 5.7。最近在做性能优化时发现,打包后的bundle size比同类项目大了近30%,经过分析,ethers.js占了相当大的比重。同时,项目中的一些复杂类型定义在ethers.js下显得很冗长,类型提示也不够友好。

团队讨论后决定尝试迁移到Viem。Viem是较新的以太坊JavaScript库,以类型安全、模块化、轻量化为特点。但迁移一个生产环境项目不是简单的替换import语句,我需要在保证现有功能完全正常的前提下完成迁移。

问题分析

最初我以为迁移就是换个库,把ethers.providers.Web3Provider换成viem/createWalletClient就行了。但实际一开始就遇到了问题:

  1. 类型系统完全不同:ethers.js使用自己的BigNumber类型,而Viem直接使用原生bigint
  2. 事件监听机制差异:ethers.js的合约事件监听和Viem的watchContractEvent参数结构完全不同
  3. 多链支持方式不同:我们项目支持Ethereum、Polygon、Arbitrum三条链,ethers.js通过Network对象管理,Viem有自己的一套链定义

最头疼的是,项目中有上百处以太坊交互代码,分布在组件、hooks、工具函数中,不可能一次性全部重写。我需要一个渐进式的迁移方案。

核心实现

第一步:搭建双库共存环境

我决定先让两个库共存,逐步迁移模块。首先安装必要的Viem包:

npm install viem wagmi

然后创建了一个lib/viem-client.ts文件,初始化基础客户端:

import { createPublicClient, http } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 根据链ID获取对应的Viem链配置
export function getChainConfig(chainId: number) {
  switch (chainId) {
    case 1: return mainnet
    case 137: return polygon
    case 42161: return arbitrum
    default: return mainnet
  }
}

// 创建公共客户端(用于读取数据)
export function createViemPublicClient(chainId: number) {
  const chain = getChainConfig(chainId)
  const transport = http(process.env.NEXT_PUBLIC_RPC_URL)
  
  return createPublicClient({
    chain,
    transport,
  })
}

// 这里有个坑:Viem的链配置需要和你的项目实际使用的RPC节点匹配
// 如果RPC节点不支持某些方法,需要在transport中配置

同时,我保留了现有的ethers.js代码,只是在新写的功能中使用Viem。

第二步:处理BigNumber类型转换

这是迁移中最频繁遇到的问题。我们的项目中有大量的金额计算、余额显示逻辑,原来都使用ethers.js的BigNumber。

我创建了一个转换工具函数:

import { BigNumber } from 'ethers'
import { formatUnits, parseUnits } from 'viem'

/**
 * 将ethers.js的BigNumber转换为Viem兼容的bigint
 * 注意:这里要处理undefined和null的情况
 */
export function bigNumberToBigInt(value?: BigNumber): bigint {
  if (!value) return 0n
  return BigInt(value.toString())
}

/**
 * 将Viem的bigint转换回ethers.js的BigNumber(用于过渡期)
 */
export function bigIntToBigNumber(value: bigint): BigNumber {
  return BigNumber.from(value.toString())
}

/**
 * 统一格式化显示金额
 * 原来用ethers.utils.formatUnits,现在用viem的formatUnits
 * 注意:viem的formatUnits返回string,而ethers返回string
 */
export function formatTokenAmount(
  amount: bigint | BigNumber,
  decimals: number
): string {
  const amountBigInt = amount instanceof BigNumber 
    ? bigNumberToBigInt(amount)
    : amount
  
  return formatUnits(amountBigInt, decimals)
}

第三步:重写合约交互层

我们项目中有几十个合约交互的hooks,这是迁移的重点。我选择从最常用的ERC20代币合约开始。

原来的ethers.js版本:

// 旧的ERC20 Hook (ethers.js)
import { Contract } from 'ethers'
import ERC20_ABI from '../abis/ERC20.json'

export function useERC20(contractAddress: string, signer: any) {
  const contract = new Contract(contractAddress, ERC20_ABI, signer)
  
  const getBalance = async (account: string) => {
    return await contract.balanceOf(account)
  }
  
  const transfer = async (to: string, amount: BigNumber) => {
    const tx = await contract.transfer(to, amount)
    return await tx.wait()
  }
  
  return { getBalance, transfer }
}

迁移到Viem的版本:

// 新的ERC20 Hook (Viem)
import { createPublicClient, createWalletClient, custom, http } from 'viem'
import { mainnet } from 'viem/chains'
import { useAccount, useWalletClient } from 'wagmi'

// 注意:Viem需要更精确的ABI类型,不能直接用JSON ABI
import { erc20Abi } from 'viem'
import { usePublicClient } from 'wagmi'

export function useViemERC20(contractAddress: `0x${string}`) {
  const { address } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const getBalance = async (account?: `0x${string}`) => {
    if (!publicClient) throw new Error('No public client')
    
    const balance = await publicClient.readContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [account || address!],
    })
    
    return balance as bigint
  }
  
  const transfer = async (to: `0x${string}`, amount: bigint) => {
    if (!walletClient || !address) throw new Error('Wallet not connected')
    
    const hash = await walletClient.writeContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [to, amount],
      account: address,
    })
    
    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash })
    return receipt
  }
  
  return { getBalance, transfer }
}

这里有个重要的坑:Viem要求地址必须是0x${string}类型,而不是普通的string。这意味着所有合约地址、用户地址都需要进行类型转换。我创建了一个类型守卫函数:

export function isValidAddress(address: string): address is `0x${string}` {
  return /^0x[a-fA-F0-9]{40}$/.test(address)
}

export function toViemAddress(address: string): `0x${string}` {
  if (!isValidAddress(address)) {
    throw new Error(`Invalid address format: ${address}`)
  }
  return address as `0x${string}`
}

第四步:处理事件监听

我们项目中有很多实时数据更新依赖于合约事件。ethers.js的事件监听和Viem完全不同。

原来的事件监听:

// ethers.js 事件监听
contract.on('Transfer', (from, to, amount, event) => {
  console.log('Transfer event:', { from, to, amount })
  updateUI()
})

迁移到Viem的事件监听:

// Viem 事件监听
import { watchContractEvent } from 'viem'

const unwatch = watchContractEvent({
  address: contractAddress,
  abi: erc20Abi,
  eventName: 'Transfer',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log('Transfer event:', {
        from: args.from,
        to: args.to,
        amount: args.value
      })
      updateUI()
    })
  },
})

// 注意:Viem的watchContractEvent返回一个取消监听的函数
// 在React组件中需要在useEffect中清理
useEffect(() => {
  const unwatch = watchContractEvent({ ... })
  
  return () => {
    unwatch()
  }
}, [])

这里踩了个坑:Viem的事件参数args可能是undefined,需要做安全处理:

onLogs: (logs) => {
  logs.forEach((log) => {
    if (!log.args) return
    
    const { from, to, value } = log.args
    // 现在from, to, value都是可选的,需要类型断言
    if (from && to && value) {
      // 处理事件
    }
  })
}

第五步:集成Wagmi管理状态

为了更好的React集成,我引入了Wagmi。Wagmi是基于Viem的React Hooks库,类似于ethers.js的useDapp或web3-react。

配置Wagmi:

// lib/wagmi-config.ts
import { createConfig, configureChains } from 'wagmi'
import { publicProvider } from 'wagmi/providers/public'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { InjectedConnector } from 'wagmi/connectors/injected'
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [publicProvider()]
)

export const config = createConfig({
  autoConnect: true,
  connectors: [
    new InjectedConnector({ chains }),
    new WalletConnectConnector({
      chains,
      options: {
        projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
      },
    }),
  ],
  publicClient,
  webSocketPublicClient,
})

然后在App中包裹WagmiProvider:

import { WagmiConfig } from 'wagmi'
import { config } from '../lib/wagmi-config'

function App() {
  return (
    <WagmiConfig config={config}>
      <YourApp />
    </WagmiConfig>
  )
}

完整代码示例

下面是一个完整的、可运行的ERC20余额查询和转账组件,展示了Viem + Wagmi的实际使用:

import React, { useState, useEffect } from 'react'
import { useAccount, usePublicClient, useWalletClient } from 'wagmi'
import { erc20Abi } from 'viem'
import { formatUnits, parseUnits } from 'viem'
import { isValidAddress, toViemAddress } from '../lib/address-utils'

// 假设的USDC合约地址
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'

function ERC20Transfer() {
  const { address, isConnected } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const [balance, setBalance] = useState<bigint>(0n)
  const [recipient, setRecipient] = useState('')
  const [amount, setAmount] = useState('')
  const [loading, setLoading] = useState(false)
  
  // 获取余额
  const fetchBalance = async () => {
    if (!publicClient || !address) return
    
    try {
      const balance = await publicClient.readContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address],
      })
      
      setBalance(balance as bigint)
    } catch (error) {
      console.error('Failed to fetch balance:', error)
    }
  }
  
  // 转账
  const handleTransfer = async () => {
    if (!walletClient || !address || !recipient || !amount) return
    if (!isValidAddress(recipient)) {
      alert('Invalid recipient address')
      return
    }
    
    setLoading(true)
    try {
      // USDC有6位小数
      const amountInWei = parseUnits(amount, 6)
      
      const hash = await walletClient.writeContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'transfer',
        args: [toViemAddress(recipient), amountInWei],
        account: address,
      })
      
      console.log('Transaction hash:', hash)
      
      // 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({ hash })
      console.log('Transaction confirmed:', receipt)
      
      // 更新余额
      await fetchBalance()
      setAmount('')
      setRecipient('')
      
      alert('Transfer successful!')
    } catch (error: any) {
      console.error('Transfer failed:', error)
      alert(`Transfer failed: ${error.shortMessage || error.message}`)
    } finally {
      setLoading(false)
    }
  }
  
  // 监听地址变化,重新获取余额
  useEffect(() => {
    if (address) {
      fetchBalance()
    }
  }, [address, publicClient])
  
  if (!isConnected) {
    return <div>Please connect your wallet</div>
  }
  
  return (
    <div>
      <h2>USDC Balance: {formatUnits(balance, 6)}</h2>
      
      <div>
        <input
          type="text"
          placeholder="Recipient address"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          type="text"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button 
          onClick={handleTransfer} 
          disabled={loading || !recipient || !amount}
        >
          {loading ? 'Processing...' : 'Transfer'}
        </button>
      </div>
      
      <button onClick={fetchBalance} style={{ marginTop: '20px' }}>
        Refresh Balance
      </button>
    </div>
  )
}

export default ERC20Transfer

踩坑记录

在实际迁移过程中,我遇到了不少预料之外的问题:

  1. 类型错误:Argument of type 'string' is not assignable to parameter of type 'Hex'

    • 问题:Viem严格要求地址类型为0x${string}(Hex类型)
    • 解决:创建了toViemAddress类型转换函数和isValidAddress类型守卫
  2. 事件监听内存泄漏

    • 问题:Viem的watchContractEvent不会自动清理,在React组件卸载后仍在监听
    • 解决:必须在useEffect的清理函数中调用返回的unwatch函数
  3. BigInt序列化问题

    • 问题:将包含bigint的对象直接存入Redux或传递给API会报错
    • 解决:在存储前转换为string,使用时再转回bigint,或者使用支持bigint的序列化库
  4. RPC方法不支持

    • 问题:某些自定义RPC节点不支持Viem默认调用的方法
    • 解决:在创建transport时指定支持的RPC方法,或使用Alchemy、Infura等标准节点
  5. ABI类型不匹配

    • 问题:直接从原有项目复制的JSON ABI在Viem中类型推断失败
    • 解决:使用Viem提供的标准ABI(如erc20Abi),或使用as const断言自定义ABI

小结

从ethers.js迁移到Viem确实需要投入不少精力,但带来的类型安全、包体积减小和更现代的API设计是值得的。最关键的是采用渐进式迁移,先让两个库共存,逐步替换模块。对于新开始的Web3项目,我会直接选择Viem + Wagmi的组合。

React + wagmi 实战:从零构建一个能“读”能“写”的 DeFi 前端,我踩了这些坑

作者 竹林818
2026年4月8日 10:02

背景

上个月,我接手了一个去中心化借贷协议前端页面的迭代任务。核心功能很简单:用户连接钱包后,页面需要实时显示其 ETH 余额和特定 ERC20 代币的余额,并且能够进行存款和借款操作。团队决定采用 wagmiviem 作为新的 Web3 开发栈,以替代之前略显笨重的 ethers.js + web3-react 组合,目标是让代码更简洁、类型提示更友好。

我本以为有了 wagmi 这种高度封装的 Hooks 库,开发会一帆风顺。但真正动手后才发现,从“显示余额”到“完成一笔交易”,每一步都藏着细节。如何优雅地管理多合约实例?如何让 UI 精准响应链上状态变化?如何处理用户拒绝交易或网络切换?这些才是实战中的真问题。

问题分析

我的初始思路很直接:用 useAccount 拿到用户地址,用 useBalanceuseContractRead 获取余额,用 useContractWrite 发送交易。然而,第一个页面刚搭起来就遇到了问题。

  1. 数据不同步:用户切换钱包账户后,余额显示有延迟,有时甚至不更新。
  2. 合约交互僵硬useContractWrite 返回的 write 函数调用后,对 pending、success、error 状态的处理分散在多个地方,逻辑混乱。
  3. 网络切换灾难:用户如果从主网切换到其他链,页面没有友好提示,合约调用会直接失败。

排查后发现,问题根源在于我没有理解 wagmi Hooks 的依赖关系和生命周期。比如 useBalance 默认不会在每次地址变化时主动重新查询,而 useContractWrite 的配置也需要根据当前连接的网络动态生成。我意识到,必须把 wagmi 的配置、合约实例的创建和状态管理作为一个整体来设计。

核心实现

第一步:搭建项目并配置 wagmi 与连接器

首先,创建一个新的 React 项目并安装核心依赖。

npm create vite@latest defi-demo -- --template react-ts
cd defi-demo
npm install wagmi viem @tanstack/react-query
npm install @rainbow-me/rainbowkit  # 用于美观的钱包连接按钮

接下来是重头戏:配置 wagmi。我选择在 main.tsxApp.tsx 的顶层进行配置。这里的关键是创建 wagmiConfig,它定义了项目要支持的链、钱包连接器以及公共客户端。

// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
import App from './App.tsx'
import { config } from './wagmi.config'
import '@rainbow-me/rainbowkit/styles.css'

const queryClient = new QueryClient()

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <App />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  </React.StrictMode>,
)

单独的配置文件 wagmi.config.ts 能让结构更清晰:

// src/wagmi.config.ts
import { http, createConfig } from 'wagmi'
import { mainnet, sepolia } from 'wagmi/chains'
import { injected, walletConnect } from 'wagmi/connectors'

// 这里有个坑:如果只开发测试网,记得把 mainnet 也加上,
// 因为很多钱包默认连接主网,不配置的话切换网络会出问题。
export const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    injected(),
    walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID }),
  ],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
})

注意这个细节transports 配置为每个链指定了 RPC 提供商。生产环境建议使用 Infura 或 Alchemy 的私有节点 URL,避免公共 RPC 的速率限制。

第二步:读取用户余额与合约状态

在 App 组件中,我们开始读取数据。这里用到了 useAccount, useBalance, useContractReads

// src/App.tsx
import { useAccount, useBalance, useChainId } from 'wagmi'
import { erc20Abi } from 'viem'

// 假设我们关注的代币是 USDC(主网合约地址)
const USDC_ADDRESS_MAINNET = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'

function App() {
  const { address, isConnected } = useAccount()
  const chainId = useChainId()
  // 读取原生币余额
  const { data: ethBalance, refetch: refetchEth } = useBalance({
    address,
    // 重点:监听 address 变化,自动重新查询
    query: { enabled: !!address },
  })

  // 读取 ERC20 代币余额和允许额度
  const { data: contractReads } = useContractReads({
    contracts: [
      {
        address: USDC_ADDRESS_MAINNET,
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address!],
      },
      {
        address: USDC_ADDRESS_MAINNET,
        abi: erc20Abi,
        functionName: 'allowance',
        args: [address!, '0xYourLendingContractAddress'],
      },
    ],
    // 只有连接钱包且地址存在时才查询
    query: { enabled: isConnected && !!address },
  })

  const [usdcBalance, allowance] = contractReads || []

  return (
    <div>
      {isConnected ? (
        <div>
          <p>ETH 余额: {ethBalance?.formatted}</p>
          <p>USDC 余额: {usdcBalance ? formatUnits(usdcBalance.result as bigint, 6) : '--'}</p>
        </div>
      ) : (
        <p>请连接钱包</p>
      )}
    </div>
  )
}

这里有个坑useContractReads 返回的 data 是一个数组,每个元素是一个对象,包含 result, status, error。直接使用 usdcBalance.result 前一定要做类型判断,因为初始状态是 undefined。另外,USDC 是 6 位小数,格式化时要用 formatUnits(value, 6),而不是默认的 18 位。

第三步:实现存款交易(合约写入)

这是最核心的部分。我们不仅要发起交易,还要处理交易状态,给用户明确的反馈。

// 在 App.tsx 内新增组件或函数
import { useState } from 'react'
import { useContractWrite, useWaitForTransactionReceipt } from 'wagmi'
import { parseUnits } from 'viem'

function DepositSection() {
  const [depositAmount, setDepositAmount] = useState('')
  const { address } = useAccount()

  // 1. 创建写入合约调用
  const {
    writeContract,
    data: hash,
    isPending: isWritePending,
    error: writeError,
    reset: resetWrite,
  } = useContractWrite()

  // 2. 等待交易上链
  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({
    hash,
  })

  const handleDeposit = async () => {
    if (!depositAmount || !address) return
    const amountInWei = parseUnits(depositAmount, 6) // USDC 小数位

    // 注意:这里调用的是模拟的借贷合约的 `deposit` 方法
    writeContract({
      address: '0xYourLendingContractAddress',
      abi: lendingPoolAbi, // 需要从项目获取真实的 ABI
      functionName: 'deposit',
      args: [USDC_ADDRESS_MAINNET, amountInWei, address],
    })
  }

  // 3. 综合状态管理
  const isProcessing = isWritePending || isConfirming
  const error = writeError || receiptError

  // 交易确认成功后,重置表单和状态
  if (isConfirmed) {
    // 可以在这里触发余额重新查询
    setTimeout(() => {
      setDepositAmount('')
      resetWrite()
    }, 3000)
  }

  return (
    <div>
      <input
        value={depositAmount}
        onChange={(e) => setDepositAmount(e.target.value)}
        placeholder="存入 USDC 数量"
        disabled={isProcessing}
      />
      <button onClick={handleDeposit} disabled={!depositAmount || isProcessing}>
        {isProcessing ? '处理中...' : '存入'}
      </button>
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
      {isConfirming && <p>交易已提交,等待确认...</p>}
      {isConfirmed && <p style={{ color: 'green' }}>存款成功!</p>}
    </div>
  )
}

关键点:我拆分了 useContractWriteuseWaitForTransactionReceipt。这是 wagmi 推荐的模式。useContractWrite 只负责将交易发送到用户钱包并获取交易哈希(hash)。useWaitForTransactionReceipt 则监听这个哈希,在链上确认后更新状态。这样的分离让 UI 可以更精细地展示“等待钱包签名”和“等待链上确认”两个不同阶段。

第四步:处理网络切换与错误边界

用户可能在任何时候切换网络,我们的应用需要优雅应对。

import { useSwitchChain } from 'wagmi'

function NetworkGuard() {
  const { chain } = useAccount()
  const { chains, switchChain } = useSwitchChain()
  const supportedChainIds = chains.map(c => c.id)
  const currentChainId = chain?.id

  // 如果当前网络不在支持列表中,提示切换
  if (currentChainId && !supportedChainIds.includes(currentChainId)) {
    return (
      <div>
        <p>当前网络不支持,请切换到以下网络之一:</p>
        {chains.map((c) => (
          <button key={c.id} onClick={() => switchChain({ chainId: c.id })}>
            切换到 {c.name}
          </button>
        ))}
      </div>
    )
  }

  return null // 渲染主界面
}

// 在 App 组件中使用
function App() {
  return (
    <div>
      <NetworkGuard />
      {/* 其他组件 */}
    </div>
  )
}

此外,对于合约调用可能抛出的错误(如余额不足、未授权),我们需要在 UI 层进行捕获和友好提示。useContractWriteerror 对象包含了丰富的错误信息,可以通过解析 error.shortMessageerror.cause 来生成用户能看懂的文字。

完整代码示例

以下是一个简化但可运行的 App.tsx 核心部分,集成了上述功能:

// src/App.tsx
import { useState } from 'react'
import { ConnectButton } from '@rainbow-me/rainbowkit'
import { useAccount, useBalance, useChainId, useContractReads, useContractWrite, useWaitForTransactionReceipt, useSwitchChain } from 'wagmi'
import { formatUnits, parseUnits, erc20Abi } from 'viem'

// 合约地址 (Sepolia测试网示例)
const USDC_SEPOLIA = '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238'
const LENDING_POOL = '0x...' // 替换为实际借贷合约地址
// 简化的借贷合约 ABI,仅包含 deposit 函数
const lendingPoolAbi = [
  {
    "inputs": [
      { "internalType": "address", "name": "asset", "type": "address" },
      { "internalType": "uint256", "name": "amount", "type": "uint256" },
      { "internalType": "address", "name": "onBehalfOf", "type": "address" }
    ],
    "name": "deposit",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
] as const

function App() {
  const { address, isConnected, chain } = useAccount()
  const { chains, switchChain } = useSwitchChain()
  const supportedChainIds = chains.map(c => c.id)
  const currentChainId = chain?.id

  // 1. 读取余额
  const { data: ethBalance } = useBalance({ address, query: { enabled: !!address } })
  const { data: contractReads } = useContractReads({
    contracts: [
      {
        address: USDC_SEPOLIA,
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address!],
      },
    ],
    query: { enabled: isConnected && !!address },
  })
  const usdcBalanceResult = contractReads?.[0]?.result

  // 2. 存款交易状态
  const [depositAmount, setDepositAmount] = useState('')
  const {
    writeContract,
    data: hash,
    isPending: isWritePending,
    error: writeError,
    reset: resetWrite,
  } = useContractWrite()
  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: receiptError,
  } = useWaitForTransactionReceipt({ hash })

  const handleDeposit = () => {
    if (!depositAmount || !address) return
    const amountInWei = parseUnits(depositAmount, 6)
    writeContract({
      address: LENDING_POOL,
      abi: lendingPoolAbi,
      functionName: 'deposit',
      args: [USDC_SEPOLIA, amountInWei, address],
    })
  }

  // 3. 网络检查
  if (currentChainId && !supportedChainIds.includes(currentChainId)) {
    return (
      <div>
        <h2>不支持的网络</h2>
        <p>请切换至 Sepolia 测试网</p>
        <button onClick={() => switchChain({ chainId: chains[0].id })}>
          切换到 {chains[0].name}
        </button>
      </div>
    )
  }

  return (
    <div style={{ padding: '2rem' }}>
      <h1>DeFi 借贷模拟</h1>
      <ConnectButton />
      {isConnected && (
        <div>
          <h2>我的资产</h2>
          <p>ETH: {ethBalance?.formatted || '--'}</p>
          <p>USDC: {usdcBalanceResult ? formatUnits(usdcBalanceResult, 6) : '--'}</p>
          <hr />
          <h2>存入资产</h2>
          <input
            value={depositAmount}
            onChange={(e) => setDepositAmount(e.target.value)}
            disabled={isWritePending || isConfirming}
          />
          <button
            onClick={handleDeposit}
            disabled={!depositAmount || isWritePending || isConfirming}
          >
            {isWritePending ? '等待签名...' : isConfirming ? '确认中...' : '存入'}
          </button>
          {(writeError || receiptError) && (
            <p style={{ color: 'red' }}>错误: {(writeError || receiptError)?.message}</p>
          )}
          {isConfirmed && (
            <p style={{ color: 'green' }}>存款成功!交易哈希: {hash}</p>
          )}
          {isConfirmed && setTimeout(() => { resetWrite(); setDepositAmount('') }, 3000) && null}
        </div>
      )}
    </div>
  )
}

export default App

踩坑记录

  1. useBalance 不更新:最初我没设置 query: { enabled: !!address },导致 Hook 在地址变为 undefined(如断开钱包)时仍在尝试查询,造成状态混乱。解决方法:始终根据连接状态和地址有效性来启用查询。

  2. 交易哈希有了,但 useWaitForTransactionReceipt 一直 loading:我一开始用的公共 RPC 节点,有时会出现响应延迟或丢失事件。解决方法:换用更稳定的节点提供商(如 Alchemy 或 Infura),并在 useWaitForTransactionReceipt 中增加重试配置:{ confirmations: 1, retry: true }

  3. ABI 类型错误:将手写的 ABI 数组传给 useContractWrite 时,TypeScript 报类型不匹配。解决方法:使用 as const 断言将 ABI 数组转为字面量类型,或者使用 viem 提供的 Abi 类型。

  4. 用户拒绝交易后的状态残留:用户在钱包弹窗中拒绝签名后,isPending 状态可能仍为 true,按钮保持禁用。解决方法:利用 useContractWrite 返回的 reset 函数,在错误发生时或组件卸载时调用它来重置所有状态。

小结

通过这个项目,我深刻体会到 wagmi 的核心优势在于将链上状态与 React 生命周期进行了无缝融合。成功的秘诀不在于记住每个 Hook 的 API,而在于理解其数据流:配置驱动连接,连接状态驱动查询,查询结果驱动 UI,而用户操作触发写入,写入结果再反馈回状态。下一步,我可以探索 useSimulateContract 进行交易预模拟,以及结合 @tanstack/react-query 做更复杂的数据缓存和失效策略,让应用体验更上一层楼。

RainbowKit快速集成多链钱包连接:从“连不上”到丝滑切换的踩坑实录

作者 竹林818
2026年4月7日 18:01

背景

上个月,我接手了一个新的DeFi聚合器项目的前端重构。这个项目的老前端用的是web3modal + 自定义的链配置,代码已经有点“祖传”的味道了,每次加一条新链都得手动改好几个配置文件,测试起来也麻烦。产品经理提了新需求:要快速支持Arbitrum、Optimism、Polygon等七八条EVM链,并且用户切换链的体验要足够丝滑。

我评估了一下,自己从头用wagmi去搭一套连接组件,虽然灵活,但时间成本太高,光是设计UI和处理好各种边缘情况(比如用户钱包里没添加该链)就得花上好几天。这时候,我想到了RainbowKit——一个基于wagmi构建的、开箱即用的钱包连接套件,UI漂亮,文档说支持多链配置。心想,用它应该能快速搞定,把时间省下来去处理更复杂的业务逻辑。于是,我的“快速集成”之旅开始了,没想到,快是快了,坑也是一个没少踩。

问题分析

一开始,我的思路很简单:照着RainbowKit官方文档的“Getting Started”部分,安装依赖,用getDefaultConfig搞个配置,把RainbowKitProviderWagmiProvider一套,最后把ConnectButton一扔,不就完事了吗?我最初的核心配置代码是这样的:

import { getDefaultConfig, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { mainnet, polygon, optimism, arbitrum } from 'wagmi/chains';

const config = getDefaultConfig({
  appName: 'My DeFi App',
  projectId: 'YOUR_PROJECT_ID', // 从WalletConnect Cloud拿的
  chains: [mainnet, polygon, optimism, arbitrum],
});

function App() {
  return (
    <WagmiProvider config={config}>
      <RainbowKitProvider>
        <ConnectButton />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

跑起来一看,连接MetaMask确实没问题,主网也能用。但当我尝试切换到Polygon时,问题来了。点击切换,钱包弹窗倒是出来了,但要么是提示“未添加网络”,要么是切换后前端的链ID显示还是1(以太坊主网)。控制台里时不时飘过一些关于RPC URL的警告。

我意识到,问题出在链的配置上。getDefaultConfig和从wagmi/chains导入的链定义,其RPC端点可能是公共的,有速率限制或不稳定。而且,对于用户钱包里没有的链,RainbowKit的默认行为可能和我想的不一样。我需要更精细地控制每条链的配置,特别是RPC,并且要处理好钱包添加网络的流程。这不是一个“五分钟集成”就能完事的问题,需要深入配置。

核心实现

第一步:自定义链配置,搞定稳定的RPC

公共RPC是第一个坑。尤其是在测试网或者Polygon这类链上,公共RPC经常不稳定,导致交易发送失败或者读取数据超时。我的解决方案是使用项目自己的Infura或Alchemy节点,如果没有,也可以选择一些更可靠的公共服务商如publicnode.com

这里有个关键点:RainbowKit(或者说底层的wagmi v2)的链配置对象,需要包含rpcUrls字段,并且要正确区分defaultpublic。我一开始没注意,直接覆盖错了,导致钱包连接内部调用还是走了不稳定的节点。

// chains/customChains.ts
import { Chain } from 'wagmi/chains';

// 自定义Polygon链配置
export const customPolygon: Chain = {
  id: 137,
  name: 'Polygon',
  network: 'matic',
  nativeCurrency: {
    name: 'MATIC',
    symbol: 'MATIC',
    decimals: 18,
  },
  rpcUrls: {
    // default 和 public 最好都配置,default用于钱包写操作,public用于前端读操作
    default: {
      http: ['https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY'], // 你的Alchemy或Infura URL
    },
    public: {
      http: ['https://polygon-rpc.com'], // 一个可靠的公共RPC
    },
  },
  blockExplorers: {
    default: { name: 'PolygonScan', url: 'https://polygonscan.com' },
  },
  contracts: {
    multicall3: {
      address: '0xca11bde05977b3631167028862be2a173976ca11',
      blockCreated: 25770160,
    },
  },
};

// 同理,配置其他链,比如Arbitrum
export const customArbitrum: Chain = {
  id: 42161,
  name: 'Arbitrum One',
  network: 'arbitrum',
  nativeCurrency: {
    name: 'Ether',
    symbol: 'ETH',
    decimals: 18,
  },
  rpcUrls: {
    default: {
      http: ['https://arb1.arbitrum.io/rpc'],
    },
    public: {
      http: ['https://arb1.arbitrum.io/rpc'],
    },
  },
  blockExplorers: {
    default: { name: 'Arbiscan', url: 'https://arbiscan.io' },
  },
  contracts: {
    multicall3: {
      address: '0xca11bde05977b3631167028862be2a173976ca11',
      blockCreated: 7654707,
    },
  },
};

第二步:配置RainbowKit与Wagmi

有了自定义的链配置,接下来就是正确创建wagmiconfig对象。这里我放弃了getDefaultConfig这个快捷方法,因为它对配置的控制不够细。我改用createConfig手动配置,这样可以明确指定传输层(transport)和连接器。

注意这个细节wagmicreateConfig需要为每条链单独创建transport。我在这里又踩了个坑,试图用一个transport给所有链用,结果只有主网能正常工作。

// config/wagmiConfig.ts
import { http, createConfig } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import { customPolygon, customArbitrum, customOptimism } from '../chains/customChains';
import { getDefaultWallets } from '@rainbow-me/rainbowkit';

// 定义项目支持的链数组
const projectChains = [mainnet, customPolygon, customArbitrum, customOptimism] as const;

// 1. 设置钱包连接器 (RainbowKit提供)
const { connectors } = getDefaultWallets({
  appName: 'My DeFi Aggregator',
  projectId: 'YOUR_WALLETCONNECT_PROJECT_ID', // 必须去WalletConnect Cloud创建项目获取
  chains: projectChains,
});

// 2. 创建Wagmi配置
export const config = createConfig({
  chains: projectChains,
  transports: {
    // 为每条链分别创建transport,使用我们自定义的RPC
    [mainnet.id]: http(mainnet.rpcUrls.default.http[0]), // 也可以用你的主网节点
    [customPolygon.id]: http(customPolygon.rpcUrls.default.http[0]),
    [customArbitrum.id]: http(customArbitrum.rpcUrls.default.http[0]),
    [customOptimism.id]: http(customOptimism.rpcUrls.default.http[0]),
  },
  connectors, // 注入RainbowKit生成的连接器
  ssr: false, // 如果不是Next.js等SSR框架,可以设为false
});

第三步:集成到React应用中并实现链切换

配置完成后,在应用根组件中注入Provider就相对简单了。但为了让用户能方便地切换链,我不仅使用了ConnectButton(它自带切换网络的下拉菜单),还在应用内部关键位置(比如资产面板顶部)添加了一个手动的链切换器,使用useSwitchChain这个hook。

这里有个用户体验上的坑:如果用户的钱包里没有添加你指定的链,直接调用switchChain会失败。RainbowKit的ConnectButton下拉菜单会自动处理这个情况(触发钱包添加网络),但自己写的切换器需要手动处理。我的做法是捕获错误,然后调用addChain

// components/ChainSwitcher.tsx
import { useChainId, useSwitchChain, useChains } from 'wagmi';
import { useCallback } from 'react';

export function ChainSwitcher() {
  const currentChainId = useChainId();
  const { switchChain } = useSwitchChain();
  const supportedChains = useChains();

  const handleSwitch = useCallback(async (targetChainId: number) => {
    if (targetChainId === currentChainId) return;
    
    try {
      await switchChain({ chainId: targetChainId });
    } catch (error: any) {
      // 错误码 4902 是钱包(如MetaMask)提示用户添加网络的标准错误
      if (error?.code === 4902) {
        // 在实际项目中,这里应该弹出一个更友好的提示,引导用户去ConnectButton那里切换,或者手动触发addChain。
        // 因为addChain API需要完整的链信息,直接从supportedChains里找。
        const targetChain = supportedChains.find(c => c.id === targetChainId);
        if (targetChain) {
          console.warn(`请手动在钱包中添加 ${targetChain.name} 网络,或使用右上角的连接按钮进行切换。`);
          // 可以在这里调用 window.ethereum.request({ method: 'wallet_addEthereumChain', params: [targetChainInfo] })
        }
      }
      console.error('切换链失败:', error);
    }
  }, [currentChainId, switchChain, supportedChains]);

  return (
    <div className="chain-switcher">
      <span>当前网络: </span>
      <select 
        value={currentChainId} 
        onChange={(e) => handleSwitch(Number(e.target.value))}
      >
        {supportedChains.map((chain) => (
          <option key={chain.id} value={chain.id}>
            {chain.name}
          </option>
        ))}
      </select>
    </div>
  );
}

完整代码示例

下面是一个简化但可运行的应用根组件示例,整合了上述所有配置:

// App.tsx
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme, ConnectButton } from '@rainbow-me/rainbowkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from './config/wagmiConfig';
import { ChainSwitcher } from './components/ChainSwitcher';
import '@rainbow-me/rainbowkit/styles.css'; // 不要忘记引入样式!

// 为Wagmi的缓存创建QueryClient
const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider 
          theme={darkTheme()} // 可以自定义主题
          coolMode // 开启酷炫的按钮效果
          locale="en-US" // 设置语言
        >
          <div className="app">
            <header>
              <h1>我的DeFi聚合器</h1>
              <div className="wallet-section">
                <ConnectButton 
                  accountStatus="full" // 显示完整地址
                  chainStatus="icon" // 只显示链图标不显示名称
                  showBalance={false}
                />
              </div>
            </header>
            <main>
              <div className="network-panel">
                <ChainSwitcher />
              </div>
              {/* 你的其他业务组件 */}
              <div>业务内容区域...</div>
            </main>
          </div>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;

踩坑记录

  1. projectId无效或缺失导致的静默失败:最开始我没仔细看文档,随便写了个字符串当projectId。结果钱包连接(尤其是WalletConnect)时,移动端扫码后一直连接不上,前端也没明显报错。解决方法:必须去WalletConnect Cloud创建项目,获取真实的projectId

  2. 链切换后,前端状态不同步:点击切换链,钱包成功了,但应用里useChainId()返回的还是旧的链ID。排查发现:这是因为我在不同的地方用了不同的wagmi配置实例,或者Provider包裹层级有问题。解决方法:确保整个应用只用一个config,且WagmiProvider包裹了所有用到wagmi hook的组件。

  3. 自定义链的图标不显示:RainbowKit为一些主流链内置了图标,但自定义链或一些较新的链(比如Base)可能没有。解决方法:可以通过RainbowKitProviderchainImages属性来注入自定义链图标,是一个{ [chainId: number]: string }的映射,值为图片URL。

  4. SSR(Next.js)下的水合错误:在Next.js项目里,因为服务端和客户端初始状态可能不一致(比如连接的钱包信息),会导致水合错误。解决方法:RainbowKit提供了SSRProvider组件来配合Next.js的App Router使用。同时,将wagmi配置中的ssr设为true,并确保连接状态相关的UI在客户端渲染后再显示(用useEffectuseState控制)。

小结

这次集成让我体会到,RainbowKit确实能极大加速Web3应用钱包连接部分的开发,但它不是“无脑”配置就能应对所有生产环境需求的。核心收获是:多链支持的关键在于稳定且可控制的RPC配置,以及对“用户钱包可能未添加链”这一情况的妥善处理。 下一步,可以继续深挖RainbowKit的主题定制、与Zustand/Redux的状态集成,以及如何优雅地处理连接断开和重连的逻辑。

在Next.js NFT市场中,我如何解决动态路由、链上数据获取与状态同步的连环坑

作者 竹林818
2026年4月7日 10:01

背景

上个月,我接手了一个NFT交易市场的前端重构项目。原版是用Create React App搭的,状态管理混乱,页面加载慢,尤其是NFT详情页,每次都要先白屏再慢慢加载链上数据,用户体验很差。团队决定用Next.js 14(App Router)重写,目标是利用服务端渲染(SSR)和静态生成(SSG)提升首屏速度,并构建更清晰的数据流。

我负责的核心模块是NFT详情页(/nft/[contractAddress]/[tokenId])和列表页。一开始我以为就是把React组件搬过来,用wagmiviem替换旧的ethers.js调用。但真正动手才发现,在Next.js的App Router架构下,如何优雅地结合SSR的确定性数据和客户端链上实时数据,如何管理因钱包切换、网络变更触发的全局状态更新,成了一个个需要具体解决的坑。

问题分析

我的初始思路很直接:

  1. 在NFT详情页的page.tsx里,用wagmiuseReadContract钩子获取NFT元数据(如名称、图片、所有者)。
  2. 用动态路由参数contractAddresstokenId作为查询依赖。
  3. 在服务端组件中获取一些静态信息。

但马上遇到了问题:页面在首次加载(或刷新)时,useReadContract返回的data初始值为undefined,虽然请求很快发出,但UI会有一个从“无数据”到“有数据”的闪烁。这对于一个追求体验的市场来说很扎眼。更麻烦的是,当用户在详情页发起一个购买交易后,我需要实时监听交易状态和NFT所有权的变化。最初我简单地在交易发起后设置了一个setInterval轮询,这导致了组件状态混乱和内存泄漏警告。

我意识到,问题可以拆解为三个关键点:

  1. 数据获取策略:哪些数据应该在服务端预取(SSR/SSG)?哪些必须在客户端实时获取?如何让两者无缝衔接?
  2. 实时状态监听:如何有效且安全地监听链上事件(如Transfer事件)和交易状态,而不是用低效的轮询?
  3. 状态同步:一个NFT的状态变化(如价格更新、所有权转移)如何及时反映在列表页和详情页,而不需要用户手动刷新?

核心实现

1. 混合数据获取:SSR静态骨架 + 客户端Hydration

我决定采用混合策略。对于NFT的“核心元数据”(如tokenURI解析后的name, image, description),这些数据一旦铸造就基本不变,非常适合在服务端获取并作为页面骨架。而对于实时变动的数据(如ownerprice),则在客户端获取。

这里有个坑:直接在服务端组件中使用viempublicClient.readContract需要配置RPC URL,并且要处理好错误(比如不支持的链)。我创建了一个服务端的工具函数:

// app/lib/server/nft-data.ts
import { createPublicClient, http, isAddress } from 'viem'
import { mainnet } from 'viem/chains'

export async function getBaseNFTMetadata(contractAddress: string, tokenId: string) {
  // 基础验证
  if (!isAddress(contractAddress)) {
    throw new Error('Invalid contract address')
  }

  const client = createPublicClient({
    chain: mainnet, // 根据你的主要链配置
    transport: http(process.env.NEXT_PUBLIC_RPC_URL_MAINNET)
  })

  try {
    // 1. 读取 tokenURI
    const tokenURI = await client.readContract({
      address: contractAddress as `0x${string}`,
      abi: [{
        inputs: [{ name: 'tokenId', type: 'uint256' }],
        name: 'tokenURI',
        outputs: [{ name: '', type: 'string' }],
        stateMutability: 'view',
        type: 'function'
      }],
      functionName: 'tokenURI',
      args: [BigInt(tokenId)]
    })

    // 2. 这里简化处理,实际项目需要处理IPFS、HTTP等不同协议
    // 假设tokenURI是一个直接可访问的HTTP URL
    const metadataResponse = await fetch(tokenURI)
    if (!metadataResponse.ok) {
      throw new Error(`Failed to fetch metadata: ${metadataResponse.status}`)
    }
    const metadata = await metadataResponse.json()

    return {
      name: metadata.name || `NFT #${tokenId}`,
      image: metadata.image,
      description: metadata.description,
      // 注意:这里不返回owner,因为它是实时变化的
    }
  } catch (error) {
    console.error('Failed to fetch base NFT metadata:', error)
    // 返回一个安全的默认值,避免页面崩溃
    return {
      name: `NFT #${tokenId}`,
      image: '/placeholder-nft.png',
      description: 'Metadata not available',
    }
  }
}

然后在详情页的page.tsx中:

// app/nft/[contractAddress]/[tokenId]/page.tsx
import { getBaseNFTMetadata } from '@/app/lib/server/nft-data'

interface PageProps {
  params: Promise<{ contractAddress: string; tokenId: string }>
}

export default async function NFTDetailPage({ params }: PageProps) {
  const { contractAddress, tokenId } = await params
  // 服务端获取静态元数据
  const baseMetadata = await getBaseNFTMetadata(contractAddress, tokenId)

  return (
    <div>
      <h1>{baseMetadata.name}</h1>
      <img src={baseMetadata.image} alt={baseMetadata.name} />
      <p>{baseMetadata.description}</p>
      {/* 客户端组件负责实时数据 */}
      <NFTLiveData contractAddress={contractAddress} tokenId={tokenId} />
    </div>
  )
}

2. 使用wagmi + viem监听实时事件与交易状态

对于实时数据,我创建了一个客户端组件NFTLiveData。为了避免轮询,我决定利用viemwatchContractEventwagmiuseWatchContractEvent钩子来监听Transfer事件。同时,使用useWaitForTransactionReceipt来优雅地监听交易状态。

注意这个细节:监听事件需要组件挂载,并且要在组件卸载时清理。wagmi的钩子内部帮我们处理了,但直接使用viem的客户端时需要注意。

// app/components/nft-live-data.tsx
'use client'

import { useReadContract, useWatchContractEvent, useAccount, useWaitForTransactionReceipt } from 'wagmi'
import { erc721Abi } from 'viem'
import { useState, useEffect } from 'react'

interface NFTLiveDataProps {
  contractAddress: `0x${string}`
  tokenId: string
}

export function NFTLiveData({ contractAddress, tokenId }: NFTLiveDataProps) {
  const { address } = useAccount()
  const [currentOwner, setCurrentOwner] = useState<string>()
  const [lastTxHash, setLastTxHash] = useState<`0x${string}`>()

  // 1. 读取当前所有者
  const { data: ownerData, refetch: refetchOwner } = useReadContract({
    address: contractAddress,
    abi: erc721Abi,
    functionName: 'ownerOf',
    args: [BigInt(tokenId)],
    // 只有当合约地址和tokenId有效时才查询
    query: {
      enabled: !!contractAddress && !!tokenId,
    },
  })

  // 2. 监听该NFT的Transfer事件
  useWatchContractEvent({
    address: contractAddress,
    abi: erc721Abi,
    eventName: 'Transfer',
    // 监听特定tokenId的转移
    args: [null, null, BigInt(tokenId)],
    onLogs: (logs) => {
      console.log('Transfer event detected!', logs)
      // 事件触发后,重新获取所有者信息
      refetchOwner()
      // 可以在这里触发一个Toast通知
    },
  })

  // 3. 如果有正在进行的交易,监听其状态
  const { data: receipt, isSuccess: isTxConfirmed } = useWaitForTransactionReceipt({
    hash: lastTxHash,
    // 确认数等配置
    confirmations: 2,
  })

  useEffect(() => {
    if (ownerData) {
      setCurrentOwner(ownerData)
    }
  }, [ownerData])

  useEffect(() => {
    if (isTxConfirmed && receipt) {
      // 交易确认,可以更新UI状态,比如显示“购买成功”
      console.log('Transaction confirmed!', receipt)
      // 事件监听器会捕获到Transfer事件并触发refetchOwner,所以这里不一定需要再调用
    }
  }, [isTxConfirmed, receipt])

  const handlePurchase = async () => {
    // ... 购买逻辑,成功后会设置 setLastTxHash(txHash)
  }

  return (
    <div>
      <p>当前所有者: {currentOwner ? `${currentOwner.slice(0,6)}...${currentOwner.slice(-4)}` : '加载中...'}</p>
      <p>你是所有者吗? {address && currentOwner?.toLowerCase() === address.toLowerCase() ? '是' : '否'}</p>
      <button onClick={handlePurchase} disabled={!address}>
        购买
      </button>
      {lastTxHash && !isTxConfirmed && <p>交易确认中...</p>}
    </div>
  )
}

3. 全局状态同步:使用Zustand管理NFT状态

当用户在详情页购买了一个NFT后,列表页应该能及时反映出该NFT“已售出”的状态。如果只用本地状态或上下文,在复杂路由下会很麻烦。我选择了Zustand,因为它轻量且与React并发特性兼容性好。

我创建了一个store来管理关键NFT的状态:

// app/stores/nft-store.ts
import { create } from 'zustand'

interface NFTState {
  // 记录最近更新的NFT,key为`${contractAddress}-${tokenId}`
  recentlyUpdated: Record<string, { lastUpdated: number; owner?: string }>
  // 标记需要重新获取数据的NFT
  markAsUpdated: (contractAddress: string, tokenId: string, newOwner?: string) => void
  // 检查某个NFT是否需要更新
  needsRefresh: (contractAddress: string, tokenId: string, cacheThreshold?: number) => boolean
}

export const useNFTStore = create<NFTState>((set, get) => ({
  recentlyUpdated: {},
  markAsUpdated: (contractAddress, tokenId, newOwner) => {
    const key = `${contractAddress}-${tokenId}`
    set((state) => ({
      recentlyUpdated: {
        ...state.recentlyUpdated,
        [key]: {
          lastUpdated: Date.now(),
          owner: newOwner,
        },
      },
    }))
    // 可以设置一个定时器,一段时间后清理旧记录,避免内存膨胀
  },
  needsRefresh: (contractAddress, tokenId, cacheThreshold = 60000) => { // 默认60秒缓存
    const key = `${contractAddress}-${tokenId}`
    const record = get().recentlyUpdated[key]
    if (!record) return false
    // 如果记录更新时间在阈值内,则认为UI数据可能已过时
    return Date.now() - record.lastUpdated < cacheThreshold
  },
}))

然后在列表项组件和详情页组件中,都可以订阅这个store。当详情页完成购买后,调用markAsUpdated。列表页的组件通过useNFTStoreneedsRefresh方法判断是否需要重新获取数据,从而触发refetch

// 在列表项组件中
'use client'
import { useNFTStore } from '@/app/stores/nft-store'

function NFTListItem({ contractAddress, tokenId, initialOwner }) {
  const { needsRefresh } = useNFTStore()
  const shouldRefetch = needsRefresh(contractAddress, tokenId)

  const { data: owner, refetch } = useReadContract({
    // ... 配置,
    query: {
      // 当标记为需要刷新时,重新启用查询
      enabled: shouldRefetch,
    },
  })

  // ... 其他渲染逻辑
}

完整代码示例

以下是一个简化但可运行的NFT详情页核心结构:

// app/nft/[contractAddress]/[tokenId]/page.tsx
import { getBaseNFTMetadata } from '@/app/lib/server/nft-data'
import { NFTLiveData } from '@/app/components/nft-live-data'
import { Suspense } from 'react'

interface PageProps {
  params: Promise<{ contractAddress: string; tokenId: string }>
}

export default async function NFTDetailPage({ params }: PageProps) {
  const { contractAddress, tokenId } = await params

  // 服务端获取基础元数据(可能失败,需要容错)
  let baseMetadata
  try {
    baseMetadata = await getBaseNFTMetadata(contractAddress, tokenId)
  } catch (error) {
    baseMetadata = {
      name: `NFT #${tokenId}`,
      image: '/placeholder-nft.png',
      description: 'Could not load metadata.',
    }
  }

  return (
    <div className="container mx-auto p-4">
      <div className="grid md:grid-cols-2 gap-8">
        {/* 左侧:静态图片 */}
        <div>
          <img
            src={baseMetadata.image}
            alt={baseMetadata.name}
            className="w-full rounded-xl shadow-lg"
            onError={(e) => {
              // 图片加载失败时使用占位图
              e.currentTarget.src = '/placeholder-nft.png'
            }}
          />
        </div>

        {/* 右侧:信息与交互 */}
        <div>
          <h1 className="text-3xl font-bold mb-2">{baseMetadata.name}</h1>
          <p className="text-gray-600 mb-6">{baseMetadata.description}</p>

          {/* 客户端实时数据部分,用Suspense包裹避免阻塞流 */}
          <Suspense fallback={<div>加载实时数据...</div>}>
            <NFTLiveData
              contractAddress={contractAddress as `0x${string}`}
              tokenId={tokenId}
              baseName={baseMetadata.name}
            />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

踩坑记录

  1. BigInt序列化错误:在服务端获取tokenIduint256)后,直接将其作为props传递给客户端组件,Next.js在序列化时会报错“BigInt not serializable”。解决:在服务端将其转换为string,在客户端需要时再转回BigInt
  2. useWatchContractEvent重复触发:最初没有在args中指定具体的tokenId,导致监听整个合约的所有Transfer事件,任何NFT的交易都会触发回调,造成不必要的重渲染和API调用。解决:精确指定事件参数过滤器。
  3. Zustand store在服务端组件中导入错误:尝试在服务端组件中导入useNFTStore会导致错误,因为Zustand使用了React上下文。解决:严格区分服务端与客户端代码,store只在客户端组件或钩子中使用。服务端数据通过props传递。
  4. RPC限流与错误处理:在服务端函数getBaseNFTMetadata中直接使用公共RPC,在流量大时容易触发限流。解决:增加了健壮的try-catch,返回友好的默认数据;对于生产环境,应考虑使用付费RPC服务、设置缓存(如redis)或使用像The Graph这样的索引服务来减轻链上直接查询的压力。

小结

这次重构让我深刻体会到,在Next.js中构建响应式Web3前端,关键在于分层处理数据(SSR静态层 + 客户端动态层)和选择正确的同步机制(事件监听优于轮询)。一个简单的refetch背后,需要全局状态管理的配合才能实现流畅的跨页面状态同步。下一步,我计划将链上事件监听抽象为更通用的自定义Hook,并探索React Querywagmi更深入的集成,来管理更复杂的缓存策略。

❌
❌