从ethers.js迁移到Viem:我在重构DeFi前端时踩过的那些坑

背景

我负责维护一个已经运行两年的DeFi项目前端,技术栈是React + TypeScript + ethers.js 5.7。最近在做性能优化时发现,打包后的bundle size比同类项目大了近30%,经过分析,ethers.js占了相当大的比重。同时,项目中的一些复杂类型定义在ethers.js下显得很冗长,类型提示也不够友好。

团队讨论后决定尝试迁移到Viem。Viem是较新的以太坊JavaScript库,以类型安全、模块化、轻量化为特点。但迁移一个生产环境项目不是简单的替换import语句,我需要在保证现有功能完全正常的前提下完成迁移。

问题分析

最初我以为迁移就是换个库,把ethers.providers.Web3Provider换成viem/createWalletClient就行了。但实际一开始就遇到了问题:

  1. 类型系统完全不同:ethers.js使用自己的BigNumber类型,而Viem直接使用原生bigint
  2. 事件监听机制差异:ethers.js的合约事件监听和Viem的watchContractEvent参数结构完全不同
  3. 多链支持方式不同:我们项目支持Ethereum、Polygon、Arbitrum三条链,ethers.js通过Network对象管理,Viem有自己的一套链定义

最头疼的是,项目中有上百处以太坊交互代码,分布在组件、hooks、工具函数中,不可能一次性全部重写。我需要一个渐进式的迁移方案。

核心实现

第一步:搭建双库共存环境

我决定先让两个库共存,逐步迁移模块。首先安装必要的Viem包:

bash 复制代码
npm install viem wagmi

然后创建了一个lib/viem-client.ts文件,初始化基础客户端:

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

// 根据链ID获取对应的Viem链配置
export function getChainConfig(chainId: number) {
  switch (chainId) {
    case 1: return mainnet
    case 137: return polygon
    case 42161: return arbitrum
    default: return mainnet
  }
}

// 创建公共客户端(用于读取数据)
export function createViemPublicClient(chainId: number) {
  const chain = getChainConfig(chainId)
  const transport = http(process.env.NEXT_PUBLIC_RPC_URL)
  
  return createPublicClient({
    chain,
    transport,
  })
}

// 这里有个坑:Viem的链配置需要和你的项目实际使用的RPC节点匹配
// 如果RPC节点不支持某些方法,需要在transport中配置

同时,我保留了现有的ethers.js代码,只是在新写的功能中使用Viem。

第二步:处理BigNumber类型转换

这是迁移中最频繁遇到的问题。我们的项目中有大量的金额计算、余额显示逻辑,原来都使用ethers.js的BigNumber。

我创建了一个转换工具函数:

typescript 复制代码
import { BigNumber } from 'ethers'
import { formatUnits, parseUnits } from 'viem'

/**
 * 将ethers.js的BigNumber转换为Viem兼容的bigint
 * 注意:这里要处理undefined和null的情况
 */
export function bigNumberToBigInt(value?: BigNumber): bigint {
  if (!value) return 0n
  return BigInt(value.toString())
}

/**
 * 将Viem的bigint转换回ethers.js的BigNumber(用于过渡期)
 */
export function bigIntToBigNumber(value: bigint): BigNumber {
  return BigNumber.from(value.toString())
}

/**
 * 统一格式化显示金额
 * 原来用ethers.utils.formatUnits,现在用viem的formatUnits
 * 注意:viem的formatUnits返回string,而ethers返回string
 */
export function formatTokenAmount(
  amount: bigint | BigNumber,
  decimals: number
): string {
  const amountBigInt = amount instanceof BigNumber 
    ? bigNumberToBigInt(amount)
    : amount
  
  return formatUnits(amountBigInt, decimals)
}

第三步:重写合约交互层

我们项目中有几十个合约交互的hooks,这是迁移的重点。我选择从最常用的ERC20代币合约开始。

原来的ethers.js版本:

typescript 复制代码
// 旧的ERC20 Hook (ethers.js)
import { Contract } from 'ethers'
import ERC20_ABI from '../abis/ERC20.json'

export function useERC20(contractAddress: string, signer: any) {
  const contract = new Contract(contractAddress, ERC20_ABI, signer)
  
  const getBalance = async (account: string) => {
    return await contract.balanceOf(account)
  }
  
  const transfer = async (to: string, amount: BigNumber) => {
    const tx = await contract.transfer(to, amount)
    return await tx.wait()
  }
  
  return { getBalance, transfer }
}

迁移到Viem的版本:

typescript 复制代码
// 新的ERC20 Hook (Viem)
import { createPublicClient, createWalletClient, custom, http } from 'viem'
import { mainnet } from 'viem/chains'
import { useAccount, useWalletClient } from 'wagmi'

