背景
上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。老代码用的是 ethers.js + 自己封装的钱包连接逻辑,维护起来非常头疼,尤其是多链支持和交易状态跟踪的部分,bug频出。团队决定迁移到更现代的 wagmi v2 搭配 viem,希望利用其声明式的Hooks来简化状态管理。我的任务很明确:用 React + wagmi v2 搭建一个新的前端基础框架,核心要搞定钱包连接、实时读取用户在不同链上的资产余额、以及一个关键合约(质押池)的数据。
一开始我以为照着官方文档拼凑一下就行,结果在实际开发中,从钱包连接状态同步到合约数据读取,我踩了一路的坑。这篇文章就是我解决这些问题的完整记录。
问题分析
我最开始的思路很简单:按照wagmi官方示例,配置好WagmiProvider,用useConnect连接钱包,用useAccount获取账户,然后用useReadContract读取数据。但一上手就发现了问题。
首先,当用户在MetaMask里切换网络时,前端应用的状态并没有立即同步更新。用户从以太坊主网切换到Arbitrum,但UI上显示的链ID还是1,这会导致后续所有针对错误链的合约调用失败。其次,在读取用户在不同链上的ERC20代币余额时,我需要根据当前激活的链动态切换合约地址,但最初的实现里,链切换后合约查询并没有自动重新执行。最后,在用户进行质押操作后,我需要准确监听交易状态(提交、打包、成功/失败),并实时更新UI上的余额数据,避免用户看到陈旧信息。
排查过程让我意识到,wagmi虽然抽象得很好,但如果不理解其内部的状态更新机制和Hooks的依赖关系,很容易写出看起来能跑但实际上有隐性bug的代码。问题的核心在于如何让React组件状态与钱包的外部状态(链、账户)保持强同步,以及如何正确构造依赖数组以触发查询的重新执行。
核心实现
1. 配置Provider与多链支持
第一步是正确配置WagmiProvider。这里我选择了项目需要支持的四个链:Ethereum, Arbitrum, Optimism和Polygon。我使用viem提供的预定义链配置,并创建了一个自定义的wagmi配置对象。这里有个关键点:config对象必须被稳定地引用,最好在React组件外部创建,或者用useMemo包裹,防止它在每次渲染时重新创建,导致不必要的上下文重置。
// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi';
import { mainnet, arbitrum, optimism, polygon } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { injected } from 'wagmi/connectors';
// 创建稳定的查询客户端和配置
const queryClient = new QueryClient();
const config = createConfig({
chains: [mainnet, arbitrum, optimism, polygon],
connectors: [injected()], // 主要支持注入式钱包(如MetaMask)
transports: {
[mainnet.id]: http(),
[arbitrum.id]: http(),
[optimism.id]: http(),
[polygon.id]: http(),
},
});
export function WagmiProvider({ children }: { children: React.ReactNode }) {
return (
<WagmiProviderCore config={config}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</WagmiProviderCore>
);
}
2. 实现可靠的钱包连接与链状态同步
接下来是连接组件。我不仅需要连接按钮,还需要一个实时显示当前网络和账户的组件。useAccount Hook提供了address, chainId, connector等信息,并且会响应钱包扩展程序的状态变化。但为了处理网络切换,我必须结合使用useSwitchChain。
踩过的一个坑:最初我试图用useAccount的chainId直接作为读取合约数据的链依据,但当用户拒绝网络切换请求时,chainId可能处于“期望切换”但“实际未变”的中间状态。更好的做法是,对于关键操作(如发送交易),始终使用useAccount返回的chain对象,并结合错误处理。
// src/components/ConnectButton.tsx
import { useConnect, useAccount, useDisconnect, useSwitchChain } from 'wagmi';
export function ConnectButton() {
const { connect, connectors, isPending } = useConnect();
const { address, chain, isConnected } = useAccount();
const { disconnect } = useDisconnect();
const { switchChain } = useSwitchChain();
const handleConnect = () => {
// 默认连接第一个注入式连接器(如MetaMask)
connect({ connector: connectors[0] });
};
const handleSwitchChain = (targetChainId: number) => {
switchChain({ chainId: targetChainId });
};
if (!isConnected) {
return (
<button onClick={handleConnect} disabled={isPending}>
{isPending ? 'Connecting...' : 'Connect Wallet'}
</button>
);
}
return (
<div>
<p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
<p>Network: {chain?.name} (ID: {chain?.id})</p>
<div>
<button onClick={() => handleSwitchChain(arbitrum.id)}>Switch to Arbitrum</button>
<button onClick={() => disconnect()}>Disconnect</button>
</div>
</div>
);
}
3. 动态读取多链合约数据
这是DeFi前端的核心。我需要读取用户在某条链上的特定代币余额。合约地址因链而异。useReadContract Hook接收一个配置对象,当其中的address、chainId或account发生变化时,它会自动重新获取数据。
注意这个细节:useReadContract的query选项中的enabled属性非常有用。我可以设置enabled: !!address && !!chainId,这样只有当用户钱包已连接且链ID明确时,才会发起查询,避免了不必要的错误请求和日志噪音。
// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from 'viem';
// 不同链上的USDC合约地址映射
const USDC_ADDRESS: Record<number, `0x${string}`> = {
1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum Mainnet
42161: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // Arbitrum
10: '0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // Optimism
137: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', // Polygon
};
export function useTokenBalance() {
const { address, chainId } = useAccount();
const { data: balance, isLoading, error, refetch } = useReadContract({
abi: erc20Abi,
address: chainId ? USDC_ADDRESS[chainId] : undefined,
functionName: 'balanceOf',
args: address ? [address] : undefined,
query: {
enabled: !!address && !!chainId, // 关键:确保条件满足才查询
refetchInterval: 10000, // 每10秒自动刷新一次
},
});
return {
balance,
isLoading,
error,
refetch, // 暴露手动刷新函数,用于交易后更新
};
}
4. 执行合约写入与交易状态监听
用户操作,比如质押代币,需要发送交易。我使用useWriteContract来发起交易,但更重要的是监听交易状态。wagmi v2 通过useWaitForTransactionReceipt Hook提供了优雅的解决方案。
这里有个大坑:useWriteContract返回的writeContractAsync函数在调用时,必须明确指定chainId。即使你的config里配置了多链,且用户当前已切换到目标链,如果你不传chainId,它有时会默认使用配置中的第一个链(比如主网),导致交易发错链。务必显式传递account和chainId。
// src/components/StakeForm.tsx
import { useState } from 'react';
import { useWriteContract, useWaitForTransactionReceipt, useAccount } from 'wagmi';
import { parseUnits } from 'viem';
const stakingPoolAbi = [ /* 你的质押合约ABI */ ] as const;
const STAKING_POOL_ADDRESS = '0x...'; // 你的质押合约地址
export function StakeForm() {
const [amount, setAmount] = useState('');
const { address, chainId } = useAccount();
const {
writeContractAsync,
isPending: isWritePending,
data: hash,
error: writeError,
reset: resetWrite,
} = useWriteContract();
const {
isLoading: isConfirming,
isSuccess: isConfirmed,
error: receiptError,
} = useWaitForTransactionReceipt({
hash,
query: {
enabled: !!hash, // 只有有交易哈希时才启动监听
},
});
const handleStake = async () => {
if (!address || !chainId) return;
try {
resetWrite(); // 重置上一次的写入状态
await writeContractAsync({
abi: stakingPoolAbi,
address: STAKING_POOL_ADDRESS,
functionName: 'stake',
args: [parseUnits(amount, 18)], // 假设代币精度18
account: address,
chainId: chainId, // !!!务必显式指定链ID
});
// 交易哈希已提交,状态由 useWaitForTransactionReceipt 监听
} catch (err) {
console.error('Stake failed:', err);
}
};
return (
<div>
<input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="Amount to stake" />
<button onClick={handleStake} disabled={isWritePending || !amount}>
{isWritePending ? 'Confirming in wallet...' : 'Stake'}
</button>
{isConfirming && <p>Transaction is being confirmed...</p>}
{isConfirmed && <p>Stake successful! <button onClick={() => refetchBalance()}>Refresh Balance</button></p>}
{writeError && <p style={{ color: 'red' }}>Error: {writeError.message}</p>}
</div>
);
}
完整代码示例
下面是一个整合了以上关键部分的简化版主应用组件,你可以直接复制到一个新的React项目中运行(需先安装依赖)。
// App.tsx
import { WagmiProvider } from './providers/WagmiProvider';
import { ConnectButton } from './components/ConnectButton';
import { useTokenBalance } from './hooks/useTokenBalance';
import { StakeForm } from './components/StakeForm';
function AppContent() {
const { balance, isLoading, error, refetch } = useTokenBalance();
return (
<div style={{ padding: '20px' }}>
<h1>DeFi Staking Dashboard</h1>
<ConnectButton />
<hr />
<h2>Your USDC Balance</h2>
{isLoading && <p>Loading balance...</p>}
{error && <p>Error loading balance: {error.message}</p>}
{balance !== undefined && (
<p>Balance: {balance.toString()} units (raw)</p>
// 实际应用中,这里需要根据代币精度格式化显示
)}
<button onClick={() => refetch()}>Refresh Balance</button>
<hr />
<h2>Stake Tokens</h2>
<StakeForm onSuccess={refetch} /> {/* 传入刷新余额的回调 */}
</div>
);
}
export default function App() {
return (
<WagmiProvider>
<AppContent />
</WagmiProvider>
);
}
踩坑记录
-
useReadContract不自动更新:当用户切换钱包账户后,余额查询没有更新。原因:我忘记将address作为args的一部分。args: [address]必须依赖address变量,当address变化时,查询才会重新执行。解决:确保args正确绑定到响应式变量(如来自useAccount的address)。 -
交易发错链:用户在Arbitrum上点击质押,交易却发到了以太坊主网,导致失败和Gas费损失。原因:调用
writeContractAsync时没有显式传递chainId参数。解决:始终从useAccount中获取当前的chainId,并在写入合约时明确指定chainId: currentChainId。 -
“RPC Error: Rate Limited”:在开发时频繁刷新页面,快速连接/断开钱包,导致Infura或Alchemy的RPC端点报速率限制错误。原因:
wagmi的http()传输层默认没有配置请求节流或重试。解决:为生产环境配置更健壮的RPC提供商,或者使用viem的fallback传输层,设置多个RPC端点作为备用。例如:transport: fallback([http('https://mainnet.infura.io/v3/your-key'), http()])。 -
**TypeScript类型错误:
“0x{string}`类型。**原因**:viem和wagmi为了类型安全,要求地址是严格的以0x开头的十六进制字符串类型。**解决**:使用类型断言as `0x${string}``,或者确保你的地址常量符合该模板字面量类型。
小结
通过这一轮实战,我深刻体会到在Web3前端开发中,状态同步的可靠性远比功能实现更重要。wagmi v2配合viem提供了强大的基础,但开发者必须清晰地理解:账户、链ID、合约地址如何作为Hooks的依赖项驱动数据流。下一步,我计划深入研究wagmi的存储持久化和自定义缓存策略,以进一步提升复杂DeFi应用的用户体验。