用 Viem 替代 ethers.js 重写 DeFi 前端:从踩坑 3 天到丝滑体验,我总结了这些关键差异

用 Viem 替代 ethers.js:Web3 前端新标准

摘要

在重构一个多链 DeFi 前端时,我用 Viem 替换了 ethers.js v5。本以为只是换个 API 调用方式,结果在交易模拟、gas 估算、事件监听上踩了无数坑。这篇文章完整记录了我迁移过程中的真实问题、解决方案和关键代码,希望能帮你少走弯路。

背景

今年年初,我接手了一个老旧的 DeFi 聚合器前端项目。这个项目使用 ethers.js v5 + React,支持 Ethereum、Polygon、Arbitrum 三条链。随着用户量增长,项目暴露出两个痛点:一是 ethers.js 的 bundle 体积太大(压缩后约 120KB),导致首屏加载慢;二是类型支持不够好,合约调用经常出现运行时错误,特别是处理复杂结构体时。

我决定用 Viem 重写整个前端。Viem 是 wagmi 团队推出的轻量级 Web3 库,TypeScript 优先,体积只有 ethers.js 的 1/4。但迁移过程远比我想象的复杂------Viem 的 API 设计哲学和 ethers.js 完全不同,很多我习以为常的模式在 Viem 里行不通。

问题分析

我的第一反应是:不就是换个库调用 RPC 吗?把 ethers.providers.JsonRpcProvider 换成 createPublicClient,把 ethers.Contract 换成 getContract 不就完事了?

结果第一天就栽了。项目里有一段关键代码:在用户发起 swap 交易前,先模拟交易(eth_call)来估算实际 gas 消耗。用 ethers.js 时,我这样写:

typescript 复制代码
// ethers.js 的做法
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
const contract = new ethers.Contract(routerAddress, routerABI, provider);
// 模拟交易
const gasEstimate = await contract.estimateGas.swapExactTokensForTokens(
  amountIn,
  amountOutMin,
  path,
  to,
  deadline
);

这个模式在 ethers.js 里很自然:estimateGas 方法直接返回 BigNumber。但 Viem 里没有 estimateGas 这种语法糖,它把合约调用拆成了更底层的步骤。

更坑的是,Viem 的 simulateContractestimateContractGas 返回的数据结构和 ethers.js 完全不同。我当时按照 ethers.js 的习惯调用,结果报错 "Invalid argument type" 整整排查了半天。

核心实现

1. 初始化客户端:Viem 的"双客户端"模式

Viem 和 ethers.js 最大的区别是:Viem 把"读"和"写"分成了两个客户端

  • createPublicClient:负责读取链上数据(余额、合约状态、交易详情)
  • createWalletClient:负责签名和发送交易(需要连接钱包)

我当时踩的第一个坑是:以为一个 createClient 就能搞定一切,结果发现 publicClient 没有 sendTransaction 方法,walletClient 没有 getBalance 方法。

正确的做法是分别创建:

typescript 复制代码
import { createPublicClient, createWalletClient, http } from 'viem';
import { mainnet, polygon, arbitrum } from 'viem/chains';

// 公共客户端:只读操作
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(process.env.NEXT_PUBLIC_MAINNET_RPC_URL),
});

// 钱包客户端:写操作,需要连接钱包
const walletClient = createWalletClient({
  chain: mainnet,
  transport: window.ethereum ? custom(window.ethereum) : http(),
});

注意这个细节createWalletClienttransport 参数,如果你用的是浏览器扩展钱包(如 MetaMask),必须传入 custom(window.ethereum)。如果你传入 http(),它会尝试用 RPC 直接发交易,但钱包签名需要用户交互,所以会报错。

2. 模拟交易:从 estimateGassimulateContract

这是迁移过程中最让我头疼的部分。在 Viem 里,模拟交易和估算 gas 是分离的:

  • simulateContract:模拟执行合约调用,返回执行结果和 gas 估算
  • estimateContractGas:只估算 gas 消耗

我项目里需要先模拟交易看是否成功,再估算 gas 做安全限制。用 Viem 的正确写法是:

typescript 复制代码
import { parseEther, formatEther, maxUint256 } from 'viem';

