背景
上个月,我接手了一个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的架构理念不同:它把“读取”和“写入”分离成了PublicClient和WalletClient,把链配置和传输层解耦。我需要重新思考整个数据流的设计。
核心实现
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.parseEther和formatEther的调用。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的账户抽象和多链管理,把这些经验用到新项目中。