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

3 阅读1分钟

背景

最近在参与一个借贷类 DeFi 项目的界面重构,技术栈是 Next.js + TypeScript。项目需要与以太坊主网和几个 Layer 2 网络上的智能合约进行交互,核心功能包括连接/断开钱包、显示用户地址和余额、读取资金池数据以及提交存款/借款交易。

为了提升开发效率和代码质量,我们决定从之前直接使用 ethers.js 的方式,迁移到以 wagmiviem 为核心的现代 Web3 开发栈。wagmi 提供了完善的 React Hooks 来管理钱包连接状态、账户信息和链信息,而 viem 则作为底层与区块链交互的库。想法很美好,但在实际集成过程中,我遇到了几个教科书上没写的“坑”,尤其是在状态同步和类型安全方面。

问题分析

一开始,我按照官方示例快速搭建了基础框架:配置 WagmiProvider,使用 useConnectuseAccount 来连接钱包和获取账户信息。界面很快就能弹出钱包选择框并成功连接。

但第一个问题立刻出现了:连接状态不同步。当我在 MetaMask 中切换账户或者切换网络时,我的应用界面并没有实时更新。用户看到的还是上一个账户的地址和余额,这显然是不可接受的。我最初的思路是监听 window.ethereumaccountsChangedchainChanged 事件,然后手动去触发 wagmi 的状态更新。但这样做不仅代码冗余,还容易与 wagmi 内部的状态管理产生冲突。

经过排查 wagmi 的文档和源码,我意识到问题出在配置上。wagmi 默认的 InjectedConnector 已经内置了这些事件的监听和状态同步,但需要确保 WagmiProvider 的配置中正确指定了连接器,并且项目使用的 wagmi 和 viem 版本是兼容的。此外,对于多链支持,仅仅配置连接器还不够,还需要在 wagmi 的 config 中明确定义项目支持的所有链,并配置好对应的 RPC 端点。

核心实现

1. 项目初始化与依赖安装

首先,创建一个新的 Next.js 项目(这里以 App Router 为例),并安装必要的依赖。

npx create-next-app@latest defi-frontend --typescript --tailwind --app
cd defi-frontend
npm install wagmi viem @tanstack/react-query

注意这个细节@tanstack/react-query 是 wagmi 的内部依赖,用于管理请求状态和缓存,虽然 wagmi 会默认安装它,但显式安装可以确保版本兼容性,避免后续奇怪的问题。

2. 配置 wagmi 与多链支持

这是整个项目的基石。在 app/providers.tsx(或类似位置)创建 Provider 组件。核心是创建一个 wagmiConfig 对象,在其中定义连接器和支持的链。

// app/providers.tsx
'use client'; // Next.js App Router 中,使用状态的组件必须是 Client Component

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, sepolia, polygon, arbitrum } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
import { useState } from 'react';

// 1. 定义项目支持的链
const supportedChains = [mainnet, sepolia, polygon, arbitrum];

