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

在Web3前端开发领域,ethers.js 长期以来占据着主导地位,尤其是在DeFi(去中心化金融)应用中。然而,随着区块链生态的演进和开发者对性能、类型安全要求的提高,Viem 作为一种更轻量、更现代化的以太坊客户端库,正逐渐成为行业新标准。许多团队在维护老旧项目时,面临着代码冗余、包体积过大以及TypeScript支持不足的挑战。将核心交互逻辑从 ethers.js v5 迁移至 Viem,不仅能显著减小前端包体积,还能利用其内置的多链工具和原生 EIP-4337(账户抽象)支持,提升应用的现代化水平。

本文深入探讨在重构一个三年历史的 DeFi 借贷平台前端时,从 ethers.js 迁移到 Viem 的具体实践过程。文章将详细分析两者在架构设计上的根本差异,重点解决 Provider 初始化BigNumber 与 bigint 类型转换合约交互模式重构 以及 事件监听机制 等核心痛点。通过提供可复用的代码示例和设计模式,旨在为正在进行类似技术栈升级的开发团队提供一份详尽的避坑指南和最佳实践参考,帮助开发者平滑过渡,降低维护成本并提升代码质量。

迁移背景与架构差异分析

在启动迁移工作之前,深入理解 ethers.js 与 Viem 在设计哲学上的差异至关重要。原有的 DeFi 项目基于 ethers.js v5 构建,代码中充斥着大量的 BigNumber 手动转换逻辑和冗长的 Provider 初始化代码。随着业务扩展,每新增一条区块链网络的支持,都需要手动配置 RPC 节点地址和链 ID 映射,这种硬编码方式导致维护成本呈指数级上升。此外,社区对新项目的技术选型普遍转向 Viem,主要原因在于其更小的包体积、卓越的 TypeScript 类型推断能力以及模块化的架构设计。

Viem 的核心设计理念是“无头”(Headless)和模块化。它将传统的单一 Provider 拆分为 PublicClient(用于读取链上数据)和 WalletClient(用于签署交易和写入数据)。这种读写分离的设计不仅符合最小权限原则,还使得状态管理更加清晰。相比之下,ethers.js 的 Web3Provider 往往混合了读取和写入功能,且在处理多链切换时,通常需要重新实例化整个 Provider 对象,这在 React 等响应式框架中容易引发不必要的重渲染或状态同步问题。

另一个关键优势在于 Viem 对现代 JavaScript 特性的原生支持。它摒弃了自定义的大数处理库,直接采用 ES2020 引入的原生 bigint 类型。这不仅减少了依赖项,还提升了数值运算的性能。同时,Viem 对 EIP-4337 账户抽象的原生支持,使得集成智能合约钱包变得异常简单,这对于计划引入社交登录或 gas 代付功能的 DeFi 应用而言,是一个巨大的战略优势。因此,迁移不仅仅是替换库文件,更是一次对底层数据流和状态管理架构的重构机会。

核心实现:动态 Client 工厂模式

在迁移初期,最直接的挑战是如何处理 window.ethereum 的动态性以及多链切换的需求。在 ethers.js 中,开发者习惯于在组件挂载后初始化 Provider,并且 Provider 能够一定程度上自动感知网络变化。然而,Viem 的 Client 实例一旦创建,其关联的链配置(Chain Config)就是固定的。这意味着当用户在前端切换网络(例如从 Ethereum Mainnet 切换到 Polygon)时,必须销毁旧的 Client 实例并创建新的实例,否则会导致交易发送到错误的网络或读取错误的数据。

为了解决这一问题,建议采用 工厂模式 结合 缓存机制 来管理 Client 的生命周期。首先,建立一个链 ID 到 Viem Chain 配置的映射表,确保所有支持的网络都有明确的定义。接着,创建一个工厂函数,根据当前的链 ID 和注入的 Ethereum 提供者(如 MetaMask)动态生成 PublicClient 和 WalletClient。这种设计解耦了链配置与传输层,使得添加新链只需在配置表中增加一行记录,无需修改核心逻辑。

