React + wagmi 构建 DeFi 前端:从连接钱包到读取合约数据的完整实战与避坑指南

6 阅读1分钟

背景

上个月,我接手了一个去中心化借贷协议的前端迭代任务。项目原本使用 ethers.js 直接与 MetaMask 交互,代码里散落着大量的 window.ethereum 判断和事件监听,维护起来非常头疼。我的目标是重构这部分逻辑,让钱包连接、链切换和合约调用变得清晰可控。经过调研,我决定采用 wagmi + viem 这套现代组合,它们提供了声明式的 Hooks 和强大的类型安全。然而,从老方案迁移过来并非一帆风顺,我遇到了连接状态不同步、多链切换时数据读取错误等一系列具体问题。

问题分析

一开始,我的想法很简单:用 wagmi 提供的 useConnect, useAccount, useSwitchChain 等 Hook 替换掉所有的手动 ethers.providers.Web3Provider 实例化。我快速搭建了一个基础配置,连接钱包很顺利。但当我尝试在组件中读取用户在不同链上的余额和抵押头寸时,问题出现了。

  1. 状态同步延迟:用户通过 MetaMask 切换了网络,但我的前端组件里 useAccount 返回的 chainId 并没有立即更新,导致后续的合约读取请求仍然发向了旧的链。
  2. 合约实例管理:我需要根据当前激活的链,动态使用对应的合约地址和 ABI 创建合约实例。最初我尝试在每次渲染时都重新创建,这引发了不必要的重渲染和潜在的内存问题。
  3. 数据读取优化:一个页面需要同时读取用户钱包余额、存款余额和市场价格等多个数据。如果简单地为每个数据调用一个独立的 Hook,会导致对 RPC 节点的请求激增,在公共 RPC 下容易遇到速率限制。

排查过程让我意识到,不能只是机械地替换 API,而需要理解 wagmi 的状态管理流,并利用 viempublicClientwalletClient 概念来设计数据获取逻辑。

核心实现

1. 配置 wagmi 与多链支持

首先,需要正确配置 wagmi。这里的关键是创建 wagmiConfig,并定义项目需要支持的链。我选择了 Ethereum 主网和 Sepolia 测试网。

这里有个坑viem 的链定义需要从 viem/chains 导入,而不是自己写对象,否则会缺少一些内部的 RPC 配置。

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

export const config = createConfig({
  chains: [mainnet, sepolia], // 明确支持的多链
  connectors: [
    injected(), // 支持 MetaMask 等注入式钱包
    walletConnect({ projectId: '你的-WalletConnect-项目ID' }),
  ],
  // 为每条链配置传输层 (Transport)
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http('https://rpc.sepolia.org'), // 可以指定自定义 RPC URL
  },
});

然后,在应用根组件用 WagmiProvider 包裹。

// src/App.tsx
import { WagmiProvider } from 'wagmi';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from './config/wagmi';
import { Dashboard } from './components/Dashboard';

const queryClient = new QueryClient();

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

2. 构建健壮的钱包连接与链切换组件

我创建了一个 WalletConnector 组件,它不仅要连接钱包,还要处理链不匹配的情况。

注意这个细节useAccount 返回的 chainId 是当前钱包连接的链,而 useChainId 返回的是 wagmi 配置中当前“激活”的链(受 useSwitchChain 影响)。在用户手动切换网络时,两者需要同步。

// src/components/WalletConnector.tsx
import { useConnect, useAccount, useSwitchChain, useDisconnect } from 'wagmi';

export function WalletConnector() {
  const { connect, connectors, error: connectError } = useConnect();
  const { address, chainId, isConnected } = useAccount();
  const { switchChain } = useSwitchChain();
  const { disconnect } = useDisconnect();

  // 假设我们的应用只支持 Sepolia 测试网
  const TARGET_CHAIN_ID = 11155111; // Sepolia

  const handleConnect = () => {
    connect({ connector: connectors[0] }); // 连接第一个连接器(如 MetaMask)
  };

  const handleSwitchChain = () => {
    switchChain({ chainId: TARGET_CHAIN_ID });
  };

  if (!isConnected) {
    return (
      <div>
        <button onClick={handleConnect}>连接钱包</button>
        {connectError && <p style={{ color: 'red' }}>{connectError.message}</p>}
      </div>
    );
  }

  return (
    <div>
      <p>地址: {address}</p>
      <p>当前链 ID: {chainId}</p>
      {chainId !== TARGET_CHAIN_ID ? (
        <div>
          <p style={{ color: 'orange' }}>请切换到 Sepolia 测试网</p>
          <button onClick={handleSwitchChain}>切换网络</button>
        </div>
      ) : (
        <p style={{ color: 'green' }}>网络正确</p>
      )}
      <button onClick={() => disconnect()}>断开连接</button>
    </div>
  );
}

3. 动态创建合约客户端并读取数据

这是 DeFi 前端的核心。我使用 usePublicClientuseWalletClient 来获取客户端,然后动态创建合约实例。为了优化请求,我使用 useReadContracts 来批量读取数据。

这里有个坑useWalletClient 返回的数据可能为 undefined(当钱包未连接或锁定时),必须做好防御性判断。

