背景
上个月,我接手了一个去中心化借贷协议的前端迭代任务。项目原本使用 ethers.js 直接与 MetaMask 交互,代码里散落着大量的 window.ethereum 判断和事件监听,维护起来非常头疼。我的目标是重构这部分逻辑,让钱包连接、链切换和合约调用变得清晰可控。经过调研,我决定采用 wagmi + viem 这套现代组合,它们提供了声明式的 Hooks 和强大的类型安全。然而,从老方案迁移过来并非一帆风顺,我遇到了连接状态不同步、多链切换时数据读取错误等一系列具体问题。
问题分析
一开始,我的想法很简单:用 wagmi 提供的 useConnect, useAccount, useSwitchChain 等 Hook 替换掉所有的手动 ethers.providers.Web3Provider 实例化。我快速搭建了一个基础配置,连接钱包很顺利。但当我尝试在组件中读取用户在不同链上的余额和抵押头寸时,问题出现了。
- 状态同步延迟:用户通过 MetaMask 切换了网络,但我的前端组件里
useAccount返回的chainId并没有立即更新,导致后续的合约读取请求仍然发向了旧的链。 - 合约实例管理:我需要根据当前激活的链,动态使用对应的合约地址和 ABI 创建合约实例。最初我尝试在每次渲染时都重新创建,这引发了不必要的重渲染和潜在的内存问题。
- 数据读取优化:一个页面需要同时读取用户钱包余额、存款余额和市场价格等多个数据。如果简单地为每个数据调用一个独立的 Hook,会导致对 RPC 节点的请求激增,在公共 RPC 下容易遇到速率限制。
排查过程让我意识到,不能只是机械地替换 API,而需要理解 wagmi 的状态管理流,并利用 viem 的 publicClient 和 walletClient 概念来设计数据获取逻辑。
核心实现
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 前端的核心。我使用 usePublicClient 和 useWalletClient 来获取客户端,然后动态创建合约实例。为了优化请求,我使用 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>
);
}
踩坑记录
useAccount链 ID 更新延迟:现象是用户切换网络后,UI 状态还是旧的链。解决方法:我发现useAccount的更新依赖于wagmi内部的事件监听。确保WagmiProvider的配置中transports为每条链正确配置了 RPC,并且钱包注入正常。有时需要检查浏览器控制台是否有来自viem的 RPC 错误。useReadContract在链切换后读取旧链数据:调用useReadContract时,如果chainId变化,但contract配置对象(包含地址)没有用新的chainId重新创建,它仍会向旧地址发起请求。解决方法:将合约地址做成依赖于chainId的变量,并确保contract配置在chainId变化后重新生成。使用我上面封装的useLendingContractHook 可以很好地管理这个生命周期。- 批量请求
useReadContracts的类型错误:useReadContracts对contracts数组的类型要求非常严格,需要每个元素都明确functionName和args的类型。解决方法:在定义contracts数组时,使用as const断言来锁定字面量类型,或者确保从 ABI 生成的类型被正确应用。 - 公共 RPC 速率限制:在开发时频繁刷新页面,很快收到了 429 错误。解决方法:使用
wagmi的batch配置项(在createConfig的transports中设置{ batch: { batchSize: 1024 } }),它允许将多个 JSON-RPC 请求打包成一个。更根本的解决方案是使用可靠的私有 RPC 节点服务。
小结
这次重构让我深刻体会到,用 wagmi 构建 DeFi 前端,核心在于理解其基于 viem 客户端的架构和 React Query 的异步状态管理。将链感知的合约实例创建封装成自定义 Hook,并善用批量读取,能大幅提升代码健壮性和用户体验。下一步,我可以继续探索 wagmi 的 useWriteContract 与交易状态跟踪,以及如何集成更复杂的交易模拟(Simulation)功能。