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

背景

上个月,我接手了一个去中心化借贷协议前端页面的迭代任务。核心功能很简单:用户连接钱包后,页面需要实时显示其 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 项目并安装核心依赖。

bash 复制代码
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,它定义了项目要支持的链、钱包连接器以及公共客户端。

typescript 复制代码
// 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 能让结构更清晰:

typescript 复制代码
// 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

tsx 复制代码
// 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 位。

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

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

tsx 复制代码
// 在 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 可以更精细地展示"等待钱包签名"和"等待链上确认"两个不同阶段。

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

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

tsx 复制代码
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 核心部分,集成了上述功能:

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

相关推荐
冰暮流星2 小时前
javascript之dom方法访问内容
开发语言·前端·javascript
有意义2 小时前
滴滴一面复盘:从CSS布局到TS核心思想
前端·面试
我命由我123452 小时前
在 React 项目中,配置了 setupProxy.js 文件,无法正常访问 http://localhost:3000
开发语言·前端·javascript·react.js·前端框架·ecmascript·js
俺不会敲代码啊啊啊2 小时前
封装 ECharts Hook 适配多种图表容器
前端·vue.js·typescript·echarts
J2虾虾2 小时前
在Vue3中推荐使用的函数定义方法
前端·javascript·vue.js
辻戋2 小时前
从零手写mini-react
javascript·react.js·ecmascript
3秒一个大2 小时前
Cookie/Session vs JWT 双 Token:登录认证方案的演进与对比
前端·安全·ajax
努力的lpp2 小时前
【小迪安全41天】WEB攻防-ASP应用&HTTP.SYS&短文件&文件解析&Access注入&数据库泄漏
前端·安全·http
yellowbuff2 小时前
巧用IntersectionObserver 与 Suspense,实现真正的视口内懒加载(vue3)
前端