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

20 阅读1分钟

背景

上个月,我接手了一个DeFi借贷平台的前端重构任务。这个项目三年前用ethers.js v5开发,代码里到处都是BigNumber的手动转换、冗长的Provider初始化,还有一堆自定义的类型补丁。最头疼的是,每次添加新链支持,都得手动配置RPC节点和链ID映射,维护成本越来越高。

团队里新来的同事抱怨说:“现在社区新项目都用Viem了,咱们这代码看着像古董。”我查了一下,Viem确实有不少吸引人的地方:更小的包体积、更好的TypeScript支持、内置的多链工具。更重要的是,我们计划集成AA(账户抽象)钱包,Viem对EIP-4337的原生支持是个巨大优势。

于是,我决定用一周时间,把核心的链上交互模块从ethers.js迁移到Viem。本以为就是换个库,改改API调用,结果第一天的进展就让我意识到,这趟水比想象中深。

问题分析

我的迁移策略很直接:先不动UI层,只替换底层的链上交互逻辑。我创建了一个useViemClient的Hook,打算逐步替换项目中几十个用到ethers.providers.Web3Provider的地方。

第一个问题很快就出现了。原来的代码里到处都是这样的模式:

const provider = new ethers.providers.Web3Provider(window.ethereum)
const signer = provider.getSigner()
const contract = new ethers.Contract(address, abi, signer)

看起来很简单,对吧?我照着Viem文档写了第一版:

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

const publicClient = createPublicClient({
  chain: mainnet,
  transport: custom(window.ethereum)
})

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

但一运行就报错:window.ethereum可能是undefined。在ethers.js里,我们习惯在组件挂载后再初始化Provider,但Viem的Client设计更倾向于提前创建。更麻烦的是,用户切换网络时,ethers.js的Provider会自动更新,而Viem的Client需要手动重建。

我意识到,不能简单地对等替换。Viem的架构理念不同:它把“读取”和“写入”分离成了PublicClientWalletClient,把链配置和传输层解耦。我需要重新思考整个数据流的设计。

核心实现

1. 设计可动态切换的Client工厂

首先,我需要一个能处理动态链切换的Client管理方案。这里有个坑:Viem的Client创建后,链配置是固定的。用户切换网络时,必须创建新的Client实例。

我的解决方案是创建一个工厂函数,根据当前链ID动态生成Client:

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

// 链ID到配置的映射
const CHAIN_CONFIGS: Record<number, Chain> = {
  1: mainnet,
  137: polygon,
  42161: arbitrum,
}

export function createClients(chainId: number, ethereum: any) {
  const chain = CHAIN_CONFIGS[chainId]
  
  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,性能开销大。我加了一层缓存:

const clientCache = new Map<string, ReturnType<typeof createClients>>()

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)!
}

2. 处理BigNumber和数值转换

在ethers.js里,我们习惯用BigNumber处理大数,然后手动转换。Viem用了bigint原生类型,这本来是好事,但和现有代码的兼容性成了问题。

原来的代码:

import { BigNumber } from 'ethers'
const amount = BigNumber.from('1000000000000000000') // 1 ETH
const formatted = ethers.utils.formatEther(amount)

迁移时,我发现项目里到处是ethers.utils.parseEtherformatEther的调用。Viem的处理方式更统一:

import { parseEther, formatEther } from 'viem'

// 字符串转bigint
const amount = parseEther('1.5') // 1500000000000000000n

// bigint转可读字符串
const readable = formatEther(1500000000000000000n) // '1.5'

但这里有个细节要注意:Viem的parseEther返回的是bigint,不是字符串。如果你需要字符串形式的wei值,得手动转换:

const amountBigInt = parseEther('1.5')
const amountString = amountBigInt.toString() // '1500000000000000000'

我写了一个适配层来平滑迁移:

export function toBigInt(value: string | number | bigint): bigint {
  if (typeof value === 'bigint') return value
  if (typeof value === 'string') {
    // 处理科学计数法
    if (value.includes('e')) {
      return BigInt(Number(value))
    }
    return BigInt(value)
  }
  return BigInt(value)
}

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')
  // 去掉末尾的0
  const trimmed = fractionStr.replace(/0+$/, '')
  
  return `${integerPart}.${trimmed}`
}

3. 合约交互的重构

这是最复杂的部分。原来的合约调用模式是统一的:

const contract = new ethers.Contract(address, abi, signer)
const tx = await contract.deposit(amount, { value: amount })
await tx.wait()

Viem的写法完全不同,而且读写操作要分开处理。我花了半天时间才理清楚:

