普通视图

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

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

作者 竹林818
2026年4月13日 18:03

背景

上个月,我接手了一个多链DeFi聚合器前端的迭代任务。项目需要从原先只支持以太坊主网,扩展到支持 Arbitrum、Polygon、Base 等七八条 EVM 链。老板给的要求很明确:用户体验要丝滑,钱包连接不能卡顿,链切换要直观,最好能快速上线。

我第一时间想到了 RainbowKit。社区里都说它“开箱即用”,封装了 wagmi 和一堆 UI 组件,能省不少事。但真当我动手把文档里的示例代码往项目里一粘,问题就接踵而至了。钱包是能弹出来了,但链列表不对,切换链后前端状态没更新,甚至有的链上交易会报莫名其妙的 RPC 错误。这篇文章,就是我填平这些坑的完整记录。

问题分析

我最开始的想法很简单:照着 RainbowKit 官方文档,安装 @rainbow-me/rainbowkitwagmiviem,然后配置一个 WagmiProviderRainbowKitProvider 把应用包起来不就完事了?代码大概长这样:

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

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

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

跑起来一看,钱包连接按钮是出来了,点开也能看到 MetaMask、Coinbase Wallet 等选项。但问题立刻出现了:

  1. 链列表不全:我配置了 [mainnet, polygon],但钱包切换网络的弹窗里,有时只显示主网,Polygon 不出现。
  2. 状态不同步:用户在 MetaMask 里手动切换了网络(比如从 Ethereum 切到 Polygon),但我应用里 useAccount() 钩子返回的 chain 信息有时还是旧的,导致后续的合约调用全跑到错误的链上。
  3. 自定义链配置麻烦:像 Base、Arbitrum 这些链,wagmi/chains 里虽然有,但它们的 RPC 节点有时不稳定,我需要换成项目自备的节点,这个配置过程比预想的要绕。

我意识到,“开箱即用”指的是基础功能,一旦涉及到生产环境的多链复杂场景,细节配置一个都不能少。下面我就分步骤拆解我是怎么解决这些问题的。

核心实现

第一步:正确配置多链与 RPC

这里有个大坑:RainbowKit/Wagmi 的链配置,并不仅仅是给组件提供一个列表那么简单。它涉及到钱包连接时向钱包(如 MetaMask)发起“建议”的网络列表,以及 wagmi 客户端内部用来读取链数据、发送交易的 RPC 连接。

我最初只用 wagmi/chains 里导出的链定义,但很快就遇到了公共 RPC 限速或不稳定导致交易失败的问题。解决方案是自定义 viemTransport,并为每条链指定更可靠的 RPC 端点。

// src/config/chains.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { getDefaultConfig } from '@rainbow-me/rainbowkit';

// 1. 定义项目需要的所有链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;