// src/hooks/useLendingContract.ts
import { usePublicClient, useWalletClient } from 'wagmi';
import { getContract } from 'viem';
import lendingPoolAbi from '../abis/LendingPool.json'; // 你的合约 ABI

// 合约地址映射
const CONTRACT_ADDRESSES: Record<number, `0x${string}`> = {
  1: '0xMainnetAddress...',
  11155111: '0xSepoliaAddress...',
};

export function useLendingContract() {
  const publicClient = usePublicClient();
  const { data: walletClient } = useWalletClient();
  const { chainId } = useAccount();

  // 如果链不被支持或未连接,返回 null
  if (!chainId || !CONTRACT_ADDRESSES[chainId]) {
    return { contract: null, isReady: false };
  }

  const contractAddress = CONTRACT_ADDRESSES[chainId];

  // 创建公共客户端合约实例(用于只读操作)
  const publicContract = getContract({
    address: contractAddress,
    abi: lendingPoolAbi,
    client: publicClient,
  });

  // 创建钱包客户端合约实例(用于写操作,需要签名)
  const walletContract = walletClient
    ? getContract({
        address: contractAddress,
        abi: lendingPoolAbi,
        client: walletClient,
      })
    : null;

  return {
    publicContract,
    walletContract,
    isReady: !!publicClient && !!chainId,
    chainId,
  };
}
// src/components/UserPosition.tsx
import { useAccount, useReadContracts } from 'wagmi';
import { useLendingContract } from '../hooks/useLendingContract';

export function UserPosition() {
  const { address } = useAccount();
  const { publicContract, isReady } = useLendingContract();

  // 使用 useReadContracts 批量读取!
  const { data: contractData, isLoading } = useReadContracts({
    contracts: [
      {
        ...publicContract!,
        functionName: 'getUserAccountData',
        args: [address!],
      } as const,
      {
        ...publicContract!,
        functionName: 'getReserveData',
        args: ['0xTokenAddress...'],
      } as const,
    ],
    query: {
      enabled: isReady && !!address, // 仅在条件满足时启用查询
    },
  });

  if (!isReady) return <p>请连接钱包并切换到支持的链</p>;
  if (isLoading) return <p>加载数据中...</p>;
  if (!contractData) return <p>暂无数据</p>;

  const [userData, reserveData] = contractData;
  // 处理并展示数据...
  return (
    <div>
      <p>健康因子: {userData.result?.healthFactor?.toString()}</p>
      <p>流动性率: {reserveData.result?.liquidityRate?.toString()}</p>
    </div>
  );
}

完整代码示例

以下是一个整合了上述关键部分的简化版主组件,可以直接在配置好 wagmi 的 React 项目中运行。

// src/components/Dashboard.tsx
import { useAccount } from 'wagmi';
import { WalletConnector } from './WalletConnector';
import { UserPosition } from './UserPosition';

export function Dashboard() {
  const { isConnected } = useAccount();

  return (
    <div style={{ padding: '20px' }}>
      <h1>DeFi 借贷协议看板</h1>
      <WalletConnector />
      <hr style={{ margin: '20px 0' }} />
      {isConnected ? (
        <>
          <h2>您的仓位信息</h2>
          <UserPosition />
        </>
      ) : (
        <p>请先连接钱包以查看您的仓位。</p>
      )}
    </div>
  );
}

踩坑记录

  1. useAccount 链 ID 更新延迟:现象是用户切换网络后,UI 状态还是旧的链。解决方法:我发现 useAccount 的更新依赖于 wagmi 内部的事件监听。确保 WagmiProvider 的配置中 transports 为每条链正确配置了 RPC,并且钱包注入正常。有时需要检查浏览器控制台是否有来自 viem 的 RPC 错误。
  2. useReadContract 在链切换后读取旧链数据:调用 useReadContract 时,如果 chainId 变化,但 contract 配置对象(包含地址)没有用新的 chainId 重新创建,它仍会向旧地址发起请求。解决方法:将合约地址做成依赖于 chainId 的变量,并确保 contract 配置在 chainId 变化后重新生成。使用我上面封装的 useLendingContract Hook 可以很好地管理这个生命周期。
  3. 批量请求 useReadContracts 的类型错误useReadContractscontracts 数组的类型要求非常严格,需要每个元素都明确 functionNameargs 的类型。解决方法:在定义 contracts 数组时,使用 as const 断言来锁定字面量类型,或者确保从 ABI 生成的类型被正确应用。
  4. 公共 RPC 速率限制:在开发时频繁刷新页面,很快收到了 429 错误。解决方法:使用 wagmibatch 配置项(在 createConfigtransports 中设置 { batch: { batchSize: 1024 } }),它允许将多个 JSON-RPC 请求打包成一个。更根本的解决方案是使用可靠的私有 RPC 节点服务。

小结

这次重构让我深刻体会到,用 wagmi 构建 DeFi 前端,核心在于理解其基于 viem 客户端的架构和 React Query 的异步状态管理。将链感知的合约实例创建封装成自定义 Hook,并善用批量读取,能大幅提升代码健壮性和用户体验。下一步,我可以继续探索 wagmiuseWriteContract 与交易状态跟踪,以及如何集成更复杂的交易模拟(Simulation)功能。