import { createPublicClient, createWalletClient, custom, Chain } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'

// 链ID到Viem Chain配置的映射表
// 建议将此配置提取到独立的常量文件中,便于维护
const CHAIN_CONFIGS: Record<number, Chain> = {
  1: mainnet,
  137: polygon,
  42161: arbitrum,
}

/**
 * 根据链ID和Ethereum提供者创建Client实例
 * @param chainId 当前链ID
 * @param ethereum window.ethereum 对象
 */
export function createClients(chainId: number, ethereum: any) {
  const chain = CHAIN_CONFIGS[chainId]

  // 安全性检查:防止不支持的链ID导致运行时错误
  if (!chain) {
    throw new Error(`Unsupported chainId: ${chainId}`)
  }

  // 创建公共客户端,用于读取链上数据
  const publicClient = createPublicClient({
    chain,
    transport: custom(ethereum),
  })

  // 创建钱包客户端,用于签署交易
  const walletClient = createWalletClient({
    chain,
    transport: custom(ethereum),
  })

  return { publicClient, walletClient }
}

然而,频繁地创建和销毁 Client 实例会带来性能开销,尤其是在用户快速切换网络或组件频繁挂载/卸载的场景下。为了优化性能,引入一层内存缓存是必要的。通过维护一个 Map 结构,以 chainId 和提供者类型作为缓存键,可以确保在同一网络环境下复用已创建的 Client 实例。这不仅减少了内存分配压力,还避免了重复初始化带来的延迟。需要注意的是,缓存策略应包含清理机制,以防内存泄漏,但在大多数单页应用(SPA)生命周期内,简单的 Map 缓存已足够高效。

// 使用Map进行Client实例缓存
// Key格式: "chainId-providerType"
const clientCache = new Map<string, ReturnType<typeof createClients>>()

/**
 * 获取缓存的Client实例,若不存在则创建
 * @param chainId 当前链ID
 * @param ethereum window.ethereum 对象
 */
export function getCachedClients(chainId: number, ethereum: any) {
  // 生成唯一的缓存键,区分不同的钱包提供者
  const cacheKey = `${chainId}-${ethereum?.isMetaMask ? 'metamask' : 'generic'}`

  if (!clientCache.has(cacheKey)) {
    // 缓存未命中,创建新实例并存入缓存
    clientCache.set(cacheKey, createClients(chainId, ethereum))
  }

  // 返回缓存中的实例,断言非空
  return clientCache.get(cacheKey)!
}

数值处理:从 BigNumber 到 BigInt 的平滑过渡

在 ethers.js v5 中,由于 JavaScript 原生数字类型精度不足,所有涉及代币金额的计算都必须使用 BigNumber 对象。这导致代码中充满了 BigNumber.from()、.toString() 以及各种工具函数的调用,代码可读性较差且容易出错。Viem 顺应 JavaScript 语言的发展,全面采用原生的 bigint 类型。这一改变虽然简化了依赖,但也带来了兼容性挑战,因为现有的业务逻辑、UI 展示组件以及后端 API 可能仍然期望字符串或 BigNumber 格式的数据。

迁移过程中,最频繁遇到的操作是以太单位(Wei)与人类可读单位(Ether)之间的转换。Viem 提供了 parseEther 和 formatEther 工具函数,其用法与 ethers.js 类似,但返回值类型不同。parseEther 返回的是 bigint,而 formatEther 接收 bigint 并返回格式化后的字符串。开发者需要特别注意,不能再将这些值直接传递给期望字符串或 Number 的旧函数,必须进行显式的类型转换或适配。

import { parseEther, formatEther } from 'viem'

// 将人类可读的字符串转换为 bigint (Wei)
// 结果: 1500000000000000000n
const amountBigInt = parseEther('1.5')

// 将 bigint (Wei) 转换为人类可读的字符串
// 结果: '1.5'
const readableAmount = formatEther(1500000000000000000n)

// 注意:如果需要字符串形式的Wei值,需手动转换
// 结果: '1500000000000000000'
const amountString = amountBigInt.toString()

