背景
上个月,我接手了一个去中心化借贷协议前端页面的迭代任务。核心功能很简单:用户连接钱包后,页面需要实时显示其 ETH 余额和特定 ERC20 代币的余额,并且能够进行存款和借款操作。团队决定采用 wagmi 和 viem 作为新的 Web3 开发栈,以替代之前略显笨重的 ethers.js + web3-react 组合,目标是让代码更简洁、类型提示更友好。
我本以为有了 wagmi 这种高度封装的 Hooks 库,开发会一帆风顺。但真正动手后才发现,从"显示余额"到"完成一笔交易",每一步都藏着细节。如何优雅地管理多合约实例?如何让 UI 精准响应链上状态变化?如何处理用户拒绝交易或网络切换?这些才是实战中的真问题。
问题分析
我的初始思路很直接:用 useAccount 拿到用户地址,用 useBalance 和 useContractRead 获取余额,用 useContractWrite 发送交易。然而,第一个页面刚搭起来就遇到了问题。
- 数据不同步:用户切换钱包账户后,余额显示有延迟,有时甚至不更新。
- 合约交互僵硬 :
useContractWrite返回的write函数调用后,对 pending、success、error 状态的处理分散在多个地方,逻辑混乱。 - 网络切换灾难:用户如果从主网切换到其他链,页面没有友好提示,合约调用会直接失败。
排查后发现,问题根源在于我没有理解 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.tsx 或 App.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>
)
}
关键点 :我拆分了 useContractWrite 和 useWaitForTransactionReceipt。这是 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 层进行捕获和友好提示。useContractWrite 的 error 对象包含了丰富的错误信息,可以通过解析 error.shortMessage 或 error.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
踩坑记录
-
useBalance不更新 :最初我没设置query: { enabled: !!address },导致 Hook 在地址变为undefined(如断开钱包)时仍在尝试查询,造成状态混乱。解决方法:始终根据连接状态和地址有效性来启用查询。 -
交易哈希有了,但
useWaitForTransactionReceipt一直 loading :我一开始用的公共 RPC 节点,有时会出现响应延迟或丢失事件。解决方法 :换用更稳定的节点提供商(如 Alchemy 或 Infura),并在useWaitForTransactionReceipt中增加重试配置:{ confirmations: 1, retry: true }。 -
ABI 类型错误 :将手写的 ABI 数组传给
useContractWrite时,TypeScript 报类型不匹配。解决方法 :使用as const断言将 ABI 数组转为字面量类型,或者使用 viem 提供的Abi类型。 -
用户拒绝交易后的状态残留 :用户在钱包弹窗中拒绝签名后,
isPending状态可能仍为true,按钮保持禁用。解决方法 :利用useContractWrite返回的reset函数,在错误发生时或组件卸载时调用它来重置所有状态。
小结
通过这个项目,我深刻体会到 wagmi 的核心优势在于将链上状态与 React 生命周期进行了无缝融合 。成功的秘诀不在于记住每个 Hook 的 API,而在于理解其数据流:配置驱动连接,连接状态驱动查询,查询结果驱动 UI,而用户操作触发写入,写入结果再反馈回状态。下一步,我可以探索 useSimulateContract 进行交易预模拟,以及结合 @tanstack/react-query 做更复杂的数据缓存和失效策略,让应用体验更上一层楼。