从ethers.js迁移到Viem:我在一个DeFi项目前端重构中踩过的坑

背景

上个月我接手了一个老牌的DeFi收益聚合器项目的前端维护工作。这个项目大概两年前开发的,前端核心库用的是 ethers.js v5,配合着一些自定义的 Provider 封装和事件轮询逻辑。刚开始只是修几个小 bug,但当我需要添加对新链(比如 Arbitrum)的支持时,问题就来了。

老代码里到处都是 new ethers.providers.JsonRpcProvider() 的硬编码,钱包连接逻辑和业务逻辑耦合得很深,添加一个新链得改七八个文件。更头疼的是,项目里有些自定义的 BigNumber 处理逻辑在 ethers.js v6 里已经不兼容了,升级版本风险太大。就在我纠结是硬着头皮重构老代码,还是找个新方案时,团队里另一个在做新项目的同事提到了 Viem,说它类型安全、模块化,而且和 Wagmi 搭配起来开发效率很高。我研究了一下,决定拿一个相对独立的功能模块------用户质押和领取奖励的页面------作为"试验田",尝试用 Viem 彻底替换掉 ethers.js。

问题分析

我选择的功能模块主要做三件事:

  1. 读取用户在当前链上的质押余额和待领取奖励。
  2. 让用户进行质押(调用合约的 stake 方法)。
  3. 让用户领取奖励(调用合约的 claimRewards 方法)。

ethers.js 的老代码大概是这样的骨架:

javascript 复制代码
import { ethers } from 'ethers';
import stakingABI from './abis/staking.json';

const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const stakingContract = new ethers.Contract(STAKING_ADDRESS, stakingABI, signer);

// 读取数据
const userBalance = await stakingContract.balanceOf(userAddress);
const pendingRewards = await stakingContract.earned(userAddress);

// 发送交易
const stakeTx = await stakingContract.stake(amount);
await stakeTx.wait();

思路很直接,但问题也很明显:Provider 和 Signer 的创建与钱包状态绑定死,ABI 管理松散,错误处理简陋。我的迁移目标很明确:用 Viem 的 PublicClientWalletClient 来分离读和写,用 TypeScript 生成强类型的合约接口,并整合进现有的 React 上下文里。

一开始我以为就是简单的 API 替换,但真正动手才发现,从"面向对象"的 ethers.js 思维切换到"函数式"的 Viem 思维,以及处理两者在数据类型(尤其是 BigNumberbigint)上的差异,才是真正的挑战。

核心实现

第一步:搭建 Viem 客户端与替换读取逻辑

首先,我安装了必要的包:viem@wagmi/core(为了复用项目已有的 Wagmi 配置)。我的策略是,先不碰钱包连接和交易发送,只把数据读取的部分换掉。

在 ethers.js 里,一个 Provider 既负责读也负责写(通过 Signer)。Viem 则明确分成了 PublicClient(读)和 WalletClient(写)。我创建了一个公共的读取客户端:

typescript 复制代码
// src/lib/viemClient.ts
import { createPublicClient, http, PublicClient } from 'viem';
import { mainnet, arbitrum } from 'viem/chains'; // 从老配置里拿到链信息

// 根据当前链ID创建对应的客户端
export function getPublicClient(chainId: number): PublicClient {
  const chain = [mainnet, arbitrum].find(c => c.id === chainId) || mainnet;
  return createPublicClient({
    chain,
    transport: http(), // 这里先用公开RPC,后面可以替换成项目自己的节点
  });
}

接下来是重头戏:合约调用。我不想再手动管理 ABI JSON 文件了。Viem 鼓励使用 @wagmi/cliabitype 来生成类型。我用了更直接的方式,利用 Viem 的 createContractFunctionArgs 思路,手动为我的质押合约定义了一个类型化的"读"对象。这里有个 :Viem 的合约函数返回的数值类型默认是 bigint,而我的前端界面渲染逻辑到处都在用 ethers.utils.formatUnits 来处理 BigNumber。我必须统一处理这个转换。

typescript 复制代码
// src/contracts/stakingContract.ts
import { getPublicClient } from '@/lib/viemClient';
import stakingABI from './abis/staking.json' assert { type: 'json' }; // 暂时沿用老ABI

export const STAKING_ADDRESS = '0x...'; // 合约地址

// 封装一个类型安全的读取函数
export async function getUserStakingInfo(userAddress: `0x${string}`, chainId: number) {
  const publicClient = getPublicClient(chainId);

  // 注意:这里返回的是 bigint
  const [balance, rewards] = await Promise.all([
    publicClient.readContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'balanceOf',
      args: [userAddress],
    }) as Promise<bigint>,
    publicClient.readContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'earned',
      args: [userAddress],
    }) as Promise<bigint>,
  ]);

  // 统一转换:bigint -> 格式化的字符串(这里假设代币精度为18)
  const formatBigInt = (value: bigint) => Number(value) / 10**18; // 简单处理,生产环境建议用库
  return {
    balance: formatBigInt(balance),
    pendingRewards: formatBigInt(rewards),
  };
}