为了屏蔽底层类型差异,建议在项目中构建一个统一的数值适配层。这个适配层负责处理所有进入和流出业务逻辑的数值转换,包括处理科学计数法字符串、兼容旧的 BigNumber 对象(如果尚未完全移除 ethers.js 依赖)以及提供标准化的格式化输出。通过封装 toBigInt 和 fromWei 等辅助函数,可以确保整个应用在处理金额时具有一致的行为,避免因精度丢失或类型错误导致的资金计算偏差。

/**
 * 将各种类型的值统一转换为 bigint
 * 支持 string, number, bigint 以及科学计数法字符串
 */
export function toBigInt(value: string | number | bigint): bigint {
  if (typeof value === 'bigint') return value

  if (typeof value === 'string') {
    // 处理科学计数法情况,例如 "1e18"
    if (value.includes('e') || value.includes('E')) {
      return BigInt(Number(value))
    }
    return BigInt(value)
  }

  return BigInt(value)
}

/**
 * 将 Wei (bigint) 转换为指定小数位数的可读字符串
 * @param value Wei 值
 * @param decimals 小数位数,默认为 18 (ETH)
 */
export function fromWei(value: bigint, decimals: number = 18): string {
  const divisor = 10n ** BigInt(decimals)

  // 计算整数部分和小数部分
  const integerPart = value / divisor
  const fractionalPart = value % divisor

  // 如果没有小数部分,直接返回整数
  if (fractionalPart === 0n) {
    return integerPart.toString()
  }

  // 格式化小数部分:补零至指定长度,然后去除末尾多余的零
  const fractionStr = fractionalPart.toString().padStart(decimals, '0')
  const trimmedFraction = fractionStr.replace(/0+$/, '')

  return `${integerPart}.${trimmedFraction}`
}

合约交互:读写分离与 Hook 封装

合约交互是 DeFi 应用的核心,也是迁移工作中最复杂的部分。在 ethers.js 中,开发者通常通过 new ethers.Contract(address, abi, signer) 创建一个合约实例,然后直接调用其方法,如 contract.deposit()。这种方式将读取和写入操作耦合在一起,且返回的是一个 Promise,解析后得到交易对象或结果。而在 Viem 中,读写操作被严格分离:只读操作 通过 publicClient.readContract 执行,写入操作 通过 walletClient.writeContract 执行。这种分离要求开发者在编写代码时必须明确区分操作的性质。

对于只读操作(如查询余额、获取价格),Viem 的 readContract 方法直接返回解码后的数据,使用方式直观且类型安全。而对于写入操作(如存款、转账),writeContract 仅负责发送交易并返回交易哈希(Transaction Hash),它并不等待交易上链确认。这意味着开发者必须额外调用 publicClient.waitForTransactionReceipt 来监听交易状态,直到交易被打包确认。这种异步流程的变化需要调整原有的错误处理和加载状态管理逻辑。

import { readContract } from 'viem/actions'

// 只读操作示例:查询代币余额
const balance = await publicClient.readContract({
  address: '0xTokenAddress...',
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: ['0xUserAddress...'],
})
import { writeContract } from 'viem/actions'

// 写入操作示例:存入资产
// 1. 发送交易,获取哈希
const hash = await walletClient.writeContract({
  address: '0xContractAddress...',
  abi: lendingPoolAbi,
  functionName: 'deposit',
  args: [amount],
  value: amount, // 如果涉及ETH发送
})

// 2. 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ 
  hash 
})

if (receipt.status === 'success') {
  console.log('Transaction confirmed')
} else {
  console.error('Transaction failed')
}

为了在 React 应用中复用这些逻辑,建议封装一个自定义 Hook useContractCall。该 Hook 内部引用之前创建的 publicClient 和 walletClient,并提供 read 和 write 两个方法。通过 useCallback 优化函数引用,避免不必要的重渲染。这种封装不仅统一了合约调用的接口,还集中处理了客户端不可用时的异常抛出,使得上层组件只需关注业务参数,无需关心底层的 Client 管理细节。