// 2. 为每条链配置 Transport (RPC 连接)
// 注意:生产环境建议将 RPC URL 放在环境变量中
const transports = {
  [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'),
  [base.id]: http('https://mainnet.base.org'), // 也可以使用公共节点
};

// 3. 创建 wagmi 配置
export const config = createConfig({
  chains: supportedChains as any, // 这里有个类型小坑,需要断言
  transports, // 关键!注入自定义的 RPC 传输层
  // ... 其他配置如连接器、SSR 等
});

// 4. 创建 RainbowKit 专用的配置(用于 UI 部分)
export const rainbowKitConfig = getDefaultConfig({
  appName: 'MyDeFiAggregator',
  projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, // 必须
  chains: supportedChains,
  transports, // 这里也要传一次,确保一致性
});

关键点transports 配置是性能和安全的关键。使用像 Alchemy、Infura 这样的专业节点服务,能显著提升交易发送和区块数据读取的可靠性。getDefaultConfig 内部其实也是调用了 createConfig,所以我们直接基于 createConfig 来构建,灵活性更高。

第二步:搞定 RainbowKitProvider 与主题

RainbowKit 的 UI 很棒,但默认主题可能和你的项目不搭。集成时,我建议一开始就处理好主题,避免后期再改一堆样式。

// src/providers/Web3Provider.tsx
import { RainbowKitProvider, darkTheme, lightTheme } from '@rainbow-me/rainbowkit';
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from '@/config/chains';

const queryClient = new QueryClient();

export function Web3Provider({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({
            accentColor: '#3B82F6', // 自定义主色
            accentColorForeground: 'white',
            borderRadius: 'medium',
            fontStack: 'system',
            overlayBlur: 'small',
          })}
          // 这个 locale 设置对中文用户很友好
          locale="en-US"
          // 可以在这里配置初始链影响连接时的默认网络
          initialChain={polygon}
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

注意这个细节WagmiProvider 需要 @tanstack/react-queryQueryClientProvider 作为上下文来管理请求状态。getDefaultConfig 帮我们隐式创建了 queryClient,但自己显式创建并传入能获得更多控制权,比如设置全局的请求重试、缓存时间等。

第三步:实现链感知的连接与切换

这是用户体验的核心。用户连接钱包后,我们需要清晰地展示当前连接的链,并提供一个便捷的切换方式。RainbowKit 提供了 ConnectButtonChain 组件,但直接使用可能不够。

我遇到的一个典型场景是:用户当前连接在 Polygon 上,但我们的某个功能只支持 Arbitrum。我们需要引导用户切换网络。

// src/components/ChainAwareConnectButton.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useSwitchChain } from 'wagmi';
import { supportedChains } from '@/config/chains';
import { useEffect } from 'react';

export function ChainAwareConnectButton({ requiredChainId }: { requiredChainId?: number }) {
  const { chain, isConnected } = useAccount();
  const { switchChain } = useSwitchChain();

  // 效果:当组件要求特定链,且用户已连接但链不对时,自动提示切换
  useEffect(() => {
    if (isConnected && requiredChainId && chain?.id !== requiredChainId) {
      // 这里可以触发一个自定义的模态框提示,而不是自动切换。
      // 自动切换体验很生硬,可能会被钱包拦截。
      console.warn(`请将网络切换至 ${supportedChains.find(c => c.id === requiredChainId)?.name}`);
    }
  }, [isConnected, chain, requiredChainId]);

  return (
    <ConnectButton.Custom>
      {({
        account,
        chain: connectedChain,
        openAccountModal,
        openChainModal,
        openConnectModal,
        authenticationStatus,
        mounted,
      }) => {
        const ready = mounted && authenticationStatus !== 'loading';
        const connected = ready && account && connectedChain;

        // 自定义按钮渲染逻辑
        return (
          <div
            {...(!ready && {
              'aria-hidden': true,
              'style': {
                opacity: 0,
                pointerEvents: 'none',
                userSelect: 'none',
              },
            })}
          >
            {(() => {
              if (!connected) {
                return (
                  <button onClick={openConnectModal} type="button">
                    连接钱包
                  </button>
                );
              }

              // 如果已连接,但链不符合要求,高亮显示链切换按钮
              const isOnWrongChain = requiredChainId && connectedChain.id !== requiredChainId;

              return (
                <div style={{ display: 'flex', gap: 12 }}>
                  {/* 链切换按钮 */}
                  <button
                    onClick={openChainModal}
                    type="button"
                    style={{
                      display: 'flex',
                      alignItems: 'center',
                      background: isOnWrongChain ? '#FEF3C7' : 'transparent', // 链不对时黄色背景提示
                      border: `1px solid ${isOnWrongChain ? '#F59E0B' : '#ccc'}`,
                      borderRadius: '8px',
                      padding: '4px 8px',
                    }}
                  >
                    {connectedChain.hasIcon && (
                      <div
                        style={{
                          background: connectedChain.iconBackground,
                          width: 20,
                          height: 20,
                          borderRadius: 999,
                          overflow: 'hidden',
                          marginRight: 4,
                        }}
                      >
                        {connectedChain.iconUrl && (
                          <img
                            alt={connectedChain.name ?? 'Chain icon'}
                            src={connectedChain.iconUrl}
                            style={{ width: 20, height: 20 }}
                          />
                        )}
                      </div>
                    )}
                    {connectedChain.name}
                  </button>

                  {/* 账户按钮 */}
                  <button onClick={openAccountModal} type="button">
                    {account.displayName}
                    {account.displayBalance ? ` (${account.displayBalance})` : ''}
                  </button>
                </div>
              );
            })()}
          </div>
        );
      }}
    </ConnectButton.Custom>
  );
}

这里有个坑useSwitchChain().switchChain 方法虽然存在,但在浏览器环境中,直接调用它来“强制”用户切换链,体验很差,而且 MetaMask 等钱包可能会阻止这种非用户触发的切换请求。最佳实践是只提供清晰的切换引导(比如高亮链按钮、文字提示),让用户自己点击 openChainModal 去操作。ConnectButton.Custom 给了我们极大的灵活性来实现这种定制 UI 和交互逻辑。

第四步:在应用各处安全地使用链状态

解决了连接和切换,最后一步是确保在需要链信息的任何地方(比如调用合约、查询余额),我们使用的 chainId 都是正确且最新的。

// src/hooks/useSafeChain.ts
import { useAccount, useChainId } from 'wagmi';
import { supportedChains } from '@/config/chains';

// 这个钩子确保返回的 chainId 一定是项目支持的,否则返回 undefined 或默认链
export function useSafeChain(requiredChainId?: number) {
  const { chain } = useAccount();
  const globalChainId = useChainId(); // wagmi v2 的新钩子,直接获取当前链ID

  // 优先级:参数指定 > 当前连接链 > undefined
  let targetChainId = requiredChainId || chain?.id || globalChainId;

  // 检查目标链是否在支持列表中
  const isSupported = supportedChains.some(c => c.id === targetChainId);

  if (!isSupported && targetChainId) {
    console.error(`链 ID ${targetChainId} 不在项目支持列表中。`);
    // 根据业务逻辑,可以在这里触发链切换,或者返回一个默认链(如主网)
    // return mainnet.id;
    return undefined;
  }

  return targetChainId;
}

// 在合约调用处使用
import { useReadContract } from 'wagmi';
import { useSafeChain } from '@/hooks/useSafeChain';
import { myContractAbi } from './abi';

export function MyComponent() {
  const safeChainId = useSafeChain(); // 获取当前安全的链ID

  const { data } = useReadContract({
    abi: myContractAbi,
    address: '0x...', // 注意:不同链上合约地址可能不同,这里需要根据 chainId 做映射
    functionName: 'balanceOf',
    args: ['0xUserAddress'],
    chainId: safeChainId, // 关键!将安全的 chainId 传入查询
    query: {
      enabled: !!safeChainId, // 只有链ID有效时才发起查询
    },
  });

  // ... 渲染逻辑
}

关键点:所有依赖于链的钩子(useReadContract, useWriteContract, useBalance 等),都应该显式地传入 chainId 参数。不要依赖 wagmi 的全局上下文自动推断,因为在复杂的多链交互中,尤其是在用户快速切换网络时,自动推断可能会滞后或出错。useSafeChain 这个自定义钩子相当于一个保险丝,确保后续操作基于一个经过验证的链环境。

完整代码

以下是一个简化但可运行的核心集成示例,基于 Next.js (App Router) 和 TypeScript。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider } from 'wagmi';
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit';
import { config } from '@/lib/wagmi-config';

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider
          theme={darkTheme({ accentColor: '#0E76FD' })}
          locale="en-US"
        >
          {children}
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
// lib/wagmi-config.ts
import { http, createConfig } from 'wagmi';
import { mainnet, polygon, arbitrum, base } from 'wagmi/chains';
import { injected, walletConnect } from 'wagmi/connectors';

// 1. 定义支持的链
export const supportedChains = [mainnet, polygon, arbitrum, base] as const;
export type SupportedChainId = (typeof supportedChains)[number]['id'];

// 2. 配置 RPC Transports
const transports: Record<SupportedChainId, ReturnType<typeof http>> = {
  [mainnet.id]: http(process.env.NEXT_PUBLIC_ETHEREUM_RPC_URL),
  [polygon.id]: http(process.env.NEXT_PUBLIC_POLYGON_RPC_URL),
  [arbitrum.id]: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC_URL),
  [base.id]: http('https://mainnet.base.org'),
};