async function simulateAndEstimateSwap(
  publicClient: PublicClient,
  routerAddress: `0x${string}`,
  tokenIn: `0x${string}`,
  tokenOut: `0x${string}`,
  amountIn: bigint,
  recipient: `0x${string}`
) {
  // 第一步:模拟交易
  const { result, request } = await publicClient.simulateContract({
    address: routerAddress,
    abi: routerABI,
    functionName: 'swapExactTokensForTokens',
    args: [
      amountIn,
      0n, // amountOutMin 设为 0 只是为了模拟
      [tokenIn, tokenOut],
      recipient,
      maxUint256, // deadline 用最大值
    ],
    account: recipient, // 这里必须指定模拟交易的发送者
  });

  // result 是模拟执行后的返回值
  console.log('模拟交易结果:', result);

  // 第二步:估算 gas
  const gasEstimate = await publicClient.estimateContractGas(request);
  
  // 加上安全系数(比如 1.2 倍)
  const safeGasLimit = (gasEstimate * 120n) / 100n;
  
  return { result, gasEstimate, safeGasLimit };
}

这里有个坑simulateContractaccount 参数必须指定,不然 Viem 不知道用谁的地址去模拟调用。这个参数在 ethers.js 里是隐式的(从 provider 获取当前账户),但在 Viem 里必须显式传入。

另一个坑是 maxUint256 的用法。在 ethers.js 里,ethers.constants.MaxUint256 是一个 BigNumber。在 Viem 里,maxUint256 直接是一个 bigint 常量,类型不同,不能混用。

3. 发送交易:从 contract.function()writeContract

在 ethers.js 里,发送交易就像调用普通函数一样自然:

typescript 复制代码
// ethers.js
const tx = await contract.swapExactTokensForTokens(
  amountIn,
  amountOutMin,
  path,
  to,
  deadline,
  { gasLimit: 300000 }
);
await tx.wait();

Viem 里则必须通过 walletClient.writeContract 来发送,而且需要用户签名后才能拿到 txHash

typescript 复制代码
import { type Hash } from 'viem';

async function sendSwapTransaction(
  walletClient: WalletClient,
  publicClient: PublicClient,
  routerAddress: `0x${string}`,
  amountIn: bigint,
  amountOutMin: bigint,
  path: readonly [`0x${string}`, `0x${string}`],
  recipient: `0x${string}`,
  deadline: bigint,
  gasLimit: bigint
) {
  // 构造交易请求
  const hash: Hash = await walletClient.writeContract({
    address: routerAddress,
    abi: routerABI,
    functionName: 'swapExactTokensForTokens',
    args: [amountIn, amountOutMin, path, recipient, deadline],
    gas: gasLimit, // 注意这里用 gas 而不是 gasLimit
    chain: walletClient.chain, // 必须指定 chain
  });

  // 等待交易确认
  const receipt = await publicClient.waitForTransactionReceipt({
    hash,
    confirmations: 1, // 确认数
  });

  console.log('交易确认:', receipt.transactionHash);
  return receipt;
}

这里有个坑 :Viem 里 gas 参数的字段名是 gas,而不是 ethers.js 里的 gasLimit。我当时习惯性写了 gasLimit,结果 Viem 直接忽略了它,导致交易因为 gas 不足而回滚。排查了半小时才发现是字段名问题。

另一个坑是 chain 参数。如果你创建 walletClient 时指定了 chain,这里可以省略。但如果你在运行时需要切换链(比如用户从 Ethereum 切到 Polygon),必须在 writeContract 里显式传入 chain,否则 Viem 会用默认链,导致交易失败。

4. 监听事件:从 contract.onwatchContractEvent

项目里有个功能:监听 swap 事件来更新用户余额。ether.js 里用 contract.on("Swap", callback) 非常方便,但 Viem 里没有这种"持久化监听"方式。

Viem 提供了 watchContractEvent 函数,但它返回的是一个 unwatch 函数,需要手动管理生命周期:

typescript 复制代码
import { useCallback, useEffect, useRef } from 'react';
import { type Log } from 'viem';

