用 Viem 替代 ethers.js:Web3 前端新标准
摘要
在重构一个多链 DeFi 前端时,我用 Viem 替换了 ethers.js v5。本以为只是换个 API 调用方式,结果在交易模拟、gas 估算、事件监听上踩了无数坑。这篇文章完整记录了我迁移过程中的真实问题、解决方案和关键代码,希望能帮你少走弯路。
背景
今年年初,我接手了一个老旧的 DeFi 聚合器前端项目。这个项目使用 ethers.js v5 + React,支持 Ethereum、Polygon、Arbitrum 三条链。随着用户量增长,项目暴露出两个痛点:一是 ethers.js 的 bundle 体积太大(压缩后约 120KB),导致首屏加载慢;二是类型支持不够好,合约调用经常出现运行时错误,特别是处理复杂结构体时。
我决定用 Viem 重写整个前端。Viem 是 wagmi 团队推出的轻量级 Web3 库,TypeScript 优先,体积只有 ethers.js 的 1/4。但迁移过程远比我想象的复杂------Viem 的 API 设计哲学和 ethers.js 完全不同,很多我习以为常的模式在 Viem 里行不通。
问题分析
我的第一反应是:不就是换个库调用 RPC 吗?把 ethers.providers.JsonRpcProvider 换成 createPublicClient,把 ethers.Contract 换成 getContract 不就完事了?
结果第一天就栽了。项目里有一段关键代码:在用户发起 swap 交易前,先模拟交易(eth_call)来估算实际 gas 消耗。用 ethers.js 时,我这样写:
typescript
// ethers.js 的做法
const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
const contract = new ethers.Contract(routerAddress, routerABI, provider);
// 模拟交易
const gasEstimate = await contract.estimateGas.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
to,
deadline
);
这个模式在 ethers.js 里很自然:estimateGas 方法直接返回 BigNumber。但 Viem 里没有 estimateGas 这种语法糖,它把合约调用拆成了更底层的步骤。
更坑的是,Viem 的 simulateContract 和 estimateContractGas 返回的数据结构和 ethers.js 完全不同。我当时按照 ethers.js 的习惯调用,结果报错 "Invalid argument type" 整整排查了半天。
核心实现
1. 初始化客户端:Viem 的"双客户端"模式
Viem 和 ethers.js 最大的区别是:Viem 把"读"和"写"分成了两个客户端。
createPublicClient:负责读取链上数据(余额、合约状态、交易详情)createWalletClient:负责签名和发送交易(需要连接钱包)
我当时踩的第一个坑是:以为一个 createClient 就能搞定一切,结果发现 publicClient 没有 sendTransaction 方法,walletClient 没有 getBalance 方法。
正确的做法是分别创建:
typescript
import { createPublicClient, createWalletClient, http } from 'viem';
import { mainnet, polygon, arbitrum } from 'viem/chains';
// 公共客户端:只读操作
const publicClient = createPublicClient({
chain: mainnet,
transport: http(process.env.NEXT_PUBLIC_MAINNET_RPC_URL),
});
// 钱包客户端:写操作,需要连接钱包
const walletClient = createWalletClient({
chain: mainnet,
transport: window.ethereum ? custom(window.ethereum) : http(),
});
注意这个细节 :createWalletClient 的 transport 参数,如果你用的是浏览器扩展钱包(如 MetaMask),必须传入 custom(window.ethereum)。如果你传入 http(),它会尝试用 RPC 直接发交易,但钱包签名需要用户交互,所以会报错。
2. 模拟交易:从 estimateGas 到 simulateContract
这是迁移过程中最让我头疼的部分。在 Viem 里,模拟交易和估算 gas 是分离的:
simulateContract:模拟执行合约调用,返回执行结果和 gas 估算estimateContractGas:只估算 gas 消耗
我项目里需要先模拟交易看是否成功,再估算 gas 做安全限制。用 Viem 的正确写法是:
typescript
import { parseEther, formatEther, maxUint256 } from 'viem';
async function simulateAndEstimateSwap(
publicClient: PublicClient,
routerAddress: `0x${string}`,
tokenIn: `0x${string}`,
tokenOut: `0x${string}`,
amountIn: bigint,
recipient: `0x${string}`
) {
// 第一步:模拟交易
const { result, request } = await publicClient.simulateContract({
address: routerAddress,
abi: routerABI,
functionName: 'swapExactTokensForTokens',
args: [
amountIn,
0n, // amountOutMin 设为 0 只是为了模拟
[tokenIn, tokenOut],
recipient,
maxUint256, // deadline 用最大值
],
account: recipient, // 这里必须指定模拟交易的发送者
});
// result 是模拟执行后的返回值
console.log('模拟交易结果:', result);
// 第二步:估算 gas
const gasEstimate = await publicClient.estimateContractGas(request);
// 加上安全系数(比如 1.2 倍)
const safeGasLimit = (gasEstimate * 120n) / 100n;
return { result, gasEstimate, safeGasLimit };
}
这里有个坑 :simulateContract 的 account 参数必须指定,不然 Viem 不知道用谁的地址去模拟调用。这个参数在 ethers.js 里是隐式的(从 provider 获取当前账户),但在 Viem 里必须显式传入。
另一个坑是 maxUint256 的用法。在 ethers.js 里,ethers.constants.MaxUint256 是一个 BigNumber。在 Viem 里,maxUint256 直接是一个 bigint 常量,类型不同,不能混用。
3. 发送交易:从 contract.function() 到 writeContract
在 ethers.js 里,发送交易就像调用普通函数一样自然:
typescript
// ethers.js
const tx = await contract.swapExactTokensForTokens(
amountIn,
amountOutMin,
path,
to,
deadline,
{ gasLimit: 300000 }
);
await tx.wait();
Viem 里则必须通过 walletClient.writeContract 来发送,而且需要用户签名后才能拿到 txHash:
typescript
import { type Hash } from 'viem';
async function sendSwapTransaction(
walletClient: WalletClient,
publicClient: PublicClient,
routerAddress: `0x${string}`,
amountIn: bigint,
amountOutMin: bigint,
path: readonly [`0x${string}`, `0x${string}`],
recipient: `0x${string}`,
deadline: bigint,
gasLimit: bigint
) {
// 构造交易请求
const hash: Hash = await walletClient.writeContract({
address: routerAddress,
abi: routerABI,
functionName: 'swapExactTokensForTokens',
args: [amountIn, amountOutMin, path, recipient, deadline],
gas: gasLimit, // 注意这里用 gas 而不是 gasLimit
chain: walletClient.chain, // 必须指定 chain
});
// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({
hash,
confirmations: 1, // 确认数
});
console.log('交易确认:', receipt.transactionHash);
return receipt;
}
这里有个坑 :Viem 里 gas 参数的字段名是 gas,而不是 ethers.js 里的 gasLimit。我当时习惯性写了 gasLimit,结果 Viem 直接忽略了它,导致交易因为 gas 不足而回滚。排查了半小时才发现是字段名问题。
另一个坑是 chain 参数。如果你创建 walletClient 时指定了 chain,这里可以省略。但如果你在运行时需要切换链(比如用户从 Ethereum 切到 Polygon),必须在 writeContract 里显式传入 chain,否则 Viem 会用默认链,导致交易失败。
4. 监听事件:从 contract.on 到 watchContractEvent
项目里有个功能:监听 swap 事件来更新用户余额。ether.js 里用 contract.on("Swap", callback) 非常方便,但 Viem 里没有这种"持久化监听"方式。
Viem 提供了 watchContractEvent 函数,但它返回的是一个 unwatch 函数,需要手动管理生命周期:
typescript
import { useCallback, useEffect, useRef } from 'react';
import { type Log } from 'viem';
function useSwapEvent(
publicClient: PublicClient,
routerAddress: `0x${string}`,
userAddress: `0x${string}`
) {
const unwatchRef = useRef<(() => void) | null>(null);
const startWatching = useCallback(() => {
// 清除旧监听
if (unwatchRef.current) {
unwatchRef.current();
}
// 开始监听
const unwatch = publicClient.watchContractEvent({
address: routerAddress,
abi: routerABI,
eventName: 'Swap',
args: {
// 只监听和当前用户相关的事件
sender: userAddress,
},
onLogs: (logs: Log[]) => {
logs.forEach((log) => {
console.log('收到 swap 事件:', log);
// 这里更新 UI
updateUserBalance(userAddress);
});
},
});
unwatchRef.current = unwatch;
}, [publicClient, routerAddress, userAddress]);
const stopWatching = useCallback(() => {
if (unwatchRef.current) {
unwatchRef.current();
unwatchRef.current = null;
}
}, []);
// 组件挂载时开始监听,卸载时停止
useEffect(() => {
startWatching();
return stopWatching;
}, [startWatching, stopWatching]);
return { startWatching, stopWatching };
}
这里有个坑 :watchContractEvent 的 args 参数支持过滤,但必须和合约事件定义的参数名完全一致。我一开始写成 { from: userAddress },但合约事件里参数名是 sender,结果事件一个都没收到。Viem 不会报错,只是静默地不匹配。
另一个坑是性能问题。如果 onLogs 回调里做了大量计算或状态更新,可能会导致 UI 卡顿。建议在回调里只做轻量操作,或者用 useCallback + useRef 来避免不必要的重渲染。
5. 处理多链:从 provider.getNetwork 到 publicClient.chain
项目支持三条链,用户经常需要切换。在 ethers.js 里,我通过 provider.getNetwork() 获取当前链 ID,然后根据链 ID 切换 provider。
Viem 里更简洁:publicClient.chain 直接返回当前链的配置对象,包含 chain ID、name、nativeCurrency 等信息。
typescript
import { type Chain } from 'viem';
import { mainnet, polygon, arbitrum } from 'viem/chains';
// 定义支持的链
const SUPPORTED_CHAINS: Chain[] = [mainnet, polygon, arbitrum];
function getChainById(chainId: number): Chain | undefined {
return SUPPORTED_CHAINS.find((chain) => chain.id === chainId);
}
// 切换链时重新创建客户端
function createClientsForChain(chain: Chain, rpcUrl: string) {
const publicClient = createPublicClient({
chain,
transport: http(rpcUrl),
});
const walletClient = createWalletClient({
chain,
transport: custom(window.ethereum!),
});
return { publicClient, walletClient };
}
注意这个细节 :Viem 的 Chain 类型包含了很多信息,比如 chain.nativeCurrency.symbol 可以直接用来显示代币符号,不需要硬编码。这在 ethers.js 里需要手动维护一个映射表。
完整代码
下面是一个完整的 React 组件示例,演示了如何使用 Viem 实现 swap 交易的全流程(模拟、估算 gas、发送、监听):
typescript
// SwapComponent.tsx
import React, { useState, useCallback } from 'react';
import {
createPublicClient,
createWalletClient,
custom,
http,
parseEther,
formatEther,
maxUint256,
type Hash,
type PublicClient,
type WalletClient,
} from 'viem';
import { mainnet } from 'viem/chains';
// 假设的 Uniswap V2 Router ABI(简化版)
const routerABI = [
{
inputs: [
{ name: 'amountIn', type: 'uint256' },
{ name: 'amountOutMin', type: 'uint256' },
{ name: 'path', type: 'address[]' },
{ name: 'to', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
name: 'swapExactTokensForTokens',
outputs: [{ name: 'amounts', type: 'uint256[]' }],
stateMutability: 'nonpayable',
type: 'function',
},
{
anonymous: false,
inputs: [
{ indexed: true, name: 'sender', type: 'address' },
{ indexed: true, name: 'to', type: 'address' },
{ indexed: false, name: 'amount0In', type: 'uint256' },
{ indexed: false, name: 'amount1In', type: 'uint256' },
{ indexed: false, name: 'amount0Out', type: 'uint256' },
{ indexed: false, name: 'amount1Out', type: 'uint256' },
],
name: 'Swap',
type: 'event',
},
];
const ROUTER_ADDRESS = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'; // Uniswap V2 Router
export default function SwapComponent() {
const [txHash, setTxHash] = useState<Hash | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 初始化客户端
const publicClient = createPublicClient({
chain: mainnet,
transport: http(process.env.NEXT_PUBLIC_MAINNET_RPC_URL),
});
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum!),
});
const handleSwap = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
// 获取用户地址
const [address] = await walletClient.requestAddresses();
if (!address) throw new Error('未连接钱包');
// 模拟交易
const { request } = await publicClient.simulateContract({
address: ROUTER_ADDRESS,
abi: routerABI,
functionName: 'swapExactTokensForTokens',
args: [
parseEther('0.1'), // 假设输入 0.1 ETH
0n, // amountOutMin 设为 0
['0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', '0xdAC17F958D2ee523a2206206994597C13D831ec7'], // WETH -> USDT
address,
maxUint256,
],
account: address,
});
// 估算 gas
const gasEstimate = await publicClient.estimateContractGas(request);
const safeGasLimit = (gasEstimate * 130n) / 100n; // 加 30% 安全系数
// 发送交易
const hash: Hash = await walletClient.writeContract({
...request,
gas: safeGasLimit,
});
setTxHash(hash);
// 等待确认
const receipt = await publicClient.waitForTransactionReceipt({
hash,
confirmations: 1,
});
console.log('交易成功:', receipt.transactionHash);
} catch (err: any) {
setError(err.message || '交易失败');
console.error('Swap 错误:', err);
} finally {
setIsLoading(false);
}
}, [publicClient, walletClient]);
return (
<div>
<button onClick={handleSwap} disabled={isLoading}>
{isLoading ? '交易中...' : 'Swap 0.1 ETH -> USDT'}
</button>
{txHash && <p>交易哈希: {txHash}</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
踩坑记录
-
estimateContractGas需要request对象,而不是直接传参数- 报错:
TypeError: Cannot read properties of undefined (reading 'abi') - 解决方法:先调用
simulateContract拿到request,再传给estimateContractGas
- 报错:
-
watchContractEvent的args参数名必须和合约 ABI 完全一致- 报错:事件监听没任何回调触发,也不报错
- 解决方法:仔细对比合约事件定义中的参数名,大小写和命名都要一致
-
writeContract的gas参数在 Viem 里是bigint,不是number- 报错:
TypeError: Cannot convert a BigInt value to a number - 解决方法:确保所有 gas 相关值都是
bigint类型,用n后缀或BigInt()转换
- 报错:
-
切换链后必须重新创建
walletClient,否则chain信息会过时- 报错:交易发送后提示 chain ID 不匹配
- 解决方法:在
window.ethereum.on('chainChanged')回调里销毁旧客户端并创建新客户端
小结
从 ethers.js 迁移到 Viem 的过程,本质上是从"魔法方法"转向"显式操作" 。Viem 没有隐藏任何复杂性,所有步骤都需要你明确指定。虽然初期学习曲线陡峭,但换来的是更好的类型安全、更小的包体积和更清晰的代码逻辑。如果你正在迁移,建议从 simulateContract 和 writeContract 这两个核心函数入手,理解它们如何替代 ethers.js 的 contract.function()。