在 React 组件里,我就可以把老的 ethers 调用替换成:

typescript 复制代码
// 老代码
// const balance = await stakingContract.balanceOf(address);

// 新代码
const { balance, pendingRewards } = await getUserStakingInfo(address, chainId);

第一步很顺利,界面数据显示正常。这给了我很大信心。

第二步:处理钱包连接与交易发送

这是最核心也最容易出错的部分。在 ethers.js 里,我们从 window.ethereum 创建 Provider,然后 getSigner()。Viem 的 WalletClient 概念类似,但创建方式更多样。我选择与项目已有的 Wagmi 连接器集成,通过 Wagmi 的 useAccountuseWalletClient 钩子来获取。

这里有个关键细节 :Viem 的 writeContract 方法返回的是交易哈希(0x${string}),而不是一个像 ethers.js 那样的交易对象(包含 wait 方法)。你需要用 PublicClientwaitForTransactionReceipt 来等待交易确认。

typescript 复制代码
// src/hooks/useStakingAction.ts
import { useAccount, useWalletClient } from 'wagmi';
import { getPublicClient } from '@/lib/viemClient';
import { STAKING_ADDRESS, stakingABI } from '@/contracts/stakingContract';

export function useStakingAction() {
  const { address, chainId } = useAccount();
  const { data: walletClient } = useWalletClient();

  const stake = async (amount: bigint) => {
    if (!walletClient || !address) throw new Error('钱包未连接');

    try {
      // 1. 发送交易,获取哈希
      const hash = await walletClient.writeContract({
        address: STAKING_ADDRESS,
        abi: stakingABI,
        functionName: 'stake',
        args: [amount],
        account: address,
      });
      console.log('交易哈希:', hash);

      // 2. 等待交易确认
      const publicClient = getPublicClient(chainId!);
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      console.log('交易确认,区块号:', receipt.blockNumber);
      return receipt;
    } catch (error) {
      console.error('质押失败:', error);
      // 这里可以细化错误处理,比如用户拒绝、gas不足等
      throw error;
    }
  };

  const claimRewards = async () => {
    // 逻辑类似,调用 `claimRewards` 函数
    if (!walletClient || !address) throw new Error('钱包未连接');
    const hash = await walletClient.writeContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'claimRewards',
      account: address,
    });
    const publicClient = getPublicClient(chainId!);
    return await publicClient.waitForTransactionReceipt({ hash });
  };

  return { stake, claimRewards };
}

在组件中使用就非常清晰了:

tsx 复制代码
const StakingButton: React.FC = () => {
  const [amount, setAmount] = useState('');
  const { stake } = useStakingAction();
  const handleStake = async () => {
    const amountInWei = BigInt(parseFloat(amount) * 10**18); // 转换精度
    await stake(amountInWei);
    // ... 成功后刷新数据
  };
  return <button onClick={handleStake}>质押</button>;
};

第三步:集成与错误边界处理

替换了核心逻辑后,我需要把新的 Viem 客户端集成到项目的上下文中,并处理好可能出现的错误。我创建了一个 ViemProvider 上下文,用来在不同的组件中共享 PublicClient 和合约方法。

另外,我遇到了一个非常实际的坑 :合约事件监听。老代码用 ethers.Contracton 方法监听事件来更新 UI。Viem 提供了 watchContractEvent,但它的用法是函数式的,返回一个取消监听的函数,并且需要自己管理生命周期。

typescript 复制代码
// 在组件或Hook中监听质押事件
useEffect(() => {
  if (!address || !chainId) return;

  const publicClient = getPublicClient(chainId);
  const unwatch = publicClient.watchContractEvent({
    address: STAKING_ADDRESS,
    abi: stakingABI,
    eventName: 'Staked',
    args: { user: address }, // 只监听当前用户的事件
    onLogs: (logs) => {
      console.log('新的质押事件:', logs);
      // 触发UI数据更新
      refetchUserInfo();
    },
    onError: (error) => {
      console.error('监听事件出错:', error);
    }
  });

  // 组件卸载时取消监听
  return () => unwatch();
}, [address, chainId]);

完整代码示例

以下是一个简化但可运行的 React 组件,展示了如何使用我们上面封装的逻辑:

tsx 复制代码
// src/components/StakingPanel.tsx
import React, { useState, useEffect } from 'react';
import { useAccount } from 'wagmi';
import { getUserStakingInfo } from '@/contracts/stakingContract';
import { useStakingAction } from '@/hooks/useStakingAction';

