Ethers.js 与 Viem
问题
前端 DApp 如何与区块链交互?Ethers.js 和 Viem 有什么区别?如何选择?
前端 DApp 怎么跟区块链交互? 核心概念两个库都一样:
- Provider:只读连接,用来查余额/调 view 函数。ethers 叫
JsonRpcProvider,viem 叫PublicClient。 - Signer / WalletClient:能发交易、能签名,需要钱包授权。
- Contract 实例:传入 ABI + 地址后获得 typed 对象,
contract.balanceOf(addr)等价于发起eth_call。 - ABI 编解码:函数选择器 =
keccak256("transfer(address,uint256)")前 4 个字节,参数严格 ABI 编码;事件日志要解码 topics + data。
Ethers.js 和 Viem 有啥区别?
- API 风格:ethers 是 OO 风格(
new Contract(...)),viem 是函数式(readContract({ ... }))。 - 类型:viem 全部 ABI 推导函数名/参数类型,写 IDE 提示越谱;ethers v6 也在完善但不及 viem。
- 体积/性能:viem Tree-shake 很净,gas 估算/重试等默认行为更现代。
- 生态:ethers 老牌、文档多;viem 是 Wagmi v2 的底层,是现在的主流选择。
怎么选? 新项目优先 viem + wagmi;老项目以 ethers 为主、跟某个库必须配使的场景(如某些 SDK)继续用 ethers v6。
答案
Ethers.js 和 Viem 是前端与以太坊(及 EVM 兼容链)交互的两大主流库。Ethers.js 是老牌库,生态成熟;Viem 是新一代库,TypeScript 优先,性能更优。两者都提供了连接区块链、读取数据、发送交易、调用智能合约等核心能力。
Ethers.js 之于以太坊,就像 Axios 之于 HTTP —— 提供了一套简洁的 API 来完成所有链上操作。Viem 则是它的"下一代替代品",类似于 fetch 之于 Axios,更轻量、类型更安全。
Ethers.js v6 核心概念
Provider —— 只读连接
Provider 是连接区块链的只读接口,用于查询链上数据(余额、区块、交易等),不能发送交易。
import { JsonRpcProvider, BrowserProvider } from 'ethers';
// 1. 通过 RPC URL 连接(后端或无钱包场景)
const provider = new JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY');
// 2. 通过浏览器钱包连接(前端 DApp)
const browserProvider = new BrowserProvider(window.ethereum);
// 读取链上数据
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance('0x...');
const network = await provider.getNetwork();
JsonRpcProvider:通过 HTTP/WebSocket RPC 连接,适用于后端或不需要钱包的场景BrowserProvider:包装window.ethereum(EIP-1193 Provider),适用于前端 DAppInfuraProvider/AlchemyProvider:内置节点服务商的快捷连接
Signer —— 签名者
Signer 代表一个以太坊账户,拥有私钥,可以签名消息和发送交易。通常通过钱包连接获取。
import { BrowserProvider } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
// 请求用户连接钱包,获取 Signer
const signer = await provider.getSigner();
// Signer 可以做 Provider 能做的一切,还可以:
const address = await signer.getAddress();
const tx = await signer.sendTransaction({
to: '0x...',
value: parseEther('0.1'),
});
await tx.wait(); // 等待交易确认
- Provider:只读,不需要私钥,任何人都可以创建
- Signer:可读写,需要私钥(通常来自钱包),代表一个具体账户
- 调用合约的
view/pure方法只需 Provider;调用状态修改方法需要 Signer
Contract —— 合约实例
Contract 是与链上智能合约交互的核心对象。创建时需要合约地址、ABI 和 Provider/Signer。
import { Contract, BrowserProvider, parseEther } from 'ethers';
const ERC20_ABI = [
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
'event Transfer(address indexed from, address indexed to, uint256 value)',
];
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// 只读合约实例(用 Provider)
const readContract = new Contract('0xContractAddress', ERC20_ABI, provider);
const balance = await readContract.balanceOf('0xUserAddress');
// 可写合约实例(用 Signer)
const writeContract = new Contract('0xContractAddress', ERC20_ABI, signer);
const tx = await writeContract.transfer('0xRecipient', parseEther('10'));
await tx.wait();
ABI 编码与解码
Interface 类提供了底层的 ABI 编码/解码能力,适用于需要手动构建交易 data 的场景。
import { Interface, parseEther } from 'ethers';
const iface = new Interface([
'function transfer(address to, uint256 amount) returns (bool)',
]);
// 编码函数调用数据(构建交易的 data 字段)
const data = iface.encodeFunctionData('transfer', [
'0xRecipient',
parseEther('10'),
]);
// data = '0xa9059cbb000000000000000000...'
// 解码返回值
const result = iface.decodeFunctionResult('transfer', returnData);
console.log(result[0]); // true
Viem 核心概念
Viem 是一个 TypeScript 优先的以太坊交互库,由 wagmi 团队开发,设计理念是模块化、类型安全、Tree-Shakeable。
PublicClient —— 读操作
PublicClient 对应 Ethers.js 的 Provider,用于只读的链上数据查询。
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_KEY'),
});
const blockNumber = await publicClient.getBlockNumber();
const balance = await publicClient.getBalance({
address: '0x...',
});
WalletClient —— 写操作
WalletClient 对应 Ethers.js 的 Signer,用于发送交易和签名。
import { createWalletClient, custom } from 'viem';
import { mainnet } from 'viem/chains';
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum), // 连接浏览器钱包
});
const [address] = await walletClient.getAddresses();
const hash = await walletClient.sendTransaction({
account: address,
to: '0x...',
value: parseEther('0.1'),
});
Transport —— 传输层
Transport 定义了与节点通信的方式,Viem 将其抽象为可插拔的传输层。
import { http, webSocket, fallback } from 'viem';
// HTTP 传输(最常用)
const httpTransport = http('https://rpc.example.com');
// WebSocket 传输(适合事件监听)
const wsTransport = webSocket('wss://rpc.example.com');
// 回退传输(主 RPC 不可用时自动切换)
const fallbackTransport = fallback([
http('https://primary-rpc.com'),
http('https://fallback-rpc.com'),
]);
Chain 定义
Viem 内置了主流链的完整定义(链 ID、RPC、区块浏览器等),提供了强大的类型提示。
import { mainnet, polygon, arbitrum } from 'viem/chains';
import { defineChain } from 'viem';
// 使用内置链定义
console.log(mainnet.id); // 1
console.log(polygon.id); // 137
// 自定义链
const myChain = defineChain({
id: 12345,
name: 'My Chain',
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
rpcUrls: {
default: { http: ['https://rpc.mychain.com'] },
},
});
Ethers.js vs Viem 对比
| 维度 | Ethers.js v6 | Viem |
|---|---|---|
| API 风格 | 面向对象(new Contract(...)) | 函数式(readContract(...)) |
| TypeScript | 支持但类型推断有限 | TypeScript 优先,ABI 类型自动推断 |
| Tree Shaking | 较差,全量引入 | 优秀,按需引入 |
| Bundle Size | ~120KB(min+gzip) | ~35KB(min+gzip) |
| 合约类型安全 | 需额外工具(TypeChain) | ABI 自动推断参数/返回值类型 |
| 错误处理 | 字符串形式的 revert reason | 结构化错误,自定义错误解码 |
| 生态成熟度 | 非常成熟,文档丰富 | 较新但增长快,wagmi 生态 |
| 学习曲线 | 较低,概念直观 | 中等,需理解 Client/Transport |
| 适用场景 | 快速原型、已有项目 | 新项目、注重类型安全和性能 |
常用操作代码对比
创建连接
- Ethers.js
- Viem
import { JsonRpcProvider, BrowserProvider } from 'ethers';
// RPC 连接
const provider = new JsonRpcProvider('https://rpc.example.com');
// 浏览器钱包
const browserProvider = new BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
import { createPublicClient, createWalletClient, http, custom } from 'viem';
import { mainnet } from 'viem/chains';
// 只读 Client
const publicClient = createPublicClient({
chain: mainnet,
transport: http('https://rpc.example.com'),
});
// 钱包 Client
const walletClient = createWalletClient({
chain: mainnet,
transport: custom(window.ethereum),
});
读取余额
- Ethers.js
- Viem
import { formatEther } from 'ethers';
const balance = await provider.getBalance('0xAddress');
console.log(formatEther(balance)); // '1.5'
import { formatEther } from 'viem';
const balance = await publicClient.getBalance({
address: '0xAddress',
});
console.log(formatEther(balance)); // '1.5'
调用合约方法
- Ethers.js
- Viem
import { Contract } from 'ethers';
const abi = ['function balanceOf(address) view returns (uint256)'];
const contract = new Contract('0xToken', abi, provider);
// 读取(view 方法)
const balance = await contract.balanceOf('0xUser');
// 写入(需要 signer)
const writeContract = new Contract('0xToken', abi, signer);
const tx = await writeContract.transfer('0xTo', parseEther('10'));
await tx.wait();
import { parseAbi, parseEther } from 'viem';
const abi = parseAbi([
'function balanceOf(address) view returns (uint256)',
'function transfer(address, uint256) returns (bool)',
]);
// 读取
const balance = await publicClient.readContract({
address: '0xToken',
abi,
functionName: 'balanceOf',
args: ['0xUser'], // TypeScript 自动推断参数类型
});
// 写入
const hash = await walletClient.writeContract({
address: '0xToken',
abi,
functionName: 'transfer',
args: ['0xTo', parseEther('10')],
});
// 等待交易确认
const receipt = await publicClient.waitForTransactionReceipt({ hash });
监听事件
- Ethers.js
- Viem
const contract = new Contract('0xToken', abi, provider);
// 监听 Transfer 事件
contract.on('Transfer', (from, to, value, event) => {
console.log(`${from} -> ${to}: ${formatEther(value)}`);
});
// 停止监听
contract.removeAllListeners('Transfer');
// 监听 Transfer 事件
const unwatch = publicClient.watchContractEvent({
address: '0xToken',
abi,
eventName: 'Transfer',
onLogs: (logs) => {
for (const log of logs) {
console.log(`${log.args.from} -> ${log.args.to}: ${log.args.value}`);
// args 具有完整类型提示
}
},
});
// 停止监听
unwatch();
查询历史日志
- Ethers.js
- Viem
const contract = new Contract('0xToken', abi, provider);
// 查询历史 Transfer 事件
const filter = contract.filters.Transfer('0xFrom', null);
const logs = await contract.queryFilter(filter, 18000000, 18001000);
for (const log of logs) {
console.log(log.args.from, log.args.to, log.args.value);
}
const logs = await publicClient.getContractEvents({
address: '0xToken',
abi,
eventName: 'Transfer',
args: { from: '0xFrom' },
fromBlock: 18000000n,
toBlock: 18001000n,
});
for (const log of logs) {
console.log(log.args.from, log.args.to, log.args.value);
}
ABI 详解
ABI(Application Binary Interface) 是智能合约的接口描述,定义了合约有哪些函数、参数类型、返回值类型和事件。前端必须通过 ABI 才能正确地编码调用数据和解码返回结果。
JSON ABI vs Human-Readable ABI
- JSON ABI
- Human-Readable ABI
[
{
"type": "function",
"name": "transfer",
"inputs": [
{ "name": "to", "type": "address" },
{ "name": "amount", "type": "uint256" }
],
"outputs": [{ "name": "", "type": "bool" }],
"stateMutability": "nonpayable"
},
{
"type": "event",
"name": "Transfer",
"inputs": [
{ "name": "from", "type": "address", "indexed": true },
{ "name": "to", "type": "address", "indexed": true },
{ "name": "value", "type": "uint256", "indexed": false }
]
}
]
// Ethers.js 和 Viem 都支持 Human-Readable ABI
const abi = [
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
'event Transfer(address indexed from, address indexed to, uint256 value)',
];
- 可读性强:一行一个函数/事件,一目了然
- 体积更小:相比 JSON ABI 减少约 80% 的字符数
- Ethers.js 和 Viem 均支持:Viem 通过
parseAbi()解析,还能自动推断 TypeScript 类型
ABI 编码原理
ABI 编码将函数调用转换为 EVM 能理解的字节序列:
0xa9059cbb ← 函数选择器(前 4 字节 = keccak256("transfer(address,uint256)"))
0000000000000000000000001234567890abcdef1234567890abcdef12345678 ← 参数1: address (32字节右对齐)
0000000000000000000000000000000000000000000000008ac7230489e80000 ← 参数2: uint256 (10 * 10^18)
事件监听与过滤
事件(Event)是智能合约通知外部世界的机制,前端通过监听事件来实时更新 UI。
Filter 过滤器
过滤器允许你精确筛选感兴趣的事件:
// 只关注特定地址发出的 Transfer 事件
const logs = await publicClient.getContractEvents({
address: '0xToken',
abi,
eventName: 'Transfer',
args: {
from: '0xSpecificSender', // indexed 参数可以过滤
// to: undefined // 不过滤接收者
},
fromBlock: 18000000n, // 起始区块
toBlock: 'latest', // 截止区块
});
indexed参数(最多 3 个)存储在日志的topics中,可以被过滤- 非
indexed参数存储在日志的data中,不能被过滤,需要在客户端解码后筛选 - 将高频查询的字段标记为
indexed可以大幅提升检索效率
错误处理
Revert Reason 解析
当合约执行失败时,通常会返回一个 revert reason(回退原因)。
- Ethers.js
- Viem
try {
const tx = await contract.transfer('0xTo', parseEther('999999'));
await tx.wait();
} catch (error: any) {
// Ethers.js 将 revert reason 放在 error.reason 中
console.log(error.reason); // 'ERC20: transfer amount exceeds balance'
console.log(error.code); // 'CALL_EXCEPTION'
}
import { ContractFunctionRevertedError, BaseError } from 'viem';
try {
await walletClient.writeContract({
address: '0xToken', abi,
functionName: 'transfer',
args: ['0xTo', parseEther('999999')],
});
} catch (err) {
if (err instanceof BaseError) {
const revertError = err.walk(
(e) => e instanceof ContractFunctionRevertedError
);
if (revertError instanceof ContractFunctionRevertedError) {
console.log(revertError.data?.errorName); // 自定义错误名
console.log(revertError.data?.args); // 错误参数
}
}
}
自定义错误(Solidity 0.8.4+)
现代 Solidity 合约使用自定义错误代替字符串 revert reason,节省 Gas:
// Solidity
error InsufficientBalance(address account, uint256 balance, uint256 required);
function transfer(address to, uint256 amount) external {
if (balanceOf(msg.sender) < amount) {
revert InsufficientBalance(msg.sender, balanceOf(msg.sender), amount);
}
}
Viem 能自动解码自定义错误并提供类型安全的错误参数,Ethers.js 需要手动用 Interface.parseError() 解析。
常见面试问题
Q1: Ethers.js 中 Provider 和 Signer 的区别是什么?
答案:
- Provider 是区块链的只读连接,不关联任何账户,用于查询数据(余额、区块、合约 view 方法等)
- Signer 关联一个以太坊账户(拥有私钥),可以签名消息和发送交易
- 创建合约实例时传入 Provider 只能调用
view/pure方法;传入 Signer 才能调用状态修改方法 BrowserProvider.getSigner()从钱包获取 Signer,JsonRpcProvider本身就是 Provider
Q2: 什么是 ABI?为什么前端调用合约必须要 ABI?
答案:
ABI(Application Binary Interface)是智能合约的接口描述,定义了函数签名、参数类型、返回值类型和事件。前端需要 ABI 的原因:
- 编码调用数据:EVM 不理解函数名,前端需要将
transfer('0x...', 100)编码为0xa9059cbb...这样的字节序列 - 解码返回值:EVM 返回的是原始字节,需要 ABI 才能解码为可读的值
- 事件解析:合约日志是编码的,需要 ABI 中的事件定义才能解析
topics和data - 类型安全:Viem 利用 ABI 推断 TypeScript 类型,在编译时就能检查参数错误
Q3: Ethers.js 和 Viem 该如何选择?
答案:
| 场景 | 推荐 |
|---|---|
| 新项目,使用 wagmi | Viem(wagmi 底层就是 Viem) |
| 注重 TypeScript 类型安全 | Viem(ABI 类型自动推断) |
| 已有项目使用 Ethers.js | Ethers.js(迁移成本高) |
| 快速原型、学习阶段 | Ethers.js(文档更丰富、社区示例更多) |
| 关注包体积 | Viem(~35KB vs ~120KB) |
Q4: Viem 的 readContract 和 writeContract 有什么区别?
答案:
readContract:调用合约的view/pure方法,不发送交易,不消耗 Gas,通过PublicClient调用writeContract:调用合约的状态修改方法,发送交易,消耗 Gas,通过WalletClient调用readContract使用eth_callRPC 方法;writeContract使用eth_sendTransactionwriteContract返回交易哈希,需要用publicClient.waitForTransactionReceipt()等待确认
Q5: 什么是 Human-Readable ABI?有什么优势?
答案:
Human-Readable ABI 是 Ethers.js 引入的简化 ABI 格式,用一行字符串描述一个函数或事件:
// JSON ABI 需要 10+ 行描述一个函数
// Human-Readable ABI 只需 1 行
const abi = ['function transfer(address to, uint256 amount) returns (bool)'];
优势:可读性强、体积小(减少约 80%)、Ethers.js 和 Viem 都支持。Viem 的 parseAbi() 还能从中推断完整的 TypeScript 类型。
Q6: 如何监听合约事件?实时监听和查询历史事件有什么区别?
答案:
- 实时监听:使用 WebSocket 连接或轮询,监听新产生的事件。Ethers.js 用
contract.on(),Viem 用watchContractEvent() - 查询历史事件:指定
fromBlock/toBlock范围查询已发生的事件。Ethers.js 用contract.queryFilter(),Viem 用getContractEvents() - 实时监听适合 UI 实时更新(如交易通知);历史查询适合数据展示(如交易记录列表)
- WebSocket 传输比 HTTP 轮询更高效、延迟更低,推荐用于事件监听
Q7: 事件中的 indexed 参数有什么作用?
答案:
indexed参数存储在日志的topics中(最多 3 个),可以被 RPC 节点直接过滤- 非
indexed参数存储在日志的data中,需要下载后在客户端解码筛选 - 例如
Transfer(address indexed from, address indexed to, uint256 value)中,可以按from或to地址过滤,但不能直接按value过滤 - 将高频查询字段标记为
indexed可以大幅减少数据传输量
Q8: 合约调用失败时如何获取 revert reason?
答案:
合约执行失败会 revert 并返回原因。获取方式:
- 字符串 Reason(
require(condition, "reason")):Ethers.js 在error.reason中;Viem 在ContractFunctionRevertedError中 - 自定义错误(Solidity 0.8.4+):
revert CustomError(arg1, arg2)—— 更省 Gas,Viem 可自动解码错误名和参数 - 无 Reason 的 revert:可能是
assert失败或手动revert(),只能看到 CALL_EXCEPTION
推荐在合约中使用自定义错误,前端配合 Viem 可获得完整的类型安全错误处理。
Q9: Viem 的 Transport 有哪些类型?如何实现 RPC 容错?
答案:
Viem 支持多种 Transport 类型:
http(url):HTTP JSON-RPC,最常用webSocket(url):WebSocket,适合事件监听custom(provider):自定义 EIP-1193 Provider(如window.ethereum)fallback([...transports]):回退机制,主 RPC 失败自动切换备用
// RPC 容错:主节点不可用时自动切换
const client = createPublicClient({
chain: mainnet,
transport: fallback([
http('https://primary-rpc.com'),
http('https://secondary-rpc.com'),
http('https://public-rpc.com'), // 最后兜底
]),
});
Q10: Ethers.js v5 和 v6 有哪些重要变化?
答案:
| 变化 | v5 | v6 |
|---|---|---|
| BigNumber | 自定义 BigNumber 类 | 使用原生 bigint |
| Provider | ethers.providers.Web3Provider | BrowserProvider |
| 工具函数 | ethers.utils.parseEther() | 直接导入 parseEther() |
| 合约事件 | contract.on('Transfer', ...) | 不变 |
| 包结构 | 单一入口 ethers | 支持子路径导入 |
最大变化是用原生 bigint 替代了自定义的 BigNumber,减少了包体积并提升了性能。
Q11: 前端如何与不同链交互?
答案:
与不同链交互的关键是配置正确的 Chain ID 和 RPC URL。在 Viem 中,通过 chain 参数指定链:
import { polygon, arbitrum } from 'viem/chains';
// Polygon 链
const polygonClient = createPublicClient({
chain: polygon,
transport: http(),
});
// Arbitrum 链
const arbClient = createPublicClient({
chain: arbitrum,
transport: http(),
});
连接钱包时,还需要请求用户切换网络(wallet_switchEthereumChain),确保钱包和前端在同一条链上。
Q12: 调用合约方法时 estimateGas 有什么用?
答案:
estimateGas 用于在发送交易前预估所需 Gas 量:
- 检查交易是否会成功:如果 estimateGas 失败,说明交易一定会 revert(可以提前告知用户)
- 设置合理的 Gas Limit:避免设置过低导致交易失败,或过高浪费 Gas
- 费用预览:结合当前 Gas Price,计算交易费用展示给用户
// Viem
const gas = await publicClient.estimateContractGas({
address: '0xToken', abi,
functionName: 'transfer',
args: ['0xTo', parseEther('10')],
account: '0xFrom',
});