从ethers.js迁移到Viem:我在DeFi Dashboard项目中踩过的坑与最终方案

背景

上个月,老板扔给我一个"历史遗留"的DeFi Dashboard项目,说是要加几个新功能。我打开代码一看,好家伙,ethers.js v5,没有TypeScript严格模式,合约调用全是 any 类型,交易状态管理靠手动 setInterval 轮询。最要命的是,用户反馈说在某个链上查余额总是超时,我排查了半天,发现是ethers.js的Provider连接池管理有问题。

我当时就想,与其修修补补,不如直接用Viem重写这部分。Viem是Wagmi团队的新作品,TypeScript原生支持,API设计更现代。但迁移过程远比我想象的复杂------Viem的 PublicClientWalletClient 跟ethers.js的 ProviderSigner 完全是两套逻辑。这篇文章就是我踩坑两天的完整记录。

问题分析

我的最初思路很简单:把 new ethers.providers.JsonRpcProvider(rpcUrl) 替换成 createPublicClient,把 signer.connect(provider) 替换成 createWalletClient。结果一跑就报错------Viem不直接支持EIP-1193之外的Provider格式。

更坑的是,ethers.js里 contract.connect(signer) 那种链式调用,在Viem里完全不存在。Viem要求你把钱包客户端和合约配置分开传。我当时就懵了:难道要每个合约调用都重复传参?

排查后发现,Viem的哲学是"显式优于隐式"。它不搞 provider 那种全局状态,而是让你明确指定每一步用哪个客户端。这虽然更安全,但代码结构需要大改。

核心实现

第一步:创建Viem客户端,告别Provider全局变量

在ethers.js里,我习惯这样写:

typescript 复制代码
// ethers.js 旧代码
const provider = new ethers.providers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/xxx');
const signer = new ethers.Wallet('privateKey', provider);

Viem里可不行。你必须区分"只读"的公共客户端和"可写"的钱包客户端:

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

// 公共客户端:只用来查链上数据
export const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});

// 钱包客户端:用来发交易,需要钱包适配器
export const walletClient = createWalletClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});

这里有个坑 :如果你直接用 createWalletClient 并传了 account 参数,那它就是"本地签名"模式。但在前端项目里,通常我们用的是浏览器钱包(如MetaMask),所以不传 account,而是在调用时动态传入。

第二步:合约交互------从Contract对象到直接调用

ethers.js里,合约交互是这样的:

typescript 复制代码
const contract = new ethers.Contract(address, abi, signer);
const balance = await contract.balanceOf(userAddress);

Viem完全不一样。它没有 Contract 类,而是通过 readContractwriteContract 方法直接调用:

typescript 复制代码
import { getContract, parseAbi } from 'viem';

const abi = parseAbi([
  'function balanceOf(address owner) view returns (uint256)',
  'function transfer(address to, uint256 amount) returns (bool)',
]);

// 读操作
const balance = await publicClient.readContract({
  address: '0x...',
  abi,
  functionName: 'balanceOf',
  args: [userAddress],
});

// 写操作
const hash = await walletClient.writeContract({
  address: '0x...',
  abi,
  functionName: 'transfer',
  args: [toAddress, amount],
  account: userAddress, // 必须显式传入
});

注意这个细节 :Viem的 readContract 返回的是 bigint 而不是 BigNumber。我当时没注意,直接拿去渲染UI,结果发现数字显示不对。后来用 formatUnits 转换才解决。

第三步:交易状态监听------从轮询到等待确认

ethers.js里监听交易确认,我原来是这样写的:

typescript 复制代码
const tx = await contract.transfer(to, amount);
const receipt = await tx.wait(1); // 等1个确认

Viem提供了更细粒度的控制:

typescript 复制代码
import { waitForTransactionReceipt } from 'viem/actions';

// 发送交易
const hash = await walletClient.sendTransaction({
  to: '0x...',
  value: parseEther('0.1'),
  account: userAddress,
});

// 等待确认
const receipt = await waitForTransactionReceipt(publicClient, {
  hash,
  confirmations: 1,
  pollingInterval: 1000, // 每秒轮询一次
});

console.log('交易确认,区块号:', receipt.blockNumber);

这里有个坑waitForTransactionReceipt 默认只等一个确认,如果你需要多个确认,必须显式传 confirmations。而且它返回的 receipt 类型是 TransactionReceipt,比ethers.js的 TransactionReceipt 字段名有差异------Viem里是 blockNumber 而不是 blockNumber(注意大小写,Viem是驼峰)。

第四步:多链支持------从手动切RPC到链配置

原来的项目支持以太坊和Polygon,代码里是:

typescript 复制代码
const providers = {
  ethereum: new ethers.providers.JsonRpcProvider(rpcEthereum),
  polygon: new ethers.providers.JsonRpcProvider(rpcPolygon),
};

Viem把链信息封装成了对象,切换起来更优雅:

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

export function getClients(chainId: number) {
  const chain = chainId === 1 ? mainnet : polygon;
  const rpcUrl = chainId === 1 ? 'https://eth-mainnet.g.alchemy.com/v2/xxx' : 'https://polygon-mainnet.g.alchemy.com/v2/xxx';

  return {
    publicClient: createPublicClient({ chain, transport: http(rpcUrl) }),
    walletClient: createWalletClient({ chain, transport: http(rpcUrl) }),
  };
}

