背景
最近在参与一个借贷类 DeFi 项目的界面重构,技术栈是 Next.js + TypeScript。项目需要与以太坊主网和几个 Layer 2 网络上的智能合约进行交互,核心功能包括连接/断开钱包、显示用户地址和余额、读取资金池数据以及提交存款/借款交易。
为了提升开发效率和代码质量,我们决定从之前直接使用 ethers.js 的方式,迁移到以 wagmi 和 viem 为核心的现代 Web3 开发栈。wagmi 提供了完善的 React Hooks 来管理钱包连接状态、账户信息和链信息,而 viem 则作为底层与区块链交互的库。想法很美好,但在实际集成过程中,我遇到了几个教科书上没写的“坑”,尤其是在状态同步和类型安全方面。
问题分析
一开始,我按照官方示例快速搭建了基础框架:配置 WagmiProvider,使用 useConnect 和 useAccount 来连接钱包和获取账户信息。界面很快就能弹出钱包选择框并成功连接。
但第一个问题立刻出现了:连接状态不同步。当我在 MetaMask 中切换账户或者切换网络时,我的应用界面并没有实时更新。用户看到的还是上一个账户的地址和余额,这显然是不可接受的。我最初的思路是监听 window.ethereum 的 accountsChanged 和 chainChanged 事件,然后手动去触发 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 的返回值,它包含了 address、isConnected 和当前 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>
);
}
关键点:
useReadContract的query.enabled选项非常有用,可以条件性地触发查询。这里我们只在当前链有合约地址时才进行查询。- 返回的
data是bigint类型(来自 viem),需要使用formatEther等工具进行格式化显示。 refetch函数允许手动触发数据重新获取。
5. 发送交易(存款操作)
读取数据相对安全,发送交易则需要用户签名并支付 Gas。我们使用 useWriteContract 和 useWaitForTransactionReceipt 组合来实现。
// 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>
);
}
踩坑记录
-
useAccount状态不更新:最初我直接在组件中解构useAccount(),但在某些嵌套组件中,状态更新没有触发重渲染。解决方法:确保组件是客户端组件('use client'),并且检查WagmiProvider是否正确地包裹在应用最外层。有时,将useAccount的返回值用useMemo包裹的组件使用,也会导致问题,直接使用即可。 -
RPC 限流与错误:在开发时使用了公共 RPC,频繁请求后很快被限流,导致
useReadContract一直返回错误。解决方法:立即申请 Alchemy 或 Infura 的免费层 API Key,并在transports配置中使用。这不仅是开发必备,也关乎生产环境的稳定性。 -
类型“
0x${string}”错误:在定义合约地址常量时,直接写字符串会报类型不匹配,因为 viem 要求地址是0x开头的特定格式。解决方法:使用 TypeScript 的字面量类型`0x${string}`来标注类型,或者使用as const断言。 -
交易成功后状态未重置:用户完成一次存款后,输入框里的金额还在,容易导致误操作。解决方法:在
useWaitForTransactionReceipt的isConfirmed变为true后,手动清空amount状态。这是一个简单的用户体验优化。
小结
通过这一轮实战,我深刻体会到 wagmi + viem 这套组合在管理 Web3 应用复杂状态时的优势,它抽象了底层事件监听,让开发者能更专注于业务逻辑。下一步可以继续探索使用 useSimulateContract 进行交易预模拟以优化用户体验,以及集成 WalletConnect 等连接器来支持更多的钱包类型。