用Viem替换ethers.js:一次合约交互的"减负"实战,我总算把TypeScript类型搞明白了
摘要
我在开发一个多链DeFi看板时,被ethers.js的TypeScript类型推导折磨到快崩溃------合约返回值全是any,事件参数要手动解析,链切换时还得自己维护Provider。换了Viem之后,类型自动推导、链配置开箱即用,代码量直接砍半。这篇文章记录了我从ethers.js迁移到Viem的全过程,包括ABI处理、合约调用、事件监听等核心场景,以及我踩过的那些坑。
背景
上个月,我在做一个跨链DeFi收益聚合器的前端。项目需要同时连接以太坊、Polygon和Arbitrum三个链,实时展示用户在各链上的质押和奖励数据。一开始我用了ethers.js v5 + TypeScript,但写着写着就发现不对劲了:每次调用合约方法,返回值的类型都是any,我得手动写类型断言;事件监听更是噩梦,需要自己解析log。更烦的是,切换链时要手动维护Provider实例,代码越来越臃肿。
后来在Twitter上看到Viem这个库,说是ethers.js的现代替代品,TypeScript支持特别好。我抱着"试试看"的心态迁移了一个模块,结果发现------真香。这篇文章就是我把整个项目从ethers.js迁移到Viem的实战记录,希望给同样被类型问题困扰的Web3前端开发者一些参考。
问题分析:ethers.js的TypeScript体验为什么让我抓狂
先说说ethers.js的问题。我当时用的是v5,v6虽然改进了不少,但核心问题没变:
-
合约返回值类型丢失 :ethers.js的
Contract类在调用方法时,返回类型是Promise<any>。比如我调用stakingContract.stakeInfo(userAddress),返回值是any,我必须手动写as StakeInfo。更坑的是,如果ABI写错了,运行时才报错。 -
事件监听要手动解析 :监听
Staked事件时,ethers.js返回的Event对象里,args是Result类型,索引和键混在一起,每次都要写event.args.amount这种代码,而且没有类型提示。 -
Provider管理繁琐:不同链需要不同的Provider,切换链时得重新创建并绑定到Contract。我写了一个Provider管理器,但代码量很大,而且容易出bug。
-
Tree Shaking困难:ethers.js包体积大,而且不支持按需引入。我用的是v5,打包后光ethers就占了200KB+。
这些问题在小型项目里还能忍,但项目一复杂,类型安全就成了大问题。我当时就决定:必须换一个TypeScript支持更好的库。
我调研了几个选项:web3.js、ethers.js v6、Viem。web3.js的类型支持更差,直接排除。ethers.js v6虽然改进了类型,但本质上还是ethers.js的架构。Viem的设计思路完全不同------它从底层就是用TypeScript写的,类型推导是第一优先级。而且Viem体积小(压缩后约30KB),支持Tree Shaking,正好契合我的需求。
核心实现:从ethers.js到Viem的迁移实战
第一步:安装和初始化,注意Viem的"链"概念
Viem的安装很简单:
bash
npm install viem
但这里有个坑:Viem不像ethers.js那样自动连接网络,你需要显式指定链。比如连接以太坊主网:
typescript
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const publicClient = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY')
})
对比ethers.js:
typescript
// ethers.js
const provider = new ethers.providers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY')
看起来差不多,但Viem的chain参数是关键。它包含了链的ID、名称、原生货币、RPC URL等信息。当你调用方法时,Viem会自动校验链ID,如果传入的链ID和配置不一致,会直接报错。这个设计让我在切换链时少踩了很多坑。
注意这个细节 :Viem的createPublicClient只用于读取链上数据。如果要写交易,需要createWalletClient。这个分离设计很清晰,但刚迁移时我总忘记切换。
第二步:合约实例化------终于有了类型推导
这是迁移的核心。ethers.js里,合约实例化后所有方法返回any。Viem通过getContract函数,结合ABI,自动推导出所有方法的参数和返回值类型。
先看ethers.js的写法:
typescript
// ethers.js - 类型全丢失
import { ethers } from 'ethers'
import StakingABI from './StakingABI.json'
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const stakingContract = new ethers.Contract(STAKING_ADDRESS, StakingABI, provider)
// 返回值是any
const stakeInfo = await stakingContract.stakeInfo(userAddress)
const amount: string = stakeInfo.amount // 必须手动断言
用Viem重写:
typescript
// Viem - 类型自动推导
import { createPublicClient, getContract, http } from 'viem'
import { polygon } from 'viem/chains'
import { stakingABI } from './stakingABI' // 这里ABI必须是const断言
const publicClient = createPublicClient({
chain: polygon,
transport: http(POLYGON_RPC_URL)
})
const stakingContract = getContract({
address: STAKING_ADDRESS as `0x${string}`,
abi: stakingABI,
client: publicClient
})
// 返回值类型自动推导为 { amount: bigint, startTime: bigint, ... }
const stakeInfo = await stakingContract.read.stakeInfo([userAddress])
console.log(stakeInfo.amount) // 类型是bigint,不是string
这里有个坑 :Viem的ABI必须用as const断言,否则类型推导会失效。比如:
typescript
// 错误:类型推导失效
const stakingABI = [...] // 普通数组
// 正确:必须用as const
const stakingABI = [...] as const
我一开始没注意,结果stakeInfo返回的还是any,心态差点崩了。后来发现是ABI的问题。
Viem的合约调用分read和write两种。只读方法用contract.read.methodName([args]),写方法用contract.write.methodName([args])。这个设计比ethers.js的contract.methodName()更清晰,而且类型推导也更准确。
第三步:多链配置------用Viem的Chain对象管理不同网络
我的项目需要同时连接三个链,Viem的链配置让这件事变得很简单。
先看ethers.js的写法:
typescript
// ethers.js - 手动管理Provider
const providers = {
ethereum: new ethers.providers.JsonRpcProvider(ETH_RPC),
polygon: new ethers.providers.JsonRpcProvider(POLYGON_RPC),
arbitrum: new ethers.providers.JsonRpcProvider(ARBITRUM_RPC)
}
function getContractForChain(chain: string, address: string, abi: any) {
return new ethers.Contract(address, abi, providers[chain])
}
用Viem重写:
typescript
// Viem - 用Chain对象
import { createPublicClient, http } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'
const clients = {
ethereum: createPublicClient({
chain: mainnet,
transport: http(ETH_RPC)
}),
polygon: createPublicClient({
chain: polygon,
transport: http(POLYGON_RPC)
}),
arbitrum: createPublicClient({
chain: arbitrum,
transport: http(ARBITRUM_RPC)
})
}
// 获取合约时,直接传入对应的client
function getStakingContract(chain: keyof typeof clients) {
return getContract({
address: STAKING_ADDRESS_MAP[chain] as `0x${string}`,
abi: stakingABI,
client: clients[chain]
})
}
Viem的viem/chains模块内置了所有主流链的配置,包括链ID、名称、原生货币等。你不需要手动写这些常量,直接导入就行。而且当你调用getContract时,Viem会自动验证链ID,如果合约地址所在的链和client配置的链不一致,会在编译时给出警告(通过类型检查)。
注意这个细节 :Viem的http传输方式支持自动重试和错误处理。我之前的ethers.js代码需要手动写重试逻辑,Viem内置了,省了不少事。
第四步:事件监听------不用再手动解析log
事件监听是我最头疼的部分。ethers.js里,事件回调的参数需要手动解析,而且类型全是any。Viem的watchContractEvent方法直接返回类型安全的事件参数。
ethers.js的写法:
typescript
// ethers.js - 事件参数类型丢失
stakingContract.on('Staked', (user, amount, startTime, event) => {
// user, amount, startTime 都是any
console.log(user, amount.toString())
})
Viem的写法:
typescript
// Viem - 事件参数类型自动推导
const unwatch = stakingContract.watchEvent.Staked(
{
// 可选:过滤特定用户的事件
args: { user: userAddress }
},
{
onLogs: (logs) => {
// logs 的类型是 StakedEvent[],包含 user, amount, startTime 等字段
logs.forEach(log => {
console.log(log.args.user, log.args.amount.toString())
})
}
}
)
// 停止监听
// unwatch()
这里有个坑 :Viem的watchContractEvent返回的是一个unwatch函数,不是像ethers.js那样返回void。如果你在React组件中监听事件,记得在useEffect的清理函数中调用unwatch(),否则会造成内存泄漏。
而且Viem的事件监听是基于WebSocket的,如果你用的是HTTP传输,它会自动降级为轮询。这个细节让我省了不少配置时间。
第五步:写交易------用WalletClient处理签名
写交易这部分,Viem和ethers.js的思路完全不同。ethers.js是把Provider和Signer混在一起,Viem则明确分离了PublicClient(只读)和WalletClient(写交易)。
ethers.js的写法:
typescript
// ethers.js
const signer = provider.getSigner()
const stakingContract = new ethers.Contract(STAKING_ADDRESS, StakingABI, signer)
const tx = await stakingContract.stake(amount)
await tx.wait()
Viem的写法:
typescript
// Viem
import { createWalletClient, custom } from 'viem'
import { polygon } from 'viem/chains'
// 创建WalletClient,使用window.ethereum作为传输
const walletClient = createWalletClient({
chain: polygon,
transport: custom(window.ethereum!)
})
// 获取用户地址
const [address] = await walletClient.requestAddresses()
// 写交易
const hash = await stakingContract.write.stake([amount], {
account: address
})
// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash })
注意这个细节 :Viem的write方法返回的是交易哈希(hash),而不是像ethers.js那样返回一个TransactionResponse对象。你需要手动调用waitForTransactionReceipt来等待确认。这个设计更贴近底层,但刚开始用的时候我总忘记等receipt。
另外,Viem的walletClient.requestAddresses()会弹出MetaMask的钱包授权,而ethers.js的provider.send('eth_requestAccounts', [])则没那么直观。Viem的API命名更符合直觉。
完整代码:一个可运行的多链DeFi看板组件
下面是一个完整的React组件,展示如何用Viem读取用户在不同链上的质押数据:
typescript
// StakingDashboard.tsx
import { useEffect, useState } from 'react'
import { createPublicClient, getContract, http, type Address } from 'viem'
import { mainnet, polygon, arbitrum } from 'viem/chains'
import { stakingABI } from './stakingABI' // 必须用as const断言
// 各链的合约地址
const CONTRACT_ADDRESSES: Record<string, Address> = {
ethereum: '0x...',
polygon: '0x...',
arbitrum: '0x...'
}
// 各链的RPC URL
const RPC_URLS: Record<string, string> = {
ethereum: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY',
polygon: 'https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY',
arbitrum: 'https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY'
}
// 链配置
const CHAINS = {
ethereum: mainnet,
polygon: polygon,
arbitrum: arbitrum
}
// 创建PublicClient
function createClient(chainId: string) {
return createPublicClient({
chain: CHAINS[chainId],
transport: http(RPC_URLS[chainId])
})
}
// 获取合约实例
function getStakingContract(chainId: string, userAddress: Address) {
const client = createClient(chainId)
return getContract({
address: CONTRACT_ADDRESSES[chainId],
abi: stakingABI,
client
})
}
interface StakeInfo {
amount: bigint
startTime: bigint
rewardDebt: bigint
}
export default function StakingDashboard({ userAddress }: { userAddress: Address }) {
const [stakeInfos, setStakeInfos] = useState<Record<string, StakeInfo | null>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function fetchAllStakes() {
try {
setLoading(true)
setError(null)
const results: Record<string, StakeInfo | null> = {}
// 并行读取三个链的数据
await Promise.all(
Object.keys(CONTRACT_ADDRESSES).map(async (chainId) => {
try {
const contract = getStakingContract(chainId, userAddress)
// read方法返回类型安全的数据
const info = await contract.read.stakeInfo([userAddress])
results[chainId] = info as StakeInfo
} catch (err) {
console.error(`Failed to fetch stake info for ${chainId}:`, err)
results[chainId] = null
}
})
)
setStakeInfos(results)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}
fetchAllStakes()
}, [userAddress])
if (loading) return <div>Loading...</div>
if (error) return <div>Error: {error}</div>
return (
<div>
<h2>Staking Dashboard</h2>
{Object.entries(stakeInfos).map(([chainId, info]) => (
<div key={chainId}>
<h3>{chainId}</h3>
{info ? (
<ul>
<li>Staked Amount: {info.amount.toString()} tokens</li>
<li>Start Time: {new Date(Number(info.startTime) * 1000).toLocaleString()}</li>
<li>Reward Debt: {info.rewardDebt.toString()}</li>
</ul>
) : (
<p>No staking data available</p>
)}
</div>
))}
</div>
)
}
踩坑记录:迁移过程中遇到的4个报错
-
ABI必须用
as const:一开始我没用as const,结果getContract返回的合约对象所有方法类型都是any。排查了半天才发现是ABI类型的问题。解决方法:导入ABI时加上as const,或者用typeof stakingABI。 -
地址类型必须是
0x${string}:Viem对地址类型要求很严格,必须是Address类型(即0x${string})。如果传入了普通的字符串,TypeScript会报错。我一开始从环境变量读取地址时没做类型转换,编译就过不了。解决方法:显式断言为as Address。 -
watchContractEvent的清理 :在React组件中监听事件时,我忘了在useEffect的清理函数中调用unwatch(),导致组件卸载后事件监听还在运行,造成内存泄漏。解决方法:在useEffect返回的清理函数中调用unwatch()。 -
链ID不匹配 :Viem的
createPublicClient需要传入chain参数,如果调用getContract时传入的client的链ID和合约地址所在的链不一致,Viem会在运行时抛出异常。我一开始没注意,把polygon的合约地址传给了mainnet的client,结果报错。解决方法:确保每个链的client和合约地址一一对应。
小结
用Viem替换ethers.js后,我的代码量减少了大概40%,TypeScript类型错误几乎降为零。Viem的类型推导、链配置和事件监听设计让我在开发多链DeFi项目时轻松了很多。如果你也在被ethers.js的类型问题困扰,不妨试试Viem。下一步我准备研究Viem的useContractRead等React Hooks,进一步简化代码。