使用的时候:

typescript 复制代码
const { publicClient } = getClients(1);
const balance = await publicClient.readContract(...);

注意这个细节 :Viem的 chain 对象里包含了原生币信息、区块浏览器URL等,如果你要用这些元数据,可以直接从 chain.nativeCurrency 获取,不用自己维护配置表了。

完整代码

下面是一个可直接运行的React组件示例,它用Viem读取用户ERC20余额,并支持发送交易:

typescript 复制代码
// ViemDeFiComponent.tsx
import React, { useState, useEffect } from 'react';
import { createPublicClient, createWalletClient, http, parseErc20Abi, formatUnits, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { waitForTransactionReceipt } from 'viem/actions';

// 配置公共客户端
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'), // 替换为你的API key
});

const TOKEN_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; // USDT合约地址

interface Props {
  userAddress: `0x${string}`;
}

export default function ViemDeFiComponent({ userAddress }: Props) {
  const [balance, setBalance] = useState<string>('0');
  const [txHash, setTxHash] = useState<string>('');
  const [isLoading, setIsLoading] = useState(false);

  // 读取余额
  useEffect(() => {
    async function fetchBalance() {
      try {
        const rawBalance = await publicClient.readContract({
          address: TOKEN_ADDRESS,
          abi: parseErc20Abi,
          functionName: 'balanceOf',
          args: [userAddress],
        });
        // 注意:rawBalance是bigint,需要格式化
        setBalance(formatUnits(rawBalance, 6)); // USDT是6位小数
      } catch (error) {
        console.error('读取余额失败:', error);
      }
    }
    fetchBalance();
  }, [userAddress]);

  // 发送交易(需要钱包连接)
  async function handleSendTransaction() {
    setIsLoading(true);
    try {
      // 假设用户已连接钱包,我们通过window.ethereum获取账户
      const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
      
      const walletClient = createWalletClient({
        chain: mainnet,
        transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
      });

      // 发送ETH
      const hash = await walletClient.sendTransaction({
        to: '0x...', // 替换为接收地址
        value: parseEther('0.01'),
        account: account as `0x${string}`,
      });

      setTxHash(hash);

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

      console.log('交易确认,消耗gas:', receipt.gasUsed);
    } catch (error) {
      console.error('交易失败:', error);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div>
      <h2>USDT余额: {balance}</h2>
      <button onClick={handleSendTransaction} disabled={isLoading}>
        {isLoading ? '交易进行中...' : '发送0.01 ETH'}
      </button>
      {txHash && <p>交易哈希: {txHash}</p>}
    </div>
  );
}

注意 :上面的 parseErc20Abi 是Viem内置的,它只包含 balanceOftotalSupplytransfer 等标准ERC20方法。如果你需要自定义方法,得用 parseAbi 自己写。

踩坑记录

  1. window.ethereum 类型问题 :Viem要求地址类型是 0x${string},但 window.ethereum.request 返回的是 string[]。我一开始直接 as 断言,结果类型检查没过。后来用 account as \0x${string}`` 才解决。

  2. formatUnits 的精度丢失 :用 formatUnits(1000000n, 6) 返回 "1" 没问题,但 formatUnits(123456789n, 6) 返回 "123.456789",如果直接渲染到UI会显示很多小数位。我后来用 Number(balance).toFixed(2) 做二次格式化。

  3. waitForTransactionReceipt 超时 :默认超时是30秒,如果网络拥堵,交易一直pending,会抛异常。我加了 maxWaitTime 参数:waitForTransactionReceipt(publicClient, { hash, maxWaitTime: 120_000 })

  4. http 传输的速率限制 :我在测试时频繁调用 readContract,结果被RPC限流了。Viem支持轮询,但默认没有内置重试逻辑。我后来用 viem/actionsgetBlockNumber 配合 setInterval 手动控制频率。

小结

Viem的核心优势是类型安全和显式API设计,迁移成本主要在理解"客户端分离"的理念。如果你也在做迁移,建议先把公共客户端和钱包客户端拆清楚,再逐个合约替换。想继续深挖的话,可以研究Viem的 Actions 架构,它能让你自定义交易流程,比ethers.js灵活得多。

相关推荐
zithern_juejin3 小时前
ES6——Promise
javascript
桜吹雪4 小时前
所有智能体架构(1):反思 (Reflection)
javascript·人工智能
ZC跨境爬虫5 小时前
跟着 MDN 学 HTML day_61:(构建反馈表单的结构化挑战)
前端·javascript·ui·html·音视频
豹哥学前端6 小时前
JavaScript 异步编程完全指南:从回调地狱到 async/await,一次通关
前端·javascript·面试
kyriewen6 小时前
面试官让我手写Promise,我打开Cursor三秒生成,他愣了两秒说“你过了”
前端·javascript·面试
软件开发技术深度爱好者6 小时前
HTML实现DOCX文档版题库图文考试系统(修订)
前端·javascript·html
问征夫以前路6 小时前
Promise知识点回顾
前端·javascript
行走的陀螺仪6 小时前
JavaScript 算法详解:10大经典算法,通俗易懂,从入门到精通
开发语言·javascript·算法
yqcoder7 小时前
异步的魔法:深入解析 async/await 原理与编译本质
前端·javascript