背景
上个月我接手了一个老牌的DeFi收益聚合器项目的前端维护工作。这个项目大概两年前开发的,前端核心库用的是 ethers.js v5,配合着一些自定义的 Provider 封装和事件轮询逻辑。刚开始只是修几个小 bug,但当我需要添加对新链(比如 Arbitrum)的支持时,问题就来了。
老代码里到处都是 new ethers.providers.JsonRpcProvider() 的硬编码,钱包连接逻辑和业务逻辑耦合得很深,添加一个新链得改七八个文件。更头疼的是,项目里有些自定义的 BigNumber 处理逻辑在 ethers.js v6 里已经不兼容了,升级版本风险太大。就在我纠结是硬着头皮重构老代码,还是找个新方案时,团队里另一个在做新项目的同事提到了 Viem,说它类型安全、模块化,而且和 Wagmi 搭配起来开发效率很高。我研究了一下,决定拿一个相对独立的功能模块------用户质押和领取奖励的页面------作为"试验田",尝试用 Viem 彻底替换掉 ethers.js。
问题分析
我选择的功能模块主要做三件事:
- 读取用户在当前链上的质押余额和待领取奖励。
- 让用户进行质押(调用合约的
stake方法)。 - 让用户领取奖励(调用合约的
claimRewards方法)。
用 ethers.js 的老代码大概是这样的骨架:
javascript
import { ethers } from 'ethers';
import stakingABI from './abis/staking.json';
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const stakingContract = new ethers.Contract(STAKING_ADDRESS, stakingABI, signer);
// 读取数据
const userBalance = await stakingContract.balanceOf(userAddress);
const pendingRewards = await stakingContract.earned(userAddress);
// 发送交易
const stakeTx = await stakingContract.stake(amount);
await stakeTx.wait();
思路很直接,但问题也很明显:Provider 和 Signer 的创建与钱包状态绑定死,ABI 管理松散,错误处理简陋。我的迁移目标很明确:用 Viem 的 PublicClient 和 WalletClient 来分离读和写,用 TypeScript 生成强类型的合约接口,并整合进现有的 React 上下文里。
一开始我以为就是简单的 API 替换,但真正动手才发现,从"面向对象"的 ethers.js 思维切换到"函数式"的 Viem 思维,以及处理两者在数据类型(尤其是 BigNumber 和 bigint)上的差异,才是真正的挑战。
核心实现
第一步:搭建 Viem 客户端与替换读取逻辑
首先,我安装了必要的包:viem 和 @wagmi/core(为了复用项目已有的 Wagmi 配置)。我的策略是,先不碰钱包连接和交易发送,只把数据读取的部分换掉。
在 ethers.js 里,一个 Provider 既负责读也负责写(通过 Signer)。Viem 则明确分成了 PublicClient(读)和 WalletClient(写)。我创建了一个公共的读取客户端:
typescript
// src/lib/viemClient.ts
import { createPublicClient, http, PublicClient } from 'viem';
import { mainnet, arbitrum } from 'viem/chains'; // 从老配置里拿到链信息
// 根据当前链ID创建对应的客户端
export function getPublicClient(chainId: number): PublicClient {
const chain = [mainnet, arbitrum].find(c => c.id === chainId) || mainnet;
return createPublicClient({
chain,
transport: http(), // 这里先用公开RPC,后面可以替换成项目自己的节点
});
}
接下来是重头戏:合约调用。我不想再手动管理 ABI JSON 文件了。Viem 鼓励使用 @wagmi/cli 或 abitype 来生成类型。我用了更直接的方式,利用 Viem 的 createContractFunctionArgs 思路,手动为我的质押合约定义了一个类型化的"读"对象。这里有个坑 :Viem 的合约函数返回的数值类型默认是 bigint,而我的前端界面渲染逻辑到处都在用 ethers.utils.formatUnits 来处理 BigNumber。我必须统一处理这个转换。
typescript
// src/contracts/stakingContract.ts
import { getPublicClient } from '@/lib/viemClient';
import stakingABI from './abis/staking.json' assert { type: 'json' }; // 暂时沿用老ABI
export const STAKING_ADDRESS = '0x...'; // 合约地址
// 封装一个类型安全的读取函数
export async function getUserStakingInfo(userAddress: `0x${string}`, chainId: number) {
const publicClient = getPublicClient(chainId);
// 注意:这里返回的是 bigint
const [balance, rewards] = await Promise.all([
publicClient.readContract({
address: STAKING_ADDRESS,
abi: stakingABI,
functionName: 'balanceOf',
args: [userAddress],
}) as Promise<bigint>,
publicClient.readContract({
address: STAKING_ADDRESS,
abi: stakingABI,
functionName: 'earned',
args: [userAddress],
}) as Promise<bigint>,
]);
// 统一转换:bigint -> 格式化的字符串(这里假设代币精度为18)
const formatBigInt = (value: bigint) => Number(value) / 10**18; // 简单处理,生产环境建议用库
return {
balance: formatBigInt(balance),
pendingRewards: formatBigInt(rewards),
};
}
在 React 组件里,我就可以把老的 ethers 调用替换成:
typescript
// 老代码
// const balance = await stakingContract.balanceOf(address);
// 新代码
const { balance, pendingRewards } = await getUserStakingInfo(address, chainId);
第一步很顺利,界面数据显示正常。这给了我很大信心。
第二步:处理钱包连接与交易发送
这是最核心也最容易出错的部分。在 ethers.js 里,我们从 window.ethereum 创建 Provider,然后 getSigner()。Viem 的 WalletClient 概念类似,但创建方式更多样。我选择与项目已有的 Wagmi 连接器集成,通过 Wagmi 的 useAccount 和 useWalletClient 钩子来获取。
这里有个关键细节 :Viem 的 writeContract 方法返回的是交易哈希(0x${string}),而不是一个像 ethers.js 那样的交易对象(包含 wait 方法)。你需要用 PublicClient 的 waitForTransactionReceipt 来等待交易确认。
typescript
// src/hooks/useStakingAction.ts
import { useAccount, useWalletClient } from 'wagmi';
import { getPublicClient } from '@/lib/viemClient';
import { STAKING_ADDRESS, stakingABI } from '@/contracts/stakingContract';
export function useStakingAction() {
const { address, chainId } = useAccount();
const { data: walletClient } = useWalletClient();
const stake = async (amount: bigint) => {
if (!walletClient || !address) throw new Error('钱包未连接');
try {
// 1. 发送交易,获取哈希
const hash = await walletClient.writeContract({
address: STAKING_ADDRESS,
abi: stakingABI,
functionName: 'stake',
args: [amount],
account: address,
});
console.log('交易哈希:', hash);
// 2. 等待交易确认
const publicClient = getPublicClient(chainId!);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log('交易确认,区块号:', receipt.blockNumber);
return receipt;
} catch (error) {
console.error('质押失败:', error);
// 这里可以细化错误处理,比如用户拒绝、gas不足等
throw error;
}
};
const claimRewards = async () => {
// 逻辑类似,调用 `claimRewards` 函数
if (!walletClient || !address) throw new Error('钱包未连接');
const hash = await walletClient.writeContract({
address: STAKING_ADDRESS,
abi: stakingABI,
functionName: 'claimRewards',
account: address,
});
const publicClient = getPublicClient(chainId!);
return await publicClient.waitForTransactionReceipt({ hash });
};
return { stake, claimRewards };
}
在组件中使用就非常清晰了:
tsx
const StakingButton: React.FC = () => {
const [amount, setAmount] = useState('');
const { stake } = useStakingAction();
const handleStake = async () => {
const amountInWei = BigInt(parseFloat(amount) * 10**18); // 转换精度
await stake(amountInWei);
// ... 成功后刷新数据
};
return <button onClick={handleStake}>质押</button>;
};
第三步:集成与错误边界处理
替换了核心逻辑后,我需要把新的 Viem 客户端集成到项目的上下文中,并处理好可能出现的错误。我创建了一个 ViemProvider 上下文,用来在不同的组件中共享 PublicClient 和合约方法。
另外,我遇到了一个非常实际的坑 :合约事件监听。老代码用 ethers.Contract 的 on 方法监听事件来更新 UI。Viem 提供了 watchContractEvent,但它的用法是函数式的,返回一个取消监听的函数,并且需要自己管理生命周期。
typescript
// 在组件或Hook中监听质押事件
useEffect(() => {
if (!address || !chainId) return;
const publicClient = getPublicClient(chainId);
const unwatch = publicClient.watchContractEvent({
address: STAKING_ADDRESS,
abi: stakingABI,
eventName: 'Staked',
args: { user: address }, // 只监听当前用户的事件
onLogs: (logs) => {
console.log('新的质押事件:', logs);
// 触发UI数据更新
refetchUserInfo();
},
onError: (error) => {
console.error('监听事件出错:', error);
}
});
// 组件卸载时取消监听
return () => unwatch();
}, [address, chainId]);
完整代码示例
以下是一个简化但可运行的 React 组件,展示了如何使用我们上面封装的逻辑:
tsx
// src/components/StakingPanel.tsx
import React, { useState, useEffect } from 'react';
import { useAccount } from 'wagmi';
import { getUserStakingInfo } from '@/contracts/stakingContract';
import { useStakingAction } from '@/hooks/useStakingAction';
const StakingPanel: React.FC = () => {
const { address, chainId } = useAccount();
const { stake, claimRewards } = useStakingAction();
const [userInfo, setUserInfo] = useState({ balance: 0, pendingRewards: 0 });
const [stakeAmount, setStakeAmount] = useState('');
const [isLoading, setIsLoading] = useState(false);
// 加载用户数据
const loadUserInfo = async () => {
if (!address || !chainId) return;
setIsLoading(true);
try {
const info = await getUserStakingInfo(address, chainId);
setUserInfo(info);
} catch (error) {
console.error('加载数据失败:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadUserInfo();
}, [address, chainId]);
const handleStake = async () => {
if (!stakeAmount) return;
setIsLoading(true);
try {
const amountInWei = BigInt(Math.floor(parseFloat(stakeAmount) * 10**18));
await stake(amountInWei);
setStakeAmount('');
await loadUserInfo(); // 刷新数据
alert('质押成功!');
} catch (error: any) {
alert(`质押失败: ${error?.shortMessage || error.message}`);
} finally {
setIsLoading(false);
}
};
const handleClaim = async () => {
setIsLoading(true);
try {
await claimRewards();
await loadUserInfo();
alert('领取成功!');
} catch (error: any) {
alert(`领取失败: ${error?.shortMessage || error.message}`);
} finally {
setIsLoading(false);
}
};
return (
<div>
<h2>我的质押</h2>
{isLoading && <p>加载中...</p>}
<p>质押余额: {userInfo.balance}</p>
<p>待领取奖励: {userInfo.pendingRewards}</p>
<div>
<input
type="number"
value={stakeAmount}
onChange={(e) => setStakeAmount(e.target.value)}
placeholder="输入质押数量"
disabled={isLoading}
/>
<button onClick={handleStake} disabled={isLoading}>
质押
</button>
</div>
<button onClick={handleClaim} disabled={isLoading || userInfo.pendingRewards <= 0}>
领取奖励
</button>
</div>
);
};
export default StakingPanel;
踩坑记录
-
bigint序列化错误(JSON.stringify) :这是第一个拦路虎。当我将从 Viem 合约调用中获取的bigint类型的状态直接放入 React 状态或传递给JSON.stringify时,控制台会报错"Do not know how to serialize a BigInt"。解决方法 :在数据层(如getUserStakingInfo函数中)就将其转换为number或string。对于大数,可以使用viem自带的formatUnits函数或转换为字符串value.toString()。 -
钱包客户端(WalletClient)获取为
undefined:在useStakingAction钩子中,useWalletClient()返回的data可能为undefined,尤其是在钱包连接初始状态或切换链时。解决方法:增加严格的空值检查,并在 UI 上给出明确的禁用状态或提示。确保 Wagmi 配置正确,连接器支持当前链。 -
事件监听内存泄漏 :最初我在组件中直接调用
watchContractEvent而没有在useEffect中返回清理函数,导致组件卸载后监听依然存在,控制台会有警告,并可能引发状态更新错误。解决方法 :严格遵守useEffect的生命周期,将watchContractEvent返回的unwatch函数在清理阶段调用。 -
交易模拟错误信息不直观 :
walletClient.writeContract失败时,抛出的错误对象有时很深,直接error.message可能是一串复杂的 RPC 错误。解决方法 :利用 Viem 错误工具类,如parseContractError(在较新版本中)或decodeErrorResult来解析。在实践中,我发现error.shortMessage或error.details通常包含了可读性更强的信息,可以优先展示给用户。
小结
这次迁移就像给老房子换了一套更现代化的水电管道,过程有点折腾,但完成后维护性和扩展性肉眼可见地提升了。Viem 的函数式、类型安全设计,强迫我写出更清晰、解耦的代码。最大的收获不是学会了一个新库的 API,而是理解了如何用"客户端分离"和"类型优先"的思想来构建更健壮的 Web3 前端。下一步,我打算用 @wagmi/cli 来自动生成所有合约的完整类型化接口,彻底告别手写 ABI 导入的日子。