// 2. 创建 wagmi 配置
const createWagmiConfig = () => {
  return createConfig({
    chains: supportedChains,
    connectors: [
      // 注入式连接器(如 MetaMask)
      injected({
        // 这里有个坑:如果不指定 `shimDisconnect`,断开连接后可能无法自动重连
        shimDisconnect: true,
      }),
      // 未来可以在这里添加 WalletConnect 等其它连接器
    ],
    // 为每条链配置传输层(RPC)
    transports: {
      [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
      [sepolia.id]: http('https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY'),
      [polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
      [arbitrum.id]: http('https://arb-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
    },
  });
};

export function Providers({ children }: { children: React.ReactNode }) {
  // 使用 useState 确保配置在客户端只创建一次
  const [config] = useState(() => createWagmiConfig());
  const [queryClient] = useState(() => new QueryClient());

  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

关键点

  • transports 配置非常重要,它告诉 viem 如何与每条链通信。务必使用可靠的 RPC 提供商(如 Alchemy, Infura)并替换 YOUR_API_KEY
  • 将配置的创建包裹在 useState 中,避免在 React 的严格模式下或热重载时重复创建。

然后在 app/layout.tsx 中用 Providers 包裹你的应用。

3. 实现钱包连接与状态显示组件

接下来,创建一个 WalletConnector.tsx 组件。这个组件负责显示连接按钮、当前账户地址、链信息以及断开连接。

// components/WalletConnector.tsx
'use client';

import { useConnect, useAccount, useDisconnect, useChainId } from 'wagmi';
import { formatAddress } from '@/utils/format'; // 一个简单的格式化函数

export function WalletConnector() {
  // 1. 使用 wagmi Hooks
  const { connect, connectors, isPending } = useConnect();
  const { address, isConnected, chain } = useAccount();
  const { disconnect } = useDisconnect();
  const chainId = useChainId();

  // 2. 处理连接(这里以第一个连接器,即 Injected 为例)
  const handleConnect = () => {
    if (connectors[0]) {
      connect({ connector: connectors[0] });
    }
  };

  if (!isConnected) {
    return (
      <button
        onClick={handleConnect}
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? 'Connecting...' : 'Connect Wallet'}
      </button>
    );
  }

  // 3. 已连接状态下的显示
  return (
    <div className="flex items-center gap-4 border p-3 rounded-lg">
      <div className="text-sm">
        <p>
          <span className="font-semibold">Address:</span> {formatAddress(address)}
        </p>
        <p>
          <span className="font-semibold">Chain:</span> {chain?.name} (ID: {chainId})
        </p>
      </div>
      <button
        onClick={() => disconnect()}
        className="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600"
      >
        Disconnect
      </button>
    </div>
  );
}

这个组件利用了 useAccount 的返回值,它包含了 addressisConnected 和当前 chain 信息。当用户在 MetaMask 中切换账户或网络时,useAccount 会自动触发重新渲染,从而解决了最初的状态同步问题。

4. 读取 DeFi 合约数据(以总存款量为例)

DeFi 项目的核心是与合约交互。假设我们有一个简单的借贷池合约,有一个 totalDeposits 公共变量。我们将使用 useReadContract 这个 Hook 来读取它。

首先,定义合约的 ABI 和地址。为了支持多链,地址应该是一个映射。

// constants/contracts.ts
export const LENDING_POOL_ABI = [
  {
    type: 'function',
    name: 'totalDeposits',
    inputs: [],
    outputs: [{ type: 'uint256', name: '' }],
    stateMutability: 'view',
  },
  // ... 其他函数 ABI
] as const; // 使用 `as const` 获得最精确的类型推断

export const LENDING_POOL_ADDRESS: Record<number, `0x${string}`> = {
  1: '0xMainnetAddress...', // 以太坊主网
  137: '0xPolygonAddress...', // Polygon
  42161: '0xArbitrumAddress...', // Arbitrum
};

然后,在组件中使用:

// components/TotalDeposits.tsx
'use client';

import { useReadContract, useChainId } from 'wagmi';
import { LENDING_POOL_ABI, LENDING_POOL_ADDRESS } from '@/constants/contracts';
import { formatEther } from 'viem'; // 使用 viem 的格式化工具

export function TotalDeposits() {
  const chainId = useChainId();
  const contractAddress = LENDING_POOL_ADDRESS[chainId];

  // 使用 useReadContract Hook
  const {
    data: totalDepositsRaw,
    isError,
    isLoading,
    refetch,
  } = useReadContract({
    abi: LENDING_POOL_ABI,
    address: contractAddress,
    functionName: 'totalDeposits',
    // 这里有个坑:如果合约地址在当前链不存在,应跳过查询
    query: {
      enabled: !!contractAddress,
    },
  });

  if (!contractAddress) {
    return <div className="text-yellow-600">Contract not deployed on this network.</div>;
  }

  if (isLoading) return <div>Loading total deposits...</div>;
  if (isError) return <div>Error loading data.</div>;

  // 将 BigInt 格式化为可读的 ETH 字符串
  const totalDepositsFormatted = totalDepositsRaw
    ? `${formatEther(totalDepositsRaw)} ETH`
    : '0 ETH';

  return (
    <div className="p-4 border rounded shadow">
      <h3 className="font-bold">Total Pool Deposits</h3>
      <p className="text-2xl mt-2">{totalDepositsFormatted}</p>
      <button
        onClick={() => refetch()}
        className="mt-2 text-sm text-blue-500 hover:underline"
      >
        Refresh
      </button>
    </div>
  );
}

关键点

  • useReadContractquery.enabled 选项非常有用,可以条件性地触发查询。这里我们只在当前链有合约地址时才进行查询。
  • 返回的 databigint 类型(来自 viem),需要使用 formatEther 等工具进行格式化显示。
  • refetch 函数允许手动触发数据重新获取。

5. 发送交易(存款操作)

读取数据相对安全,发送交易则需要用户签名并支付 Gas。我们使用 useWriteContractuseWaitForTransactionReceipt 组合来实现。

// components/DepositForm.tsx
'use client';

import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useChainId } from 'wagmi';
import { parseEther } from 'viem';
import { LENDING_POOL_ABI, LENDING_POOL_ADDRESS } from '@/constants/contracts';

export function DepositForm() {
  const [amount, setAmount] = useState('');
  const chainId = useChainId();
  const contractAddress = LENDING_POOL_ADDRESS[chainId];

  // 1. 准备写合约 Hook
  const {
    writeContract,
    data: hash,
    isPending: isWritePending,
    error: writeError,
  } = useWriteContract();

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

  const handleDeposit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!contractAddress || !amount) return;

    writeContract({
      abi: LENDING_POOL_ABI,
      address: contractAddress,
      functionName: 'deposit',
      // 注意:value 需要以 wei 为单位,使用 parseEther 转换
      value: parseEther(amount),
    });
  };

  if (!contractAddress) {
    return <p className="text-red-500">Please switch to a supported network.</p>;
  }

  return (
    <form onSubmit={handleDeposit} className="space-y-4 p-4 border rounded">
      <h3 className="font-bold">Deposit ETH</h3>
      <input
        type="text"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
        placeholder="Amount in ETH"
        className="w-full p-2 border rounded"
        disabled={isWritePending || isConfirming}
      />
      <button
        type="submit"
        disabled={!writeContract || isWritePending || isConfirming}
        className="w-full py-2 bg-green-600 text-white rounded disabled:opacity-50"
      >
        {isWritePending ? 'Confirming in wallet...' : 'Deposit'}
      </button>

      {hash && <div className="text-sm break-all">Transaction Hash: {hash}</div>}
      {isConfirming && <div>Waiting for confirmation...</div>}
      {isConfirmed && <div className="text-green-600">Transaction confirmed!</div>}
      {writeError && (
        <div className="text-red-600">Error: {writeError.message}</div>
      )}
    </form>
  );
}

