背景
上个月,老板扔给我一个"历史遗留"的DeFi Dashboard项目,说是要加几个新功能。我打开代码一看,好家伙,ethers.js v5,没有TypeScript严格模式,合约调用全是 any 类型,交易状态管理靠手动 setInterval 轮询。最要命的是,用户反馈说在某个链上查余额总是超时,我排查了半天,发现是ethers.js的Provider连接池管理有问题。
我当时就想,与其修修补补,不如直接用Viem重写这部分。Viem是Wagmi团队的新作品,TypeScript原生支持,API设计更现代。但迁移过程远比我想象的复杂------Viem的 PublicClient 和 WalletClient 跟ethers.js的 Provider 和 Signer 完全是两套逻辑。这篇文章就是我踩坑两天的完整记录。
问题分析
我的最初思路很简单:把 new ethers.providers.JsonRpcProvider(rpcUrl) 替换成 createPublicClient,把 signer.connect(provider) 替换成 createWalletClient。结果一跑就报错------Viem不直接支持EIP-1193之外的Provider格式。
更坑的是,ethers.js里 contract.connect(signer) 那种链式调用,在Viem里完全不存在。Viem要求你把钱包客户端和合约配置分开传。我当时就懵了:难道要每个合约调用都重复传参?
排查后发现,Viem的哲学是"显式优于隐式"。它不搞 provider 那种全局状态,而是让你明确指定每一步用哪个客户端。这虽然更安全,但代码结构需要大改。
核心实现
第一步:创建Viem客户端,告别Provider全局变量
在ethers.js里,我习惯这样写:
typescript
// ethers.js 旧代码
const provider = new ethers.providers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/xxx');
const signer = new ethers.Wallet('privateKey', provider);
Viem里可不行。你必须区分"只读"的公共客户端和"可写"的钱包客户端:
typescript
// Viem 新代码
import { createPublicClient, createWalletClient, http } from 'viem';
import { mainnet } from 'viem/chains';
// 公共客户端:只用来查链上数据
export const publicClient = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});
// 钱包客户端:用来发交易,需要钱包适配器
export const walletClient = createWalletClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/xxx'),
});
这里有个坑 :如果你直接用 createWalletClient 并传了 account 参数,那它就是"本地签名"模式。但在前端项目里,通常我们用的是浏览器钱包(如MetaMask),所以不传 account,而是在调用时动态传入。
第二步:合约交互------从Contract对象到直接调用
ethers.js里,合约交互是这样的:
typescript
const contract = new ethers.Contract(address, abi, signer);
const balance = await contract.balanceOf(userAddress);
Viem完全不一样。它没有 Contract 类,而是通过 readContract 和 writeContract 方法直接调用:
typescript
import { getContract, parseAbi } from 'viem';
const abi = parseAbi([
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
]);
// 读操作
const balance = await publicClient.readContract({
address: '0x...',
abi,
functionName: 'balanceOf',
args: [userAddress],
});
// 写操作
const hash = await walletClient.writeContract({
address: '0x...',
abi,
functionName: 'transfer',
args: [toAddress, amount],
account: userAddress, // 必须显式传入
});
注意这个细节 :Viem的 readContract 返回的是 bigint 而不是 BigNumber。我当时没注意,直接拿去渲染UI,结果发现数字显示不对。后来用 formatUnits 转换才解决。
第三步:交易状态监听------从轮询到等待确认
ethers.js里监听交易确认,我原来是这样写的:
typescript
const tx = await contract.transfer(to, amount);
const receipt = await tx.wait(1); // 等1个确认
Viem提供了更细粒度的控制:
typescript
import { waitForTransactionReceipt } from 'viem/actions';
// 发送交易
const hash = await walletClient.sendTransaction({
to: '0x...',
value: parseEther('0.1'),
account: userAddress,
});
// 等待确认
const receipt = await waitForTransactionReceipt(publicClient, {
hash,
confirmations: 1,
pollingInterval: 1000, // 每秒轮询一次
});
console.log('交易确认,区块号:', receipt.blockNumber);
这里有个坑 :waitForTransactionReceipt 默认只等一个确认,如果你需要多个确认,必须显式传 confirmations。而且它返回的 receipt 类型是 TransactionReceipt,比ethers.js的 TransactionReceipt 字段名有差异------Viem里是 blockNumber 而不是 blockNumber(注意大小写,Viem是驼峰)。
第四步:多链支持------从手动切RPC到链配置
原来的项目支持以太坊和Polygon,代码里是:
typescript
const providers = {
ethereum: new ethers.providers.JsonRpcProvider(rpcEthereum),
polygon: new ethers.providers.JsonRpcProvider(rpcPolygon),
};
Viem把链信息封装成了对象,切换起来更优雅:
typescript
import { mainnet, polygon } from 'viem/chains';
export function getClients(chainId: number) {
const chain = chainId === 1 ? mainnet : polygon;
const rpcUrl = chainId === 1 ? 'https://eth-mainnet.g.alchemy.com/v2/xxx' : 'https://polygon-mainnet.g.alchemy.com/v2/xxx';
return {
publicClient: createPublicClient({ chain, transport: http(rpcUrl) }),
walletClient: createWalletClient({ chain, transport: http(rpcUrl) }),
};
}
使用的时候:
typescript
const { publicClient } = getClients(1);
const balance = await publicClient.readContract(...);
注意这个细节 :Viem的 chain 对象里包含了原生币信息、区块浏览器URL等,如果你要用这些元数据,可以直接从 chain.nativeCurrency 获取,不用自己维护配置表了。
完整代码
下面是一个可直接运行的React组件示例,它用Viem读取用户ERC20余额,并支持发送交易:
typescript
// ViemDeFiComponent.tsx
import React, { useState, useEffect } from 'react';
import { createPublicClient, createWalletClient, http, parseErc20Abi, formatUnits, parseEther } from 'viem';
import { mainnet } from 'viem/chains';
import { waitForTransactionReceipt } from 'viem/actions';
// 配置公共客户端
const publicClient = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'), // 替换为你的API key
});
const TOKEN_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'; // USDT合约地址
interface Props {
userAddress: `0x${string}`;
}
export default function ViemDeFiComponent({ userAddress }: Props) {
const [balance, setBalance] = useState<string>('0');
const [txHash, setTxHash] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
// 读取余额
useEffect(() => {
async function fetchBalance() {
try {
const rawBalance = await publicClient.readContract({
address: TOKEN_ADDRESS,
abi: parseErc20Abi,
functionName: 'balanceOf',
args: [userAddress],
});
// 注意:rawBalance是bigint,需要格式化
setBalance(formatUnits(rawBalance, 6)); // USDT是6位小数
} catch (error) {
console.error('读取余额失败:', error);
}
}
fetchBalance();
}, [userAddress]);
// 发送交易(需要钱包连接)
async function handleSendTransaction() {
setIsLoading(true);
try {
// 假设用户已连接钱包,我们通过window.ethereum获取账户
const [account] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const walletClient = createWalletClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
});
// 发送ETH
const hash = await walletClient.sendTransaction({
to: '0x...', // 替换为接收地址
value: parseEther('0.01'),
account: account as `0x${string}`,
});
setTxHash(hash);
// 等待1个确认
const receipt = await waitForTransactionReceipt(publicClient, {
hash,
confirmations: 1,
});
console.log('交易确认,消耗gas:', receipt.gasUsed);
} catch (error) {
console.error('交易失败:', error);
} finally {
setIsLoading(false);
}
}
return (
<div>
<h2>USDT余额: {balance}</h2>
<button onClick={handleSendTransaction} disabled={isLoading}>
{isLoading ? '交易进行中...' : '发送0.01 ETH'}
</button>
{txHash && <p>交易哈希: {txHash}</p>}
</div>
);
}
注意 :上面的 parseErc20Abi 是Viem内置的,它只包含 balanceOf、totalSupply、transfer 等标准ERC20方法。如果你需要自定义方法,得用 parseAbi 自己写。
踩坑记录
-
window.ethereum类型问题 :Viem要求地址类型是0x${string},但window.ethereum.request返回的是string[]。我一开始直接as断言,结果类型检查没过。后来用account as \0x${string}`` 才解决。 -
formatUnits的精度丢失 :用formatUnits(1000000n, 6)返回"1"没问题,但formatUnits(123456789n, 6)返回"123.456789",如果直接渲染到UI会显示很多小数位。我后来用Number(balance).toFixed(2)做二次格式化。 -
waitForTransactionReceipt超时 :默认超时是30秒,如果网络拥堵,交易一直pending,会抛异常。我加了maxWaitTime参数:waitForTransactionReceipt(publicClient, { hash, maxWaitTime: 120_000 })。 -
http传输的速率限制 :我在测试时频繁调用readContract,结果被RPC限流了。Viem支持轮询,但默认没有内置重试逻辑。我后来用viem/actions的getBlockNumber配合setInterval手动控制频率。
小结
Viem的核心优势是类型安全和显式API设计,迁移成本主要在理解"客户端分离"的理念。如果你也在做迁移,建议先把公共客户端和钱包客户端拆清楚,再逐个合约替换。想继续深挖的话,可以研究Viem的 Actions 架构,它能让你自定义交易流程,比ethers.js灵活得多。