function useSwapEvent(
  publicClient: PublicClient,
  routerAddress: `0x${string}`,
  userAddress: `0x${string}`
) {
  const unwatchRef = useRef<(() => void) | null>(null);

  const startWatching = useCallback(() => {
    // 清除旧监听
    if (unwatchRef.current) {
      unwatchRef.current();
    }

    // 开始监听
    const unwatch = publicClient.watchContractEvent({
      address: routerAddress,
      abi: routerABI,
      eventName: 'Swap',
      args: {
        // 只监听和当前用户相关的事件
        sender: userAddress,
      },
      onLogs: (logs: Log[]) => {
        logs.forEach((log) => {
          console.log('收到 swap 事件:', log);
          // 这里更新 UI
          updateUserBalance(userAddress);
        });
      },
    });

    unwatchRef.current = unwatch;
  }, [publicClient, routerAddress, userAddress]);

  const stopWatching = useCallback(() => {
    if (unwatchRef.current) {
      unwatchRef.current();
      unwatchRef.current = null;
    }
  }, []);

  // 组件挂载时开始监听,卸载时停止
  useEffect(() => {
    startWatching();
    return stopWatching;
  }, [startWatching, stopWatching]);

  return { startWatching, stopWatching };
}

这里有个坑watchContractEventargs 参数支持过滤,但必须和合约事件定义的参数名完全一致。我一开始写成 { from: userAddress },但合约事件里参数名是 sender,结果事件一个都没收到。Viem 不会报错,只是静默地不匹配。

另一个坑是性能问题。如果 onLogs 回调里做了大量计算或状态更新,可能会导致 UI 卡顿。建议在回调里只做轻量操作,或者用 useCallback + useRef 来避免不必要的重渲染。

5. 处理多链:从 provider.getNetworkpublicClient.chain

项目支持三条链,用户经常需要切换。在 ethers.js 里,我通过 provider.getNetwork() 获取当前链 ID,然后根据链 ID 切换 provider。

Viem 里更简洁:publicClient.chain 直接返回当前链的配置对象,包含 chain ID、name、nativeCurrency 等信息。

typescript 复制代码
import { type Chain } from 'viem';
import { mainnet, polygon, arbitrum } from 'viem/chains';

// 定义支持的链
const SUPPORTED_CHAINS: Chain[] = [mainnet, polygon, arbitrum];

function getChainById(chainId: number): Chain | undefined {
  return SUPPORTED_CHAINS.find((chain) => chain.id === chainId);
}

// 切换链时重新创建客户端
function createClientsForChain(chain: Chain, rpcUrl: string) {
  const publicClient = createPublicClient({
    chain,
    transport: http(rpcUrl),
  });

  const walletClient = createWalletClient({
    chain,
    transport: custom(window.ethereum!),
  });

  return { publicClient, walletClient };
}

注意这个细节 :Viem 的 Chain 类型包含了很多信息,比如 chain.nativeCurrency.symbol 可以直接用来显示代币符号,不需要硬编码。这在 ethers.js 里需要手动维护一个映射表。

完整代码

下面是一个完整的 React 组件示例,演示了如何使用 Viem 实现 swap 交易的全流程(模拟、估算 gas、发送、监听):

typescript 复制代码
// SwapComponent.tsx
import React, { useState, useCallback } from 'react';
import {
  createPublicClient,
  createWalletClient,
  custom,
  http,
  parseEther,
  formatEther,
  maxUint256,
  type Hash,
  type PublicClient,
  type WalletClient,
} from 'viem';
import { mainnet } from 'viem/chains';

// 假设的 Uniswap V2 Router ABI(简化版)
const routerABI = [
  {
    inputs: [
      { name: 'amountIn', type: 'uint256' },
      { name: 'amountOutMin', type: 'uint256' },
      { name: 'path', type: 'address[]' },
      { name: 'to', type: 'address' },
      { name: 'deadline', type: 'uint256' },
    ],
    name: 'swapExactTokensForTokens',
    outputs: [{ name: 'amounts', type: 'uint256[]' }],
    stateMutability: 'nonpayable',
    type: 'function',
  },
  {
    anonymous: false,
    inputs: [
      { indexed: true, name: 'sender', type: 'address' },
      { indexed: true, name: 'to', type: 'address' },
      { indexed: false, name: 'amount0In', type: 'uint256' },
      { indexed: false, name: 'amount1In', type: 'uint256' },
      { indexed: false, name: 'amount0Out', type: 'uint256' },
      { indexed: false, name: 'amount1Out', type: 'uint256' },
    ],
    name: 'Swap',
    type: 'event',
  },
];

const ROUTER_ADDRESS = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'; // Uniswap V2 Router