// 3. 创建 wagmi 配置对象
export const config = createConfig({
  chains: supportedChains as any,
  transports,
  connectors: [
    injected(), // 支持 MetaMask 等注入式钱包
    walletConnect({
      projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
    }),
  ],
  ssr: true, // 如果你用 Next.js 且需要 SSR,开启这个
});
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'My Web3 App',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
// app/page.tsx
'use client';

import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useAccount, useBalance } from 'wagmi';

export default function Home() {
  const { address, chain } = useAccount();
  const { data: balance } = useBalance({ address });

  return (
    <main style={{ padding: '2rem' }}>
      <h1>我的多链 DeFi 聚合器</h1>
      <div style={{ margin: '2rem 0' }}>
        <ConnectButton />
      </div>

      {address && (
        <div style={{ marginTop: '1rem', padding: '1rem', border: '1px solid #333' }}>
          <p>连接地址: {address}</p>
          <p>当前网络: {chain?.name} (ID: {chain?.id})</p>
          <p>余额: {balance?.formatted} {balance?.symbol}</p>
        </div>
      )}
    </main>
  );
}

环境变量 (.env.local):

NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=你的_WalletConnect_Cloud_项目ID
NEXT_PUBLIC_ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_POLYGON_RPC_URL=https://polygon-mainnet.g.alchemy.com/v2/your_key
NEXT_PUBLIC_ARBITRUM_RPC_URL=https://arb-mainnet.g.alchemy.com/v2/your_key

