跳到主要内容

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.jsViem 是前端与以太坊(及 EVM 兼容链)交互的两大主流库。Ethers.js 是老牌库,生态成熟;Viem 是新一代库,TypeScript 优先,性能更优。两者都提供了连接区块链、读取数据、发送交易、调用智能合约等核心能力。

一句话理解

Ethers.js 之于以太坊,就像 Axios 之于 HTTP —— 提供了一套简洁的 API 来完成所有链上操作。Viem 则是它的"下一代替代品",类似于 fetch 之于 Axios,更轻量、类型更安全。


Ethers.js v6 核心概念

Provider —— 只读连接

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();
Provider 类型
  • JsonRpcProvider:通过 HTTP/WebSocket RPC 连接,适用于后端或不需要钱包的场景
  • BrowserProvider:包装 window.ethereum(EIP-1193 Provider),适用于前端 DApp
  • InfuraProvider / AlchemyProvider:内置节点服务商的快捷连接

Signer —— 签名者

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 vs Signer
  • Provider:只读,不需要私钥,任何人都可以创建
  • Signer:可读写,需要私钥(通常来自钱包),代表一个具体账户
  • 调用合约的 view / pure 方法只需 Provider;调用状态修改方法需要 Signer

Contract —— 合约实例

Contract 是与链上智能合约交互的核心对象。创建时需要合约地址ABIProvider/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 的场景。

ABI 编码与解码
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,用于只读的链上数据查询。

创建 PublicClient
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,用于发送交易和签名。

创建 WalletClient
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 将其抽象为可插拔的传输层。

不同的 Transport 类型
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、区块浏览器等),提供了强大的类型提示。

Chain 定义与自定义链
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 v6Viem
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-provider.ts
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();

读取余额

ethers-balance.ts
import { formatEther } from 'ethers';

const balance = await provider.getBalance('0xAddress');
console.log(formatEther(balance)); // '1.5'

调用合约方法

ethers-contract.ts
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();

监听事件

ethers-events.ts
const contract = new Contract('0xToken', abi, provider);

// 监听 Transfer 事件
contract.on('Transfer', (from, to, value, event) => {
console.log(`${from} -> ${to}: ${formatEther(value)}`);
});

// 停止监听
contract.removeAllListeners('Transfer');

查询历史日志

ethers-logs.ts
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);
}

ABI 详解

ABI(Application Binary Interface) 是智能合约的接口描述,定义了合约有哪些函数、参数类型、返回值类型和事件。前端必须通过 ABI 才能正确地编码调用数据和解码返回结果。

JSON ABI vs Human-Readable ABI

erc20-abi.json
[
{
"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 }
]
}
]
Human-Readable ABI 的优势
  • 可读性强:一行一个函数/事件,一目了然
  • 体积更小:相比 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 参数
  • indexed 参数(最多 3 个)存储在日志的 topics 中,可以被过滤
  • indexed 参数存储在日志的 data 中,不能被过滤,需要在客户端解码后筛选
  • 将高频查询的字段标记为 indexed 可以大幅提升检索效率

错误处理

Revert Reason 解析

当合约执行失败时,通常会返回一个 revert reason(回退原因)。

ethers-error.ts
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'
}

自定义错误(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 的原因:

  1. 编码调用数据:EVM 不理解函数名,前端需要将 transfer('0x...', 100) 编码为 0xa9059cbb... 这样的字节序列
  2. 解码返回值:EVM 返回的是原始字节,需要 ABI 才能解码为可读的值
  3. 事件解析:合约日志是编码的,需要 ABI 中的事件定义才能解析 topicsdata
  4. 类型安全:Viem 利用 ABI 推断 TypeScript 类型,在编译时就能检查参数错误

Q3: Ethers.js 和 Viem 该如何选择?

答案

场景推荐
新项目,使用 wagmiViem(wagmi 底层就是 Viem)
注重 TypeScript 类型安全Viem(ABI 类型自动推断)
已有项目使用 Ethers.jsEthers.js(迁移成本高)
快速原型、学习阶段Ethers.js(文档更丰富、社区示例更多)
关注包体积Viem(~35KB vs ~120KB)

Q4: Viem 的 readContractwriteContract 有什么区别?

答案

  • readContract:调用合约的 view/pure 方法,不发送交易,不消耗 Gas,通过 PublicClient 调用
  • writeContract:调用合约的状态修改方法,发送交易,消耗 Gas,通过 WalletClient 调用
  • readContract 使用 eth_call RPC 方法;writeContract 使用 eth_sendTransaction
  • writeContract 返回交易哈希,需要用 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) 中,可以按 fromto 地址过滤,但不能直接按 value 过滤
  • 将高频查询字段标记为 indexed 可以大幅减少数据传输量

Q8: 合约调用失败时如何获取 revert reason?

答案

合约执行失败会 revert 并返回原因。获取方式:

  1. 字符串 Reasonrequire(condition, "reason")):Ethers.js 在 error.reason 中;Viem 在 ContractFunctionRevertedError
  2. 自定义错误(Solidity 0.8.4+):revert CustomError(arg1, arg2) —— 更省 Gas,Viem 可自动解码错误名和参数
  3. 无 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 有哪些重要变化?

答案

变化v5v6
BigNumber自定义 BigNumber使用原生 bigint
Providerethers.providers.Web3ProviderBrowserProvider
工具函数ethers.utils.parseEther()直接导入 parseEther()
合约事件contract.on('Transfer', ...)不变
包结构单一入口 ethers支持子路径导入

最大变化是用原生 bigint 替代了自定义的 BigNumber,减少了包体积并提升了性能。

Q11: 前端如何与不同链交互?

答案

与不同链交互的关键是配置正确的 Chain IDRPC 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 量:

  1. 检查交易是否会成功:如果 estimateGas 失败,说明交易一定会 revert(可以提前告知用户)
  2. 设置合理的 Gas Limit:避免设置过低导致交易失败,或过高浪费 Gas
  3. 费用预览:结合当前 Gas Price,计算交易费用展示给用户
// Viem
const gas = await publicClient.estimateContractGas({
address: '0xToken', abi,
functionName: 'transfer',
args: ['0xTo', parseEther('10')],
account: '0xFrom',
});

相关链接