import { useCallback } from 'react'
import { Address, Hash } from 'viem'

// 定义合约调用的通用选项接口
interface ContractCallOptions {
  address: Address
  abi: any[]
  functionName: string
  args?: any[]
  value?: bigint
}

// 假设 useClients 是一个提供 publicClient 和 walletClient 的自定义 Hook
// import { useClients } from './useClients'

export function useContractCall() {
  // 获取当前的客户端实例
  // 实际项目中需根据具体状态管理方案实现 useClients
  const { publicClient, walletClient } = useClients() 

  /**
   * 执行只读合约调用
   */
  const read = useCallback(async (options: ContractCallOptions) => {
    if (!publicClient) throw new Error('Public client not available')

    return publicClient.readContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
    })
  }, [publicClient])

  /**
   * 执行写入合约调用,返回交易哈希
   */
  const write = useCallback(async (options: ContractCallOptions): Promise
<Hash> => {
    if (!walletClient) throw new Error('Wallet client not available')

    return walletClient.writeContract({
      address: options.address,
      abi: options.abi,
      functionName: options.functionName,
      args: options.args,
      value: options.value,
    })
  }, [walletClient])

  return { read, write }
}

事件监听:从回调到日志轮询

在 ethers.js 中,监听合约事件非常直观,只需调用 contract.on('EventName', callback) 即可注册监听器,并通过 contract.removeAllListeners() 清理。这种基于 WebSocket 或 HTTP 长连接的机制在浏览器环境中表现良好。然而,Viem 采取了更底层且通用的方式,即通过 watchContractEvent 来监听事件。Viem 的事件监听本质上是对区块日志的轮询或过滤,它返回一个取消监听的函数(unwatch function),而不是依赖于对象本身的方法。

这种变化要求开发者改变事件管理的思维模式。在 Viem 中,watchContractEvent 需要传入 publicClient、合约地址、ABI 以及事件名称。回调函数 onLogs 接收的是一个日志数组,每个日志对象中包含了解码后的 args 参数。由于 Viem 强依赖 ABI 进行类型推导,确保 ABI 定义的准确性至关重要,否则可能导致参数解析错误。此外,由于是轮询机制,需要注意设置合适的轮询间隔,以平衡实时性和网络请求频率。

import { watchContractEvent } from 'viem/actions'

// 监听 Deposit 事件
const unwatch = watchContractEvent(publicClient, {
  address: '0xContractAddress...',
  abi: lendingPoolAbi,
  eventName: 'Deposit',
  onLogs: (logs) => {
    logs.forEach((log) => {
      // Viem 自动根据 ABI 解码参数
      const { args } = log
      console.log(`Deposit from ${args.sender}: ${args.amount}`)
    })
  },
})

// 在组件卸载或不再需要监听时,调用 unwatch 停止监听
// unwatch()

在实际应用中,特别是在 React 组件中,通常需要在 useEffect 中设置监听器,并在清理函数中调用 unwatch。这样可以确保当组件卸载时,不会留下悬空的监听器导致内存泄漏或状态更新错误。与 ethers.js 相比,Viem 的方式虽然稍微繁琐一些,但提供了更强的控制力和更好的类型安全性,特别是在处理复杂的事件参数结构时,TypeScript 的智能提示能显著减少开发错误。

事件监听的封装与最佳实践

在重构过程中,处理智能合约事件监听是一个高频且容易出错的场景。为了简化逻辑并避免内存泄漏,我们封装了一个自定义 Hook useContractEvent,它基于 Viem 的底层 API 提供了声明式的事件订阅能力。该函数接收合约地址、ABI、事件名称以及回调函数作为参数,利用 React 的 useEffect 钩子管理监听器的生命周期。当组件卸载或依赖项发生变化时,自动调用 unwatch 方法取消订阅,确保资源被正确释放。这种模式不仅减少了重复代码,还强制开发者关注依赖数组的完整性,从而避免因闭包陷阱导致的状态更新滞后问题。在实际的 DeFi 应用中,这种封装对于实时展示交易状态、余额变动或治理投票结果至关重要。