这个流程清晰地将“发起交易”(在钱包中签名)和“等待链上确认”两个步骤分开,并提供了相应的状态反馈,用户体验更好。

完整代码示例

以下是一个整合了上述核心组件的简单主页示例:

// app/page.tsx
import { WalletConnector } from '@/components/WalletConnector';
import { TotalDeposits } from '@/components/TotalDeposits';
import { DepositForm } from '@/components/DepositForm';

export default function Home() {
  return (
    <main className="min-h-screen p-8 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold mb-8">DeFi Lending Interface</h1>
      <div className="mb-8">
        <WalletConnector />
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <TotalDeposits />
        <DepositForm />
      </div>
    </main>
  );
}

踩坑记录

  1. useAccount 状态不更新:最初我直接在组件中解构 useAccount(),但在某些嵌套组件中,状态更新没有触发重渲染。解决方法:确保组件是客户端组件('use client'),并且检查 WagmiProvider 是否正确地包裹在应用最外层。有时,将 useAccount 的返回值用 useMemo 包裹的组件使用,也会导致问题,直接使用即可。

  2. RPC 限流与错误:在开发时使用了公共 RPC,频繁请求后很快被限流,导致 useReadContract 一直返回错误。解决方法:立即申请 Alchemy 或 Infura 的免费层 API Key,并在 transports 配置中使用。这不仅是开发必备,也关乎生产环境的稳定性。

  3. 类型“0x${string}”错误:在定义合约地址常量时,直接写字符串会报类型不匹配,因为 viem 要求地址是 0x 开头的特定格式。解决方法:使用 TypeScript 的字面量类型 `0x${string}` 来标注类型,或者使用 as const 断言。

  4. 交易成功后状态未重置:用户完成一次存款后,输入框里的金额还在,容易导致误操作。解决方法:在 useWaitForTransactionReceiptisConfirmed 变为 true 后,手动清空 amount 状态。这是一个简单的用户体验优化。

小结

通过这一轮实战,我深刻体会到 wagmi + viem 这套组合在管理 Web3 应用复杂状态时的优势,它抽象了底层事件监听,让开发者能更专注于业务逻辑。下一步可以继续探索使用 useSimulateContract 进行交易预模拟以优化用户体验,以及集成 WalletConnect 等连接器来支持更多的钱包类型。