export default function SwapComponent() {
  const [txHash, setTxHash] = useState<Hash | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // 初始化客户端
  const publicClient = createPublicClient({
    chain: mainnet,
    transport: http(process.env.NEXT_PUBLIC_MAINNET_RPC_URL),
  });

  const walletClient = createWalletClient({
    chain: mainnet,
    transport: custom(window.ethereum!),
  });

  const handleSwap = useCallback(async () => {
    setIsLoading(true);
    setError(null);

    try {
      // 获取用户地址
      const [address] = await walletClient.requestAddresses();
      if (!address) throw new Error('未连接钱包');

      // 模拟交易
      const { request } = await publicClient.simulateContract({
        address: ROUTER_ADDRESS,
        abi: routerABI,
        functionName: 'swapExactTokensForTokens',
        args: [
          parseEther('0.1'), // 假设输入 0.1 ETH
          0n, // amountOutMin 设为 0
          ['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7'], // WETH -> USDT
          address,
          maxUint256,
        ],
        account: address,
      });

      // 估算 gas
      const gasEstimate = await publicClient.estimateContractGas(request);
      const safeGasLimit = (gasEstimate * 130n) / 100n; // 加 30% 安全系数

      // 发送交易
      const hash: Hash = await walletClient.writeContract({
        ...request,
        gas: safeGasLimit,
      });

      setTxHash(hash);

      // 等待确认
      const receipt = await publicClient.waitForTransactionReceipt({
        hash,
        confirmations: 1,
      });

      console.log('交易成功:', receipt.transactionHash);
    } catch (err: any) {
      setError(err.message || '交易失败');
      console.error('Swap 错误:', err);
    } finally {
      setIsLoading(false);
    }
  }, [publicClient, walletClient]);

  return (
    <div>
      <button onClick={handleSwap} disabled={isLoading}>
        {isLoading ? '交易中...' : 'Swap 0.1 ETH -> USDT'}
      </button>
      {txHash && <p>交易哈希: {txHash}</p>}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录

  1. estimateContractGas 需要 request 对象,而不是直接传参数

    • 报错:TypeError: Cannot read properties of undefined (reading 'abi')
    • 解决方法:先调用 simulateContract 拿到 request,再传给 estimateContractGas
  2. watchContractEventargs 参数名必须和合约 ABI 完全一致

    • 报错:事件监听没任何回调触发,也不报错
    • 解决方法:仔细对比合约事件定义中的参数名,大小写和命名都要一致
  3. writeContractgas 参数在 Viem 里是 bigint,不是 number

    • 报错:TypeError: Cannot convert a BigInt value to a number
    • 解决方法:确保所有 gas 相关值都是 bigint 类型,用 n 后缀或 BigInt() 转换
  4. 切换链后必须重新创建 walletClient,否则 chain 信息会过时

    • 报错:交易发送后提示 chain ID 不匹配
    • 解决方法:在 window.ethereum.on('chainChanged') 回调里销毁旧客户端并创建新客户端

小结

从 ethers.js 迁移到 Viem 的过程,本质上是从"魔法方法"转向"显式操作" 。Viem 没有隐藏任何复杂性,所有步骤都需要你明确指定。虽然初期学习曲线陡峭,但换来的是更好的类型安全、更小的包体积和更清晰的代码逻辑。如果你正在迁移,建议从 simulateContractwriteContract 这两个核心函数入手,理解它们如何替代 ethers.js 的 contract.function()

相关推荐
默_笙2 小时前
⭐ 从写 Prompt 到搭上下文:AI 终于不再对我"正确的胡说八道"了
javascript
用户298698530142 小时前
如何在 JavaScript 和 React 中下载/导出 Excel 文件
前端·javascript·react.js
你怎么知道我是队长2 小时前
JavaScript的介绍
开发语言·javascript·ecmascript
触底反弹2 小时前
🔥 50 行代码,我手写了一个 MCP Server 给 Claude 用!
javascript·人工智能·程序员
大流星2 小时前
LangChainJs之Prompt提示词(二)
javascript·langchain
何时梦醒2 小时前
前端存储全攻略 & JavaScript this 绑定完全指南
前端·javascript·面试
前端百草阁2 小时前
JavaScript 设计模式(23 种)
开发语言·前端·javascript·设计模式
晓得迷路了3 小时前
栗子前端技术周刊第 136 期 - pnpr、Flow 重构移植 Rust、Astryx...
前端·javascript·css
Mr.Daozhi3 小时前
从零构建 AI 学术论文助手(四):PDF.js 实时截图 + Gemini 视觉分析
javascript·人工智能·pdf·canvas·pdf.js·gemini