背景
我负责维护一个已经运行两年的DeFi项目前端,技术栈是React + TypeScript + ethers.js 5.7。最近在做性能优化时发现,打包后的bundle size比同类项目大了近30%,经过分析,ethers.js占了相当大的比重。同时,项目中的一些复杂类型定义在ethers.js下显得很冗长,类型提示也不够友好。
团队讨论后决定尝试迁移到Viem。Viem是较新的以太坊JavaScript库,以类型安全、模块化、轻量化为特点。但迁移一个生产环境项目不是简单的替换import语句,我需要在保证现有功能完全正常的前提下完成迁移。
问题分析
最初我以为迁移就是换个库,把ethers.providers.Web3Provider换成viem/createWalletClient就行了。但实际一开始就遇到了问题:
- 类型系统完全不同:ethers.js使用自己的BigNumber类型,而Viem直接使用原生bigint
- 事件监听机制差异:ethers.js的合约事件监听和Viem的watchContractEvent参数结构完全不同
- 多链支持方式不同:我们项目支持Ethereum、Polygon、Arbitrum三条链,ethers.js通过Network对象管理,Viem有自己的一套链定义
最头疼的是,项目中有上百处以太坊交互代码,分布在组件、hooks、工具函数中,不可能一次性全部重写。我需要一个渐进式的迁移方案。
核心实现
第一步:搭建双库共存环境
我决定先让两个库共存,逐步迁移模块。首先安装必要的Viem包:
npm install viem wagmi
然后创建了一个lib/viem-client.ts文件,初始化基础客户端:
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。
我创建了一个转换工具函数:
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版本:
// 旧的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的版本:
// 新的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。这意味着所有合约地址、用户地址都需要进行类型转换。我创建了一个类型守卫函数:
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完全不同。
原来的事件监听:
// ethers.js 事件监听
contract.on('Transfer', (from, to, amount, event) => {
console.log('Transfer event:', { from, to, amount })
updateUI()
})
迁移到Viem的事件监听:
// 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,需要做安全处理:
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:
// 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:
import { WagmiConfig } from 'wagmi'
import { config } from '../lib/wagmi-config'
function App() {
return (
<WagmiConfig config={config}>
<YourApp />
</WagmiConfig>
)
}
完整代码示例
下面是一个完整的、可运行的ERC20余额查询和转账组件,展示了Viem + Wagmi的实际使用:
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
踩坑记录
在实际迁移过程中,我遇到了不少预料之外的问题:
-
类型错误:Argument of type 'string' is not assignable to parameter of type 'Hex'
- 问题:Viem严格要求地址类型为
0x${string}(Hex类型) - 解决:创建了
toViemAddress类型转换函数和isValidAddress类型守卫
- 问题:Viem严格要求地址类型为
-
事件监听内存泄漏
- 问题:Viem的
watchContractEvent不会自动清理,在React组件卸载后仍在监听 - 解决:必须在useEffect的清理函数中调用返回的unwatch函数
- 问题:Viem的
-
BigInt序列化问题
- 问题:将包含bigint的对象直接存入Redux或传递给API会报错
- 解决:在存储前转换为string,使用时再转回bigint,或者使用支持bigint的序列化库
-
RPC方法不支持
- 问题:某些自定义RPC节点不支持Viem默认调用的方法
- 解决:在创建transport时指定支持的RPC方法,或使用Alchemy、Infura等标准节点
-
ABI类型不匹配
- 问题:直接从原有项目复制的JSON ABI在Viem中类型推断失败
- 解决:使用Viem提供的标准ABI(如erc20Abi),或使用
as const断言自定义ABI
小结
从ethers.js迁移到Viem确实需要投入不少精力,但带来的类型安全、包体积减小和更现代的API设计是值得的。最关键的是采用渐进式迁移,先让两个库共存,逐步替换模块。对于新开始的Web3项目,我会直接选择Viem + Wagmi的组合。