// 注意:Viem需要更精确的ABI类型,不能直接用JSON ABI
import { erc20Abi } from 'viem'
import { usePublicClient } from 'wagmi'

export function useViemERC20(contractAddress: `0x${string}`) {
  const { address } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const getBalance = async (account?: `0x${string}`) => {
    if (!publicClient) throw new Error('No public client')
    
    const balance = await publicClient.readContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: [account || address!],
    })
    
    return balance as bigint
  }
  
  const transfer = async (to: `0x${string}`, amount: bigint) => {
    if (!walletClient || !address) throw new Error('Wallet not connected')
    
    const hash = await walletClient.writeContract({
      address: contractAddress,
      abi: erc20Abi,
      functionName: 'transfer',
      args: [to, amount],
      account: address,
    })
    
    // 等待交易确认
    const receipt = await publicClient.waitForTransactionReceipt({ hash })
    return receipt
  }
  
  return { getBalance, transfer }
}

这里有个重要的坑 :Viem要求地址必须是0x${string}类型,而不是普通的string。这意味着所有合约地址、用户地址都需要进行类型转换。我创建了一个类型守卫函数:

typescript 复制代码
export function isValidAddress(address: string): address is `0x${string}` {
  return /^0x[a-fA-F0-9]{40}$/.test(address)
}

export function toViemAddress(address: string): `0x${string}` {
  if (!isValidAddress(address)) {
    throw new Error(`Invalid address format: ${address}`)
  }
  return address as `0x${string}`
}

第四步:处理事件监听

我们项目中有很多实时数据更新依赖于合约事件。ethers.js的事件监听和Viem完全不同。

原来的事件监听:

typescript 复制代码
// ethers.js 事件监听
contract.on('Transfer', (from, to, amount, event) => {
  console.log('Transfer event:', { from, to, amount })
  updateUI()
})

迁移到Viem的事件监听:

typescript 复制代码
// Viem 事件监听
import { watchContractEvent } from 'viem'

const unwatch = watchContractEvent({
  address: contractAddress,
  abi: erc20Abi,
  eventName: 'Transfer',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log('Transfer event:', {
        from: args.from,
        to: args.to,
        amount: args.value
      })
      updateUI()
    })
  },
})

// 注意:Viem的watchContractEvent返回一个取消监听的函数
// 在React组件中需要在useEffect中清理
useEffect(() => {
  const unwatch = watchContractEvent({ ... })
  
  return () => {
    unwatch()
  }
}, [])

这里踩了个坑 :Viem的事件参数args可能是undefined,需要做安全处理:

typescript 复制代码
onLogs: (logs) => {
  logs.forEach((log) => {
    if (!log.args) return
    
    const { from, to, value } = log.args
    // 现在from, to, value都是可选的,需要类型断言
    if (from && to && value) {
      // 处理事件
    }
  })
}

第五步:集成Wagmi管理状态

为了更好的React集成,我引入了Wagmi。Wagmi是基于Viem的React Hooks库,类似于ethers.js的useDapp或web3-react。

配置Wagmi:

typescript 复制代码
// lib/wagmi-config.ts
import { createConfig, configureChains } from 'wagmi'
import { publicProvider } from 'wagmi/providers/public'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { InjectedConnector } from 'wagmi/connectors/injected'
import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [publicProvider()]
)

export const config = createConfig({
  autoConnect: true,
  connectors: [
    new InjectedConnector({ chains }),
    new WalletConnectConnector({
      chains,
      options: {
        projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!,
      },
    }),
  ],
  publicClient,
  webSocketPublicClient,
})

然后在App中包裹WagmiProvider:

tsx 复制代码
import { WagmiConfig } from 'wagmi'
import { config } from '../lib/wagmi-config'

function App() {
  return (
    <WagmiConfig config={config}>
      <YourApp />
    </WagmiConfig>
  )
}

完整代码示例

下面是一个完整的、可运行的ERC20余额查询和转账组件,展示了Viem + Wagmi的实际使用:

tsx 复制代码
import React, { useState, useEffect } from 'react'
import { useAccount, usePublicClient, useWalletClient } from 'wagmi'
import { erc20Abi } from 'viem'
import { formatUnits, parseUnits } from 'viem'
import { isValidAddress, toViemAddress } from '../lib/address-utils'

// 假设的USDC合约地址
const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'