对于只读操作:

import { readContract } from 'viem/actions'

const result = await publicClient.readContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'balanceOf',
  args: ['0xuser...'],
})

对于写入操作:

import { writeContract } from 'viem/actions'

const hash = await walletClient.writeContract({
  address: '0x...',
  abi: contractABI,
  functionName: 'deposit',
  args: [amount],
  value: amount,
})

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

这里有个重要的区别:在ethers.js里,contract.deposit()返回一个Transaction对象,你可以监听它的状态。在Viem里,writeContract直接返回交易哈希,你需要用waitForTransactionReceipt来等待确认。

我创建了一个通用的合约交互Hook:

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

interface ContractCallOptions {
  address: Address
  abi: any[]
  functionName: string
  args?: any[]
  value?: bigint
}

export function useContractCall() {
  const { publicClient, walletClient } = useClients() // 自定义Hook,提供Client
  
  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 }
}

4. 事件监听的迁移

事件处理是另一个大坑。原来的代码:

contract.on('Deposit', (sender, amount, event) => {
  console.log(`Deposit from ${sender}: ${amount}`)
})

Viem的事件监听更底层,需要自己处理过滤和解析:

import { watchContractEvent } from 'viem/actions'

const unwatch = watchContractEvent(publicClient, {
  address: '0x...',
  abi: contractABI,
  eventName: 'Deposit',
  onLogs: (logs) => {
    logs.forEach((log) => {
      const { args } = log
      console.log(`Deposit from ${args.sender}: ${args.amount}`)
    })
  },
})

// 取消监听
unwatch()

这里要注意的是,watchContractEvent返回的是一个取消监听的函数,不像ethers.js那样有contract.removeAllListeners()。而且,Viem的事件参数是强类型的,这是好事,但需要ABI定义准确。

我写了一个包装函数来处理常见的事件监听模式:

export function useContractEvent(
  address: Address,
  abi: any[],
  eventName: string,
  callback: (args: any) => void
) {
  const { publicClient } = useClients()
  
  useEffect(() => {
    if (!publicClient || !address) return
    
    const unwatch = watchContractEvent(publicClient, {
      address,
      abi,
      eventName,
      onLogs: (logs) => {
        logs.forEach((log) => {
          callback(log.args)
        })
      },
    })
    
    return () => unwatch()
  }, [address, abi, eventName, callback, publicClient])
}

完整代码

下面是一个完整的、可运行的React组件示例,展示了如何使用Viem进行基本的链上交互:

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片段
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余额
  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
  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),
      })
      
      console.log('交易哈希:', hash)
      
      // 等待确认
      const receipt = await waitForTransactionReceipt(publicClient, { hash })
      console.log('交易确认:', receipt)
      
      return receipt
    } catch (error) {
      console.error('发送交易失败:', error)
    }
  }
  
  // 转账ERC20
  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

踩坑记录

1. window.ethereum的类型问题

报错: Property 'ethereum' does not exist on type 'Window & typeof globalThis' 解决: 需要扩展Window接口,或者在代码中强制类型转换:

declare global {
  interface Window {
    ethereum?: any
  }
}
// 或者
const ethereum = (window as any).ethereum

2. 链切换时Client不更新

现象: 用户切换网络后,交易还是发到原来的链上 原因: Viem的Client创建后链配置是固定的 解决: 监听chainChanged事件,重新创建Client:

window.ethereum?.on('chainChanged', (chainId: string) => {
  const newChainId = parseInt(chainId, 16)
  // 销毁旧的Client,用新chainId创建新的
})

3. BigInt的序列化问题

报错: Do not know how to serialize a BigInt 现象: 尝试将包含bigint的对象存入Redux或通过props传递时报错 解决: 在序列化前转换为字符串:

const serializable = {
  amount: amount.toString(),
  // 其他字段...
}

4. 事件参数类型推断失败

报错: Property 'args' does not exist on type 'Log' 原因: ABI定义不够精确,TypeScript无法推断出args的类型 解决: 使用as const断言确保ABI类型被正确推断:

const ERC20_ABI = [
  // ... 明确定义每个字段的类型
] as const

小结

这次迁移让我深刻体会到,从ethers.js到Viem不只是换API,更是思维模式的转变。Viem的架构更模块化、类型更安全,但需要适应它的“读写分离”和“链配置不可变”的设计理念。最大的收获是:提前设计好Client的生命周期管理,比边写边改要省事得多。下一步我打算深入研究Viem的账户抽象和多链管理,把这些经验用到新项目中。