const StakingPanel: React.FC = () => {
  const { address, chainId } = useAccount();
  const { stake, claimRewards } = useStakingAction();
  const [userInfo, setUserInfo] = useState({ balance: 0, pendingRewards: 0 });
  const [stakeAmount, setStakeAmount] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  // 加载用户数据
  const loadUserInfo = async () => {
    if (!address || !chainId) return;
    setIsLoading(true);
    try {
      const info = await getUserStakingInfo(address, chainId);
      setUserInfo(info);
    } catch (error) {
      console.error('加载数据失败:', error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    loadUserInfo();
  }, [address, chainId]);

  const handleStake = async () => {
    if (!stakeAmount) return;
    setIsLoading(true);
    try {
      const amountInWei = BigInt(Math.floor(parseFloat(stakeAmount) * 10**18));
      await stake(amountInWei);
      setStakeAmount('');
      await loadUserInfo(); // 刷新数据
      alert('质押成功!');
    } catch (error: any) {
      alert(`质押失败: ${error?.shortMessage || error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  const handleClaim = async () => {
    setIsLoading(true);
    try {
      await claimRewards();
      await loadUserInfo();
      alert('领取成功!');
    } catch (error: any) {
      alert(`领取失败: ${error?.shortMessage || error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <h2>我的质押</h2>
      {isLoading && <p>加载中...</p>}
      <p>质押余额: {userInfo.balance}</p>
      <p>待领取奖励: {userInfo.pendingRewards}</p>

      <div>
        <input
          type="number"
          value={stakeAmount}
          onChange={(e) => setStakeAmount(e.target.value)}
          placeholder="输入质押数量"
          disabled={isLoading}
        />
        <button onClick={handleStake} disabled={isLoading}>
          质押
        </button>
      </div>

      <button onClick={handleClaim} disabled={isLoading || userInfo.pendingRewards <= 0}>
        领取奖励
      </button>
    </div>
  );
};

export default StakingPanel;

踩坑记录

  1. bigint 序列化错误(JSON.stringify) :这是第一个拦路虎。当我将从 Viem 合约调用中获取的 bigint 类型的状态直接放入 React 状态或传递给 JSON.stringify 时,控制台会报错"Do not know how to serialize a BigInt"。解决方法 :在数据层(如 getUserStakingInfo 函数中)就将其转换为 numberstring。对于大数,可以使用 viem 自带的 formatUnits 函数或转换为字符串 value.toString()

  2. 钱包客户端(WalletClient)获取为 undefined :在 useStakingAction 钩子中,useWalletClient() 返回的 data 可能为 undefined,尤其是在钱包连接初始状态或切换链时。解决方法:增加严格的空值检查,并在 UI 上给出明确的禁用状态或提示。确保 Wagmi 配置正确,连接器支持当前链。

  3. 事件监听内存泄漏 :最初我在组件中直接调用 watchContractEvent 而没有在 useEffect 中返回清理函数,导致组件卸载后监听依然存在,控制台会有警告,并可能引发状态更新错误。解决方法 :严格遵守 useEffect 的生命周期,将 watchContractEvent 返回的 unwatch 函数在清理阶段调用。

  4. 交易模拟错误信息不直观walletClient.writeContract 失败时,抛出的错误对象有时很深,直接 error.message 可能是一串复杂的 RPC 错误。解决方法 :利用 Viem 错误工具类,如 parseContractError(在较新版本中)或 decodeErrorResult 来解析。在实践中,我发现 error.shortMessageerror.details 通常包含了可读性更强的信息,可以优先展示给用户。

小结

这次迁移就像给老房子换了一套更现代化的水电管道,过程有点折腾,但完成后维护性和扩展性肉眼可见地提升了。Viem 的函数式、类型安全设计,强迫我写出更清晰、解耦的代码。最大的收获不是学会了一个新库的 API,而是理解了如何用"客户端分离"和"类型优先"的思想来构建更健壮的 Web3 前端。下一步,我打算用 @wagmi/cli 来自动生成所有合约的完整类型化接口,彻底告别手写 ABI 导入的日子。

相关推荐
像我这样帅的人丶你还2 小时前
从交稿到甩锅预防:AI 前端流水线
前端·ai编程
想想弹幕会怎么做2 小时前
如何构建一颗可交互的ui树?
前端
程序员陆业聪2 小时前
我见过的最反直觉的 Android 架构问题:UseCase 越多,项目越烂
前端
Arya_aa2 小时前
网络:前端向后端发送网络请求渲染在页面上,将EasyMock中的信息用前端vue框架编写代码,最终展示在浏览器
前端·vue.js
LlNingyu2 小时前
文艺复兴,什么是CSRF,常见形式(一)
前端·安全·web安全·csrf
晓13132 小时前
React篇——第三章 状态管理之 Redux 篇
前端·javascript·react.js
子兮曰2 小时前
🚀24k Star 的 Pretext 为何突然爆火:它不是排版库,而是在重写 Web 文本测量
前端·javascript·github
@大迁世界2 小时前
11.在 React.js 中,state 与 props 的差异体现在哪里?
前端·javascript·react.js·前端框架·ecmascript
Giant1003 小时前
🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!
前端·javascript·面试