从 ethers.js 到 viem:我在一个 DeFi 看板项目中踩过的所有坑与最终方案
摘要
做 DeFi 前端最怕什么?不是合约交互复杂,而是 RPC 超时、gas 估算失败、钱包连接中断这些破事。我之前用 ethers.js 写了一个多链流动性池看板,上线两天用户就反馈页面卡死。排查后发现是 ethers 的 getLogs 和 estimateGas 在高并发下频繁抛异常。换成 viem 后,所有问题迎刃而解。本文记录了我从 ethers 迁移到 viem 的完整过程,包括为什么换、怎么换、踩了哪些坑。
背景
上个月接了一个 DeFi 看板项目,需求是同时监控 Ethereum、Arbitrum 和 Optimism 三条链上 5 个主流 DEX 的流动性池数据,包括 TVL、交易量、APR 等。用户要求实时刷新(每 10 秒一次),还要支持钱包连接后一键添加流动性。
我一开始自然选择 ethers.js,毕竟用了两年多,生态成熟。项目用了 React + TypeScript + ethers v6 + RainbowKit。开发阶段一切正常,但测试网部署后,只要同时请求超过 3 个池子的历史事件,页面就卡住,控制台报 RPC Error: timeout。更离谱的是,用户连接 MetaMask 后,偶尔会报 gas estimation failed,但同样的交易在 etherscan 上就能成功。
我花了两天时间排查,最终决定换掉 ethers.js,用 viem 重写。说实话,一开始心里没底,但实际迁移后发现 viem 的设计理念确实更适合现代 Web3 前端。下面是我踩过的所有坑和最终方案。
问题分析
最初思路与失败原因
我最初的想法很简单:用 ethers 的 Provider 和 Contract 对象,对每个池子创建一个实例,然后轮询。
typescript
// 最初的实现(后来放弃了)
const provider = new ethers.JsonRpcProvider(RPC_URL);
const poolContract = new ethers.Contract(POOL_ADDRESS, POOL_ABI, provider);
async function fetchPoolData() {
const [tvl, volume, apr] = await Promise.all([
poolContract.getTotalValueLocked(),
poolContract.getVolume24h(),
poolContract.getApr(),
]);
return { tvl, volume, apr };
}
这段代码在单链单池子时没问题,但一旦扩展到 15 个池子(3 链 × 5 池),问题就暴露了:
- RPC 连接池爆炸 :每个
JsonRpcProvider实例都会创建独立的 HTTP 连接,15 个实例就是 15 个连接,加上轮询,浏览器同时发起大量请求,RPC 节点直接返回 429 或超时。 - gas 估算不稳定 :ethers v6 的
estimateGas在交易不失败时会返回一个 gas 值,但某些情况下(比如合约有require条件不满足)会抛异常,且异常信息不明确,导致用户无法确认问题。 - 事件日志查询慢 :
getLogs方法在大量区块范围内查询时效率极低,且没有内置的节流机制。
排查过程
我首先用 Chrome 的 Network 面板观察请求,发现每 10 秒就有 45 个 RPC 请求(3 链 × 5 池 × 3 个方法)。RPC 节点(我用的是 Infura 和 Alchemy 混合)开始返回 429 错误。
接着我尝试了 ethers.providers.FallbackProvider,想用多个 RPC 做负载均衡,但 ethers v6 的 FallbackProvider 配置复杂,而且没法精细控制每个请求的超时时间。
最后我想到用 Promise.allSettled 来避免一个失败导致全部失败,但这样又会丢失部分数据,用户体验更差。
就在这时,我看到社区有人在讨论 viem,说它天然支持 batch 请求和多链。我决定试试。
核心实现:迁移到 viem
1. 替换 Provider:从 JsonRpcProvider 到 createPublicClient
viem 的核心概念是 Client,分为 PublicClient(只读)和 WalletClient(写操作)。这和 ethers 的 Provider / Signer 类似,但 viem 的 client 是轻量级的,默认支持请求批处理。
我的第一步是把所有的 JsonRpcProvider 替换成 createPublicClient。
typescript
// 安装依赖
// npm install viem @wagmi/core @rainbow-me/rainbowkit wagmi
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet, arbitrum, optimism } from 'viem/chains';
// 创建多链客户端
const clients = {
[mainnet.id]: createPublicClient({
chain: mainnet,
transport: http(process.env.NEXT_PUBLIC_MAINNET_RPC, {
batch: { wait: 50 }, // 关键:启用批处理,50ms内收集请求
}),
}),
[arbitrum.id]: createPublicClient({
chain: arbitrum,
transport: http(process.env.NEXT_PUBLIC_ARBITRUM_RPC, {
batch: { wait: 50 },
}),
}),
[optimism.id]: createPublicClient({
chain: optimism,
transport: http(process.env.NEXT_PUBLIC_OPTIMISM_RPC, {
batch: { wait: 50 },
}),
}),
};
这里有个坑 :batch 选项默认是关闭的,必须显式设置 batch: { wait: number }。wait 是指客户端在发送批处理请求前等待多少毫秒来收集更多请求。我一开始没设置,结果请求还是一个个发,后来加了 wait: 50,RPC 请求量直接减少了 60%。
另一个细节:viem 的 http transport 支持 timeout 配置,我设置成了 10 秒,比 ethers 默认的 30 秒更合理,避免长时间等待。
typescript
transport: http(RPC_URL, {
batch: { wait: 50 },
timeout: 10_000, // 10秒超时
}),
2. 合约交互:从 Contract 到 readContract
ethers 中我习惯用 new ethers.Contract(address, abi, provider) 来创建合约对象。viem 没有合约对象,取而代之的是 readContract 和 writeContract 函数,直接传入合约地址和 ABI。
typescript
// 定义池子 ABI(只取需要的部分)
const poolAbi = parseAbi([
'function getTotalValueLocked() view returns (uint256)',
'function getVolume24h() view returns (uint256)',
'function getApr() view returns (uint256)',
'event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)',
]);
async function fetchPoolData(client, poolAddress: `0x${string}`) {
// 使用 multicall 批量读取
const results = await client.multicall({
contracts: [
{
address: poolAddress,
abi: poolAbi,
functionName: 'getTotalValueLocked',
},
{
address: poolAddress,
abi: poolAbi,
functionName: 'getVolume24h',
},
{
address: poolAddress,
abi: poolAbi,
functionName: 'getApr',
},
],
});
// 处理结果
const [tvlResult, volumeResult, aprResult] = results;
return {
tvl: tvlResult.status === 'success' ? tvlResult.result : 0n,
volume: volumeResult.status === 'success' ? volumeResult.result : 0n,
apr: aprResult.status === 'success' ? aprResult.result : 0n,
};
}
这里有个坑 :multicall 返回的数组每个元素都有 status 字段,可能是 'success' 或 'failure'。我一开始直接解构 results 然后用 result,结果某个池子合约返回空值时直接报错。加上 status 判断后,即使某个调用失败,也不会影响其他调用。
另一个关键点:parseAbi 函数可以只传入你需要的函数和事件签名,不需要完整的 ABI 文件。这对减少包体积很有帮助。
3. 事件日志查询:从 getLogs 到 getLogs + 分页
ethers 的 getLogs 查询大范围日志时经常超时。viem 的 getLogs 有一个 fromBlock 和 toBlock 参数,并且支持 blockTag 为 'latest'。但更关键的是,viem 的日志查询可以配合 createEventFilter 做流式处理。
typescript
async function fetchRecentSwaps(client, poolAddress: `0x${string}`, fromBlock: bigint) {
const logs = await client.getLogs({
address: poolAddress,
event: parseAbi(['event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)']),
fromBlock,
toBlock: 'latest',
});
// 解析日志
return logs.map((log) => ({
sender: log.args.sender,
amount0In: log.args.amount0In,
amount1In: log.args.amount1In,
amount0Out: log.args.amount0Out,
amount1Out: log.args.amount1Out,
to: log.args.to,
blockNumber: log.blockNumber,
}));
}
这里有个坑 :fromBlock 和 toBlock 的类型是 bigint,不能直接传 number。我一开始传 fromBlock: 1000000(number 类型),结果 TypeScript 报错。改成 1000000n 就好了。
但更重要的优化是:如果查询的区块范围太大(比如 1 万个区块),viem 还是会超时。我的解决方案是分页查询,每次只查 1000 个区块。
typescript
async function fetchSwapsPaginated(client, poolAddress, startBlock: bigint, endBlock: bigint) {
const allLogs = [];
const step = 1000n;
for (let from = startBlock; from < endBlock; from += step) {
const to = from + step > endBlock ? endBlock : from + step;
const logs = await client.getLogs({
address: poolAddress,
event: parseAbi(['event Swap(...)']),
fromBlock: from,
toBlock: to,
});
allLogs.push(...logs);
}
return allLogs;
}
虽然代码变长了,但实际运行稳定多了,再也没有超时报错。
4. 钱包连接与交易发送:从 Signer 到 WalletClient
这是迁移中最麻烦的部分,因为涉及到用户钱包交互。我原来用 RainbowKit + ethers 的 Signer,现在要改成 wagmi + viem 的 WalletClient。
typescript
// 使用 wagmi 的 useWalletClient hook
import { useWalletClient } from 'wagmi';
import { parseEther, encodeFunctionData } from 'viem';
function AddLiquidityButton({ poolAddress }) {
const { data: walletClient } = useWalletClient();
const handleAddLiquidity = async () => {
if (!walletClient) return;
try {
// 构造交易数据
const txData = encodeFunctionData({
abi: poolAbi,
functionName: 'addLiquidity',
args: [parseEther('1'), parseEther('0.5')], // 假设参数是 amount0Desired, amount1Desired
});
// 估算 gas(关键:使用 walletClient 的 estimateGas)
const gas = await walletClient.estimateGas({
account: walletClient.account.address,
to: poolAddress,
data: txData,
value: 0n,
});
// 发送交易
const txHash = await walletClient.sendTransaction({
account: walletClient.account.address,
to: poolAddress,
data: txData,
gas: gas * 110n / 100n, // 增加 10% 缓冲
});
// 等待交易确认
const receipt = await walletClient.waitForTransactionReceipt({ hash: txHash });
console.log('交易成功,区块号:', receipt.blockNumber);
} catch (error) {
console.error('交易失败:', error);
// 显示用户友好的错误信息
if (error.message.includes('insufficient funds')) {
alert('余额不足');
} else if (error.message.includes('user rejected')) {
alert('用户取消交易');
} else {
alert('交易失败,请重试');
}
}
};
return <button onClick={handleAddLiquidity}>添加流动性</button>;
}
这里有个坑 :viem 的 estimateGas 在交易失败时会返回具体的错误原因,比如 insufficient funds 或 execution reverted,而不是像 ethers 那样抛一个笼统的异常。这让我在错误处理时能给出更精确的提示。
另一个细节:sendTransaction 需要传入 account 字段,不能省略。我一开始忘记传,结果报错 "account" is required。
5. 实时数据更新:使用 watchContractEvent
ethers 中监听事件用 contract.on("Swap", callback),但问题是一个页面上监听多个合约时,回调函数容易内存泄漏。viem 提供了 watchContractEvent,它内部做了资源管理。
typescript
import { watchContractEvent } from 'viem/actions';
function usePoolSwaps(client, poolAddress: `0x${string}`) {
const [swaps, setSwaps] = useState([]);
const unwatchRef = useRef(null);
useEffect(() => {
// 开始监听
unwatchRef.current = watchContractEvent(client, {
address: poolAddress,
abi: poolAbi,
eventName: 'Swap',
onLogs: (logs) => {
setSwaps((prev) => {
const newSwaps = logs.map((log) => ({
sender: log.args.sender,
amount0In: log.args.amount0In,
// ... 其他字段
}));
// 只保留最近 100 条
return [...newSwaps, ...prev].slice(0, 100);
});
},
// 关键:轮询间隔
poll: true,
pollingInterval: 10_000, // 10秒轮询一次
});
return () => {
// 清理监听
if (unwatchRef.current) {
unwatchRef.current();
}
};
}, [client, poolAddress]);
return swaps;
}
这里有个坑 :watchContractEvent 默认使用 WebSocket 监听(如果 transport 支持),但我的 RPC 只支持 HTTP,所以必须设置 poll: true 和 pollingInterval。一开始我没设置 poll,结果监听一直没触发,后来才发现默认是 WebSocket 模式。
另一个细节:onLogs 回调接收的是日志数组,而不是单条日志。我一开始以为只返回一条,导致处理逻辑错误。
完整代码:可运行的 React 组件
下面是一个完整的 React 组件,展示了如何使用 viem + wagmi 实现多链流动性池数据看板的核心功能。
typescript
// PoolDashboard.tsx
import { useEffect, useState } from 'react';
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet, arbitrum, optimism } from 'viem/chains';
import { useWalletClient } from 'wagmi';
// 配置
const RPC_CONFIG = {
[mainnet.id]: process.env.NEXT_PUBLIC_MAINNET_RPC,
[arbitrum.id]: process.env.NEXT_PUBLIC_ARBITRUM_RPC,
[optimism.id]: process.env.NEXT_PUBLIC_OPTIMISM_RPC,
};
// 创建客户端
const clients = {};
for (const chain of [mainnet, arbitrum, optimism]) {
clients[chain.id] = createPublicClient({
chain,
transport: http(RPC_CONFIG[chain.id], {
batch: { wait: 50 },
timeout: 10_000,
}),
});
}
// 池子配置
const POOLS = [
{ address: '0x...', name: 'USDC/ETH', chainId: mainnet.id },
{ address: '0x...', name: 'WBTC/ETH', chainId: arbitrum.id },
// ... 更多池子
];
// ABI
const poolAbi = parseAbi([
'function getTotalValueLocked() view returns (uint256)',
'function getVolume24h() view returns (uint256)',
'function getApr() view returns (uint256)',
]);
export default function PoolDashboard() {
const [poolData, setPoolData] = useState({});
const [loading, setLoading] = useState(true);
const { data: walletClient } = useWalletClient();
useEffect(() => {
const fetchAllPools = async () => {
setLoading(true);
const results = {};
for (const pool of POOLS) {
const client = clients[pool.chainId];
if (!client) continue;
try {
const data = await client.multicall({
contracts: [
{ address: pool.address, abi: poolAbi, functionName: 'getTotalValueLocked' },
{ address: pool.address, abi: poolAbi, functionName: 'getVolume24h' },
{ address: pool.address, abi: poolAbi, functionName: 'getApr' },
],
});
results[pool.name] = {
tvl: data[0].status === 'success' ? formatUnits(data[0].result, 18) : 'N/A',
volume: data[1].status === 'success' ? formatUnits(data[1].result, 18) : 'N/A',
apr: data[2].status === 'success' ? Number(data[2].result) / 100 : 'N/A',
};
} catch (error) {
console.error(`获取 ${pool.name} 数据失败:`, error);
results[pool.name] = { tvl: 'Error', volume: 'Error', apr: 'Error' };
}
}
setPoolData(results);
setLoading(false);
};
// 初始加载
fetchAllPools();
// 每 30 秒刷新
const interval = setInterval(fetchAllPools, 30_000);
return () => clearInterval(interval);
}, []);
if (loading) return <div>加载中...</div>;
return (
<div>
<h1>流动性池看板</h1>
{Object.entries(poolData).map(([name, data]) => (
<div key={name} style={{ border: '1px solid #ccc', padding: 10, margin: 10 }}>
<h2>{name}</h2>
<p>TVL: ${data.tvl}</p>
<p>24h 交易量: ${data.volume}</p>
<p>APR: {data.apr}%</p>
</div>
))}
</div>
);
}
// 辅助函数:将 wei 转换为可读格式
function formatUnits(value: bigint, decimals: number): string {
const divisor = 10n ** BigInt(decimals);
const integerPart = value / divisor;
const fractionalPart = value % divisor;
return `${integerPart}.${fractionalPart.toString().padStart(decimals, '0').slice(0, 4)}`;
}
踩坑记录
-
multicall返回类型不匹配 :第一次用multicall时,我假设所有调用都成功,直接解构results取result。结果某个池子的getApr返回0时,result是0n(bigint),但我的代码期望是number,导致页面崩溃。后来统一用formatUnits处理,并加上status判断。 -
createPublicClient的 chain 参数 :我一开始只传了transport,没传chain,结果所有方法都报错chain is required。viem 的 client 必须指定 chain,否则无法确定链 ID 和交易类型。 -
walletClient.sendTransaction的 gas 计算 :viem 的estimateGas返回的是精确值,但实际发送时如果不加缓冲(buffer),某些合约会报gas required exceeds allowance。我后来手动加了 10% 的缓冲:gas: estimatedGas * 110n / 100n。 -
watchContractEvent的清理 :在 React 组件卸载时,watchContractEvent返回的unwatch函数必须被调用,否则会继续监听导致内存泄漏。我一开始用useEffect的清理函数调用unwatch,但忘了用useRef保存引用,结果每次组件更新都创建新的监听器。后来用useRef保存unwatch引用才解决。
小结
从 ethers.js 迁移到 viem,最直接的感受是:viem 更像一个为现代前端设计的工具,而不是一个区块链库的移植版 。它的 multicall、batch 请求、精确的错误处理,都让我少写了很多胶水代码。如果你也在做多链 DApp,强烈推荐试试 viem。下一步我打算研究 viem 的 custom transport,看看能不能直接对接 WalletConnect。