export function useContractEvent(
  address: Address,
  abi: any[],
  eventName: string,
  callback: (args: any) => void
) {
  const { publicClient } = useClients()

  useEffect(() => {
    if (!publicClient || !address) return

    // 使用 viem 的 watchContractEvent 建立监听
    const unwatch = watchContractEvent(publicClient, {
      address,
      abi,
      eventName,
      onLogs: (logs) => {
        // 遍历日志并提取参数,触发业务回调
        logs.forEach((log) => {
          callback(log.args)
        })
      },
    })

    // 清理函数:组件卸载或依赖变更时取消监听
    return () => unwatch()
  }, [address, abi, eventName, callback, publicClient])
}

完整实战:构建类型安全的钱包交互组件

为了直观展示 Viem 在实际项目中的应用,以下提供一个完整的 React 组件示例,涵盖了从客户端初始化、钱包连接到读写合约的全流程。该示例严格遵循 Viem 的“读写分离”原则,分别使用 PublicClient 进行链上数据查询和 WalletClient 处理用户签名交易。代码中引入了 parseEther 和 formatEther 等工具函数,确保在处理 ETH 单位转换时的精度安全,避免浮点数误差。通过 as const 断言 ABI 常量,TypeScript 能够自动推断出函数参数和返回值的类型,极大地提升了开发体验和代码健壮性。这种结构化的实现方式为后续扩展多链支持或集成账户抽象(Account Abstraction)奠定了坚实基础。

import React, { useState, useEffect } from 'react'
import { createPublicClient, createWalletClient, custom, parseEther, formatEther } from 'viem'
import { mainnet } from 'viem/chains'
import { readContract, writeContract, waitForTransactionReceipt } from 'viem/actions'

// 简单的ERC20 ABI片段,使用 as const 确保类型推断精确
const ERC20_ABI = [
  {
    name: 'balanceOf',
    type: 'function',
    inputs: [{ name: 'owner', type: 'address' }],
    outputs: [{ name: '', type: 'uint256' }],
    stateMutability: 'view',
  },
  {
    name: 'transfer',
    type: 'function',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'amount', type: 'uint256' },
    ],
    outputs: [{ name: '', type: 'bool' }],
    stateMutability: 'nonpayable',
  },
] as const

function WalletInteraction() {
  const [account, setAccount] = useState
<string>('')
  const [balance, setBalance] = useState
<bigint>(0n)
  const [publicClient, setPublicClient] = useState
<any>(null)
  const [walletClient, setWalletClient] = useState
<any>(null)

  // 初始化 Clients:区分只读客户端和钱包客户端
  useEffect(() => {
    if (window.ethereum) {
      const publicClient = createPublicClient({
        chain: mainnet,
        transport: custom(window.ethereum),
      })

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

      setPublicClient(publicClient)
      setWalletClient(walletClient)
    }
  }, [])

  // 连接钱包:请求账户权限并获取初始余额
  const connectWallet = async () => {
    if (!window.ethereum) {
      alert('请安装MetaMask')
      return
    }

    try {
      const [address] = await window.ethereum.request({
        method: 'eth_requestAccounts',
      })
      setAccount(address)

      // 查询原生代币余额
      if (publicClient) {
        const balance = await publicClient.getBalance({ address })
        setBalance(balance)
      }
    } catch (error) {
      console.error('连接钱包失败:', error)
    }
  }

  // 查询ERC20余额:使用 readContract 进行只读调用
  const queryTokenBalance = async (tokenAddress: string) => {
    if (!publicClient || !account) return

    try {
      const balance = await readContract(publicClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'balanceOf',
        args: [account],
      })

      console.log('Token balance:', balance)
      return balance
    } catch (error) {
      console.error('查询代币余额失败:', error)
    }
  }

  // 发送ETH:使用 walletClient 发起交易并等待确认
  const sendETH = async (to: string, amount: string) => {
    if (!walletClient || !account) return

    try {
      const hash = await walletClient.sendTransaction({
        account,
        to: to as `0x${string}`,
        value: parseEther(amount), // 将字符串转换为 Wei
      })

      console.log('交易哈希:', hash)

      // 等待交易上链确认,获取收据
      const receipt = await waitForTransactionReceipt(publicClient, { hash })
      console.log('交易确认:', receipt)

      return receipt
    } catch (error) {
      console.error('发送交易失败:', error)
    }
  }

  // 转账ERC20:使用 writeContract 执行状态变更函数
  const transferToken = async (tokenAddress: string, to: string, amount: bigint) => {
    if (!walletClient || !account) return

    try {
      const hash = await writeContract(walletClient, {
        address: tokenAddress as `0x${string}`,
        abi: ERC20_ABI,
        functionName: 'transfer',
        args: [to as `0x${string}`, amount],
        account,
      })

      console.log('代币转账哈希:', hash)
      return hash
    } catch (error) {
      console.error('代币转账失败:', error)
    }
  }

  return (

<div>

<h1>Viem钱包交互示例</h1>

      {!account ? (
        <button onClick={connectWallet}>连接钱包</button>
      ) : (

<div>

<p>已连接: {account}</p>

<p>余额: {formatEther(balance)} ETH</p>

          <button onClick={() => sendETH('0x...', '0.01')}>
            发送0.01 ETH
          </button>
        </div>
      )}
    </div>
  )
}