function ERC20Transfer() {
  const { address, isConnected } = useAccount()
  const publicClient = usePublicClient()
  const { data: walletClient } = useWalletClient()
  
  const [balance, setBalance] = useState<bigint>(0n)
  const [recipient, setRecipient] = useState('')
  const [amount, setAmount] = useState('')
  const [loading, setLoading] = useState(false)
  
  // 获取余额
  const fetchBalance = async () => {
    if (!publicClient || !address) return
    
    try {
      const balance = await publicClient.readContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address],
      })
      
      setBalance(balance as bigint)
    } catch (error) {
      console.error('Failed to fetch balance:', error)
    }
  }
  
  // 转账
  const handleTransfer = async () => {
    if (!walletClient || !address || !recipient || !amount) return
    if (!isValidAddress(recipient)) {
      alert('Invalid recipient address')
      return
    }
    
    setLoading(true)
    try {
      // USDC有6位小数
      const amountInWei = parseUnits(amount, 6)
      
      const hash = await walletClient.writeContract({
        address: toViemAddress(USDC_ADDRESS),
        abi: erc20Abi,
        functionName: 'transfer',
        args: [toViemAddress(recipient), amountInWei],
        account: address,
      })
      
      console.log('Transaction hash:', hash)
      
      // 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({ hash })
      console.log('Transaction confirmed:', receipt)
      
      // 更新余额
      await fetchBalance()
      setAmount('')
      setRecipient('')
      
      alert('Transfer successful!')
    } catch (error: any) {
      console.error('Transfer failed:', error)
      alert(`Transfer failed: ${error.shortMessage || error.message}`)
    } finally {
      setLoading(false)
    }
  }
  
  // 监听地址变化,重新获取余额
  useEffect(() => {
    if (address) {
      fetchBalance()
    }
  }, [address, publicClient])
  
  if (!isConnected) {
    return <div>Please connect your wallet</div>
  }
  
  return (
    <div>
      <h2>USDC Balance: {formatUnits(balance, 6)}</h2>
      
      <div>
        <input
          type="text"
          placeholder="Recipient address"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          type="text"
          placeholder="Amount"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button 
          onClick={handleTransfer} 
          disabled={loading || !recipient || !amount}
        >
          {loading ? 'Processing...' : 'Transfer'}
        </button>
      </div>
      
      <button onClick={fetchBalance} style={{ marginTop: '20px' }}>
        Refresh Balance
      </button>
    </div>
  )
}

export default ERC20Transfer

踩坑记录

在实际迁移过程中,我遇到了不少预料之外的问题:

  1. 类型错误:Argument of type 'string' is not assignable to parameter of type 'Hex'

    • 问题 :Viem严格要求地址类型为0x${string}(Hex类型)
    • 解决 :创建了toViemAddress类型转换函数和isValidAddress类型守卫
  2. 事件监听内存泄漏

    • 问题 :Viem的watchContractEvent不会自动清理,在React组件卸载后仍在监听
    • 解决:必须在useEffect的清理函数中调用返回的unwatch函数
  3. BigInt序列化问题

    • 问题:将包含bigint的对象直接存入Redux或传递给API会报错
    • 解决:在存储前转换为string,使用时再转回bigint,或者使用支持bigint的序列化库
  4. RPC方法不支持

    • 问题:某些自定义RPC节点不支持Viem默认调用的方法
    • 解决:在创建transport时指定支持的RPC方法,或使用Alchemy、Infura等标准节点
  5. ABI类型不匹配

    • 问题:直接从原有项目复制的JSON ABI在Viem中类型推断失败
    • 解决 :使用Viem提供的标准ABI(如erc20Abi),或使用as const断言自定义ABI

小结

从ethers.js迁移到Viem确实需要投入不少精力,但带来的类型安全、包体积减小和更现代的API设计是值得的。最关键的是采用渐进式迁移,先让两个库共存,逐步替换模块。对于新开始的Web3项目,我会直接选择Viem + Wagmi的组合。

相关推荐
码云之上2 小时前
上下文工程实战:解决多轮对话中的"上下文腐烂"问题
前端·node.js·agent
小小弯_Shelby2 小时前
webpack优化:Vue配置compression-webpack-plugin实现gzip压缩
前端·vue.js·webpack
小村儿2 小时前
连载04-CLAUDE.md ---一起吃透 Claude Code,告别 AI coding 迷茫
前端·后端·ai编程
攀登的牵牛花2 小时前
我把 Gemma4:26b 装进 M1 Pro 后,才看清 AI 编程最贵的不是模型费,而是工作流
前端·agent
前端郭德纲2 小时前
JavaScript Object.freeze() 详解
开发语言·javascript·ecmascript
大漠_w3cpluscom2 小时前
现代 CSS 的新力量
前端
魏嗣宗2 小时前
Claude Code 启动的那 200 毫秒里发生了什么
前端·claude
希望永不加班3 小时前
SpringBoot 静态资源访问(图片/JS/CSS)配置详解
java·javascript·css·spring boot·后端
m0_738120723 小时前
渗透基础知识ctfshow——Web应用安全与防护(第一章)
服务器·前端·javascript·安全·web安全·网络安全