踩坑记录

  1. Unsupported chain 错误:用户连接了钱包,但我的应用配置里没有他钱包当前所在的链(比如 BSC)。RainbowKit 的默认行为是“不支持”,用户会看到一个错误状态。解决方法:在 RainbowKitProvider 上设置 initialChain 为一个你支持的链(如主网),这样新用户连接时会先被引导到该链。对于已连接但链不对的用户,通过自定义 ConnectButton UI 强烈提示他们切换。

  2. WalletConnect 项目 ID 缺失:控制台报错 Invalid projectId解决方法:必须去 WalletConnect Cloud 创建一个项目,获取 projectId。这个 ID 是 WalletConnect 协议连接所必需的,不是可选项。

  3. Hydration 不匹配错误 (Next.js):在服务端渲染 (SSR) 时,服务端没有钱包连接状态,而客户端水合时状态可能不同,导致 React 报错。解决方法:确保 WagmiProviderRainbowKitProvider 只在客户端渲染。在 Next.js App Router 中,将包含这些 Provider 的文件标记为 'use client'。同时,在 createConfig 中设置 ssr: true,让 wagmi 适配 SSR 环境。

  4. 类型错误:chains 类型不匹配:在 createConfig 中直接传入 supportedChains 可能会遇到 TypeScript 类型过于宽泛的问题。解决方法:使用 as const 断言定义链数组,并在 createConfig 处使用 as any 进行临时断言,或者按照 wagmi 类型更精确地定义 Chain 数组。

小结

通过这一轮折腾,我最大的收获是:RainbowKit 确实是加速 Web3 前端开发的利器,但它不是“魔法”。生产级的多链集成,关键在于理解其底层依赖(wagmi, viem)的配置,特别是 TransportchainId 的精确管理。下一步,我可以继续深入优化自定义连接器、钱包连接后的权限验证(SIWE),以及更复杂的跨链状态管理。

昨天以前首页

从零到一:在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 做更复杂的数据缓存和失效策略,让应用体验更上一层楼。

❌
❌