export default WalletInteraction

迁移过程中的常见陷阱与解决方案

在从 ethers.js 迁移至 Viem 的过程中,开发者往往会遇到一些因设计理念差异导致的典型问题,其中 TypeScript 类型兼容性是最常见的阻碍之一。例如,浏览器环境下的 window.ethereum 对象默认不在 TypeScript 的全局类型定义中,直接访问会引发编译错误。解决这一问题的标准做法是通过模块增强(Module Augmentation)扩展 Window 接口,或者在必要时使用类型断言,但这应作为最后手段,以保留类型检查的优势。此外,Viem 的 Client 实例一旦创建,其关联的链配置即为不可变,这意味着当用户在钱包中切换网络时,原有的 Client 实例将无法自动适配新链,必须监听 chainChanged 事件并重新实例化 Client,否则交易将被发送到错误的网络或导致 RPC 错误。

另一个值得注意的细节是 JavaScript 中 BigInt 类型的序列化限制。由于 JSON 标准不支持 BigInt,当尝试将包含大整数的状态存入 Redux、LocalStorage 或通过 Props 传递时,会抛出序列化错误。最佳实践是在数据进入状态管理层之前,将其转换为字符串格式,并在读取时再解析回 BigInt,或者使用专门的序列化库处理此类数据类型。同时,ABI 的类型推断也是影响开发体验的关键因素,如果 ABI 数组未使用 as const 进行断言,TypeScript 将其视为普通可变数组,导致无法精确推断出合约函数的参数类型和返回值结构,从而失去 Viem 提供的核心类型安全保障。因此,始终确保 ABI 定义为常量是发挥 Viem 类型优势的前提。

总结与展望

此次从 ethers.js 到 Viem 的迁移不仅仅是 API 层面的替换,更是一次对前端区块链交互架构的深度重构。Viem 凭借其模块化设计、极致的类型安全和轻量级的体积,显著提升了代码的可维护性和运行时性能,特别是在处理复杂的 DeFi 交互场景时,其“读写分离”和“动作驱动”的模式让数据流更加清晰可控。然而,这也要求开发者转变思维,从传统的面向对象风格适应更函数式、更显式的资源管理方式,尤其是要重视 Client 的生命周期管理和链状态的同步机制。通过这次实战,我们不仅解决了具体的技术难点,还建立了一套可复用的最佳实践规范,为后续项目的快速迭代打下了坚实基础。未来,随着账户抽象(AA)和多链互操作性需求的增加,Viem 的低层级原语优势将更加凸显,值得团队持续深入研究和应用。