从 ethers.js 到 viem:我在一个 DeFi 看板项目中踩过的所有坑与最终方案

从 ethers.js 到 viem:我在一个 DeFi 看板项目中踩过的所有坑与最终方案

摘要

做 DeFi 前端最怕什么?不是合约交互复杂,而是 RPC 超时、gas 估算失败、钱包连接中断这些破事。我之前用 ethers.js 写了一个多链流动性池看板,上线两天用户就反馈页面卡死。排查后发现是 ethers 的 getLogsestimateGas 在高并发下频繁抛异常。换成 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 的 ProviderContract 对象,对每个池子创建一个实例,然后轮询。

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 池),问题就暴露了:

  1. RPC 连接池爆炸 :每个 JsonRpcProvider 实例都会创建独立的 HTTP 连接,15 个实例就是 15 个连接,加上轮询,浏览器同时发起大量请求,RPC 节点直接返回 429 或超时。
  2. gas 估算不稳定 :ethers v6 的 estimateGas 在交易不失败时会返回一个 gas 值,但某些情况下(比如合约有 require 条件不满足)会抛异常,且异常信息不明确,导致用户无法确认问题。
  3. 事件日志查询慢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 没有合约对象,取而代之的是 readContractwriteContract 函数,直接传入合约地址和 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 有一个 fromBlocktoBlock 参数,并且支持 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,
  }));
}

这里有个坑fromBlocktoBlock 的类型是 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 fundsexecution 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: truepollingInterval。一开始我没设置 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)}`;
}

踩坑记录

  1. multicall 返回类型不匹配 :第一次用 multicall 时,我假设所有调用都成功,直接解构 resultsresult。结果某个池子的 getApr 返回 0 时,result0n(bigint),但我的代码期望是 number,导致页面崩溃。后来统一用 formatUnits 处理,并加上 status 判断。

  2. createPublicClient 的 chain 参数 :我一开始只传了 transport,没传 chain,结果所有方法都报错 chain is required。viem 的 client 必须指定 chain,否则无法确定链 ID 和交易类型。

  3. walletClient.sendTransaction 的 gas 计算 :viem 的 estimateGas 返回的是精确值,但实际发送时如果不加缓冲(buffer),某些合约会报 gas required exceeds allowance。我后来手动加了 10% 的缓冲:gas: estimatedGas * 110n / 100n

  4. watchContractEvent 的清理 :在 React 组件卸载时,watchContractEvent 返回的 unwatch 函数必须被调用,否则会继续监听导致内存泄漏。我一开始用 useEffect 的清理函数调用 unwatch,但忘了用 useRef 保存引用,结果每次组件更新都创建新的监听器。后来用 useRef 保存 unwatch 引用才解决。

小结

从 ethers.js 迁移到 viem,最直接的感受是:viem 更像一个为现代前端设计的工具,而不是一个区块链库的移植版 。它的 multicallbatch 请求、精确的错误处理,都让我少写了很多胶水代码。如果你也在做多链 DApp,强烈推荐试试 viem。下一步我打算研究 viem 的 custom transport,看看能不能直接对接 WalletConnect。

相关推荐
threerocks1 小时前
什么?我连 A2A、MCP 都没学会,现在又来了 AG-UI、A2UI.
前端·aigc·ai编程
牛奶2 小时前
如何自己写一个浏览器插件?
前端·chrome·浏览器
亿元程序员2 小时前
为什么Cocos都4.0了还有人用2.x?
前端
MomentYY3 小时前
AI 到底是“懂”,还是在“猜”?
前端·人工智能·ai编程
鹏毓网络科技3 小时前
Cursor Rules 文件配置实战:3 个隐藏参数让我每月少写 40% 样板代码
前端·github
没烦恼3013 小时前
无痕模式下 HTTP\-First 拦截引发的“页面刷新”误判
前端
文心快码BaiduComate3 小时前
从个人提效到组织提效:Comate辅助构建自我进化的AI研发系统
前端·程序员
hunterandroid3 小时前
Compose 状态管理:remember、rememberSaveable 与状态提升
前端
星栈4 小时前
Dioxus 接数据库最容易写歪的 3 个地方:sqlx + SQLite 怎么接才顺
前端·rust·前端框架
晴虹4 小时前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js