Ethers.js 完整教程
从连接钱包到合约交互,掌握 Ethers.js v6 所有核心功能
🎮 实战:ERC20 交互
🎮 ERC20 合约交互演示
1. Ethers.js 简介
Ethers.js 是一个完整且轻量的 JavaScript 库,用于与以太坊区块链交互。
核心特性
- 轻量级 - 压缩后仅 88KB,比 web3.js 小很多
- 完整功能 - 涵盖钱包、合约、Provider、Signer 等所有功能
- TypeScript 原生支持 - 完整的类型定义
- ENS 支持 - 原生支持以太坊域名服务
- 安全性 - 经过大量审计,代码质量高
Ethers.js vs Web3.js
- 体积更小(88KB vs 300KB+)
- API 设计更现代化和一致
- 更好的 TypeScript 支持
- 更活跃的维护(Web3.js 更新较慢)
- 更清晰的 Provider/Signer 分离
版本说明
- 当前最新版本:v6.x(推荐)
- v5 → v6 有破坏性更新,API 变化较大
- 本教程基于 v6.x 编写
2. 安装与设置
使用 npm 或 yarn 安装 ethers.js。
安装
npm install ethers\n# or\nyarn add ethers导入
Ethers.js v6 使用 ES6 模块导入。
import { BrowserProvider, Contract, formatEther, parseEther } from 'ethers';
// Or import everything
import * as ethers from 'ethers';CDN 方式(浏览器)
如果不使用打包工具,可以通过 CDN 直接引入。
<script src="https://cdn.ethers.io/lib/ethers-6.7.0.umd.min.js"></script>
<script>
const { BrowserProvider, Contract } = ethers;
</script>3. EIP-1193 深度解析
EIP-1193 是以太坊钱包与 DApp 通信的标准接口,理解它是掌握前端区块链开发的关键。
什么是 EIP-1193
EIP-1193(Ethereum Provider JavaScript API)定义了浏览器钱包(如 MetaMask)暴露给网页的标准接口。
解决什么问题
统一接口
在 EIP-1193 之前,不同钱包的 API 不一致,导致 DApp 需要适配多种钱包
安全隔离
钱包运行在独立的上下文中,网页无法直接访问私钥
用户授权
所有敏感操作(签名、发送交易)都需要用户在钱包界面确认
事件通知
钱包状态变化(账户切换、网络切换)能实时通知 DApp
核心规范
EIP-1193 规定钱包必须在 window.ethereum 上暴露一个 Provider 对象
// Check if MetaMask is installed
if (typeof window.ethereum !== 'undefined') {
console.log('MetaMask is installed!');
console.log('Provider:', window.ethereum);
}
// window.ethereum is the EIP-1193 Provider object
// It has two core components:
// 1. request() method - all RPC calls
// 2. Event system - accountsChanged, chainChanged, etc.request() 方法
所有与钱包的交互都通过这个方法完成
request(args: { method: string; params?: unknown[] }): Promise<unknown>
常用 RPC 方法
| RPC Method | Description |
|---|---|
eth_requestAccounts | 请求连接钱包(弹出授权窗口) |
eth_accounts | 获取已授权的账户列表 |
eth_chainId | 获取当前链 ID |
eth_sendTransaction | 发送交易 |
eth_sign | 签名消息(不推荐,使用 personal_sign) |
personal_sign | 签名消息(推荐) |
eth_signTypedData_v4 | 签名结构化数据(EIP-712) |
wallet_switchEthereumChain | 切换网络 |
wallet_addEthereumChain | 添加自定义网络 |
事件系统
EIP-1193 定义了 4 个标准事件
| Event | Description | Parameters |
|---|---|---|
accountsChanged | 用户切换账户时触发 | accounts: string[] |
chainChanged | 用户切换网络时触发 | chainId: string (hex) |
connect | Provider 连接成功 | { chainId: string } |
disconnect | Provider 断开连接 | { code: number, message: string } |
// Listen to account changes
window.ethereum.on('accountsChanged', (accounts) => {
console.log('Account changed:', accounts[0]);
// Update UI, re-fetch balances, etc.
});
// Listen to network changes
window.ethereum.on('chainChanged', (chainId) => {
console.log('Network changed:', chainId);
// IMPORTANT: Must reload page when network changes
window.location.reload();
});与 Ethers.js 的关系
Ethers.js 的 BrowserProvider 是对 EIP-1193 Provider 的封装
调用流程
- 1. DApp 调用 ethers.js API(如 provider.getBalance())
- 2. BrowserProvider 将调用转换为 EIP-1193 的 request() 调用
- 3. window.ethereum.request() 发送 JSON-RPC 请求到钱包
- 4. 钱包处理请求(可能需要用户确认)
- 5. 钱包通过 RPC 节点查询区块链或签名交易
- 6. 钱包返回结果给 window.ethereum
- 7. BrowserProvider 将结果转换为 ethers.js 类型
- 8. DApp 收到最终结果
// Example: getBalance() call chain
// 1. DApp calls ethers.js
const balance = await provider.getBalance('0x...');
// 2. BrowserProvider internally does:
const result = await window.ethereum.request({
method: 'eth_getBalance',
params: ['0x...', 'latest']
});
// 3. MetaMask sends request to Infura node:
// POST https://mainnet.infura.io/v3/YOUR_KEY
// {"jsonrpc":"2.0","method":"eth_getBalance","params":["0x...","latest"],"id":1}
// 4. Infura returns: {"jsonrpc":"2.0","id":1,"result":"0x..."}
// 5. MetaMask returns to window.ethereum
// 6. BrowserProvider converts hex to BigInt
// 7. DApp gets: 1000000000000000000n (1 ETH)与 MetaMask 的协作
MetaMask 是 EIP-1193 标准的最早实现者之一
架构原理
网页层(Content Script)
MetaMask 注入 window.ethereum 对象到每个网页
通信层(Background Service)
Content Script 通过 postMessage 与 MetaMask 后台通信
钱包层(Extension Core)
处理私钥管理、签名、交易构建
节点层(RPC Provider)
MetaMask 内置 Infura 节点,也可以配置自定义 RPC
安全机制
- 私钥隔离 - 私钥永远不离开 MetaMask,网页无法访问
- 用户确认 - 每次签名/交易都需要用户在弹窗中确认
- 域名绑定 - MetaMask 会显示请求来源的域名,防止钓鱼
- Phishing 检测 - MetaMask 内置恶意网站数据库
- 权限系统 - 网站需要先调用 eth_requestAccounts 获得授权
底层调用机制
以 transfer 交易为例,看完整的调用链路
// Complete call chain for a transfer transaction
// 1. User clicks "Send" button in DApp
// 2. DApp calls ethers.js
const tx = await signer.sendTransaction({
to: '0xRecipient...',
value: parseEther('1.0')
});
// 3. Ethers.js converts to EIP-1193 request
await window.ethereum.request({
method: 'eth_sendTransaction',
params: [{
from: '0xYourAddress...',
to: '0xRecipient...',
value: '0xde0b6b3a7640000', // 1 ETH in hex
gas: '0x5208', // 21000 gas
gasPrice: '0x...'
}]
});
// 4. MetaMask Content Script receives request
// 5. Sends to Background Service via postMessage
// 6. Background Service shows confirmation popup
// 7. User clicks "Confirm"
// 8. MetaMask signs transaction with private key
// 9. MetaMask sends signed tx to Infura:
// eth_sendRawTransaction with signed data
// 10. Infura broadcasts to Ethereum network
// 11. Returns tx hash: 0x...
// 12. MetaMask returns hash to window.ethereum
// 13. Ethers.js wraps as TransactionResponse
// 14. DApp gets tx object with hash实战演练
直接使用 window.ethereum 与 MetaMask 交互(不使用 ethers.js)
// Example 1: Connect wallet (pure EIP-1193)
async function connectWallet() {
try {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts'
});
console.log('Connected:', accounts[0]);
return accounts[0];
} catch (error) {
console.error('User rejected:', error);
}
}
// Example 2: Get chain ID
async function getChainId() {
const chainId = await window.ethereum.request({
method: 'eth_chainId'
});
console.log('Chain ID:', parseInt(chainId, 16)); // Convert hex to decimal
}
// Example 3: Switch network
async function switchToSepolia() {
try {
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0xaa36a7' }] // Sepolia = 11155111
});
} catch (error) {
// If network doesn't exist, add it
if (error.code === 4902) {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: '0xaa36a7',
chainName: 'Sepolia',
rpcUrls: ['https://sepolia.infura.io/v3/YOUR_KEY'],
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
blockExplorerUrls: ['https://sepolia.etherscan.io']
}]
});
}
}
}
// Example 4: Sign message
async function signMessage(message) {
const accounts = await window.ethereum.request({
method: 'eth_accounts'
});
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [message, accounts[0]]
});
console.log('Signature:', signature);
return signature;
}为什么还需要 Ethers.js
✓ 类型安全
TypeScript 类型定义完善
✓ 单位转换
自动处理 Wei/Ether 转换
✓ ABI 解码
自动解析合约调用和事件
✓ 兼容性
统一处理不同钱包的差异
✓ 高级功能
ENS 解析、批量调用、gas 估算等
✓ 错误处理
友好的错误信息和重试机制
💡 最佳实践
- 始终检查 window.ethereum 是否存在
- 监听 accountsChanged 和 chainChanged 事件,及时更新 UI
- 使用 personal_sign 而不是 eth_sign(安全)
- EIP-712 签名提供更好的用户体验(显示结构化数据)
- 切换网络时处理用户拒绝的情况
- 不要频繁调用 eth_requestAccounts(会弹窗)
- 使用 BrowserProvider 而不是直接操作 window.ethereum
4. Providers(提供者)
Provider 是与区块链的只读连接,用于查询区块链数据。
Provider 类型
JsonRpcProvider
连接到 JSON-RPC 节点(Infura / Alchemy / 自建节点)
💡 生产环境推荐
BrowserProvider
连接到浏览器钱包(MetaMask / Coinbase Wallet)
💡 DApp 前端必需
WebSocketProvider
WebSocket 连接,支持实时事件监听
💡 需要实时监听时使用
InfuraProvider / AlchemyProvider
快捷方式连接到 Infura / Alchemy
💡 开发测试方便
JsonRpcProvider Example
import { JsonRpcProvider, formatEther } from 'ethers';
// Connect to Infura
const provider = new JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_API_KEY');
// Get block number
const blockNumber = await provider.getBlockNumber();
console.log('Current block:', blockNumber);
// Get balance
const balance = await provider.getBalance('0x...');
console.log('Balance:', formatEther(balance), 'ETH');BrowserProvider Example
import { BrowserProvider } from 'ethers';
// Connect to MetaMask
const provider = new BrowserProvider(window.ethereum);
// Request account access
await provider.send("eth_requestAccounts", []);
// Get network
const network = await provider.getNetwork();
console.log('Network:', network.name, 'ChainId:', network.chainId);常用方法
getBlockNumber()- 获取最新区块号getBalance(address)- 查询 ETH 余额getTransactionCount(address)- 查询 noncegetCode(address)- 查询合约字节码getGasPrice()- 获取当前 gas 价格estimateGas(tx)- 估算 gas 消耗
5. Signers(签名者)
Signer 是有私钥的账户,可以签名和发送交易。
Wallet
从私钥或助记词创建钱包
💡 后端脚本、自动化任务
BrowserProvider.getSigner()
从浏览器钱包获取 Signer
💡 DApp 前端
Wallet Example
import { Wallet, JsonRpcProvider } from 'ethers';
// Method 1: From private key
const wallet = new Wallet('0x...'); // NEVER expose private key!
// Method 2: From mnemonic
const walletFromMnemonic = Wallet.fromPhrase('word1 word2 word3...');
// Method 3: Random wallet
const randomWallet = Wallet.createRandom();
// Connect to provider
const provider = new JsonRpcProvider('https://sepolia.infura.io/v3/YOUR_API_KEY');
const connectedWallet = wallet.connect(provider);
// Get address
console.log('Address:', await wallet.getAddress());BrowserProvider.getSigner() Example
import { BrowserProvider } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
// Get signer
const signer = await provider.getSigner();
// Get address
const address = await signer.getAddress();
console.log('Connected account:', address);
// Sign message
const message = "Hello Ethereum!";
const signature = await signer.signMessage(message);
console.log('Signature:', signature);常用方法
getAddress()- 获取地址signMessage(message)- 签名消息signTransaction(tx)- 签名交易sendTransaction(tx)- 发送交易
⚠️ 安全提示
- 永远不要在前端代码中硬编码私钥
- 使用环境变量存储私钥
- 生产环境使用硬件钱包或 KMS
- 助记词比私钥更安全(可恢复多个账户)
6. 合约交互
通过 Contract 实例与智能合约交互。
创建 Contract 实例
需要合约地址、ABI 和 Provider/Signer。
读取合约(view 函数)
只读操作,不消耗 gas,使用 Provider 即可。
import { Contract, JsonRpcProvider, formatUnits } from 'ethers';
const provider = new JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_API_KEY');
// ERC20 contract
const contractAddress = '0x...';
const abi = [
"function name() view returns (string)",
"function symbol() view returns (string)",
"function balanceOf(address) view returns (uint256)"
];
const contract = new Contract(contractAddress, abi, provider);
// Call view functions (no gas)
const name = await contract.name();
const symbol = await contract.symbol();
const balance = await contract.balanceOf('0x...');
console.log(`Token: ${name} (${symbol})`);
console.log('Balance:', formatUnits(balance, 18));写入合约(状态变更)
需要发送交易,消耗 gas,必须使用 Signer。
import { Contract, BrowserProvider, parseUnits } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contractAddress = '0x...';
const abi = [
"function transfer(address to, uint256 amount) returns (bool)"
];
const contract = new Contract(contractAddress, abi, signer);
// Send transaction
const tx = await contract.transfer(
'0x...', // recipient
parseUnits('10', 18) // amount
);
console.log('Transaction hash:', tx.hash);
// Wait for confirmation
const receipt = await tx.wait();
console.log('Transaction confirmed in block:', receipt.blockNumber);Payable 函数
发送 ETH 到合约。
import { Contract, BrowserProvider, parseEther } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const contractAddress = '0x...';
const abi = [
"function mint() payable"
];
const contract = new Contract(contractAddress, abi, signer);
// Send ETH with transaction
const tx = await contract.mint({
value: parseEther('0.1') // Send 0.1 ETH
});
await tx.wait();错误处理
- 交易可能失败(余额不足、require 检查失败)
- 使用 try-catch 捕获错误
- 解析 revert 原因
- 自定义错误(0.8.4+)解析
7. 工具函数
Ethers.js 提供丰富的工具函数。
单位转换
formatUnits(value, decimals)- Wei → 人类可读parseUnits(value, decimals)- 人类可读 → WeiformatEther(value)- Wei → ETHparseEther(value)- ETH → Wei
哈希函数
keccak256(data)- Keccak-256 哈希solidityPackedKeccak256(types, values)- Solidity packed hashhashMessage(message)- 消息哈希(EIP-191)
地址工具
isAddress(address)- 验证地址格式getAddress(address)- 规范化地址(校验和格式)getCreateAddress(tx)- 计算 CREATE 部署地址getCreate2Address(from, salt, initCodeHash)- 计算 CREATE2 地址
编码/解码
AbiCoder.encode(types, values)- ABI 编码AbiCoder.decode(types, data)- ABI 解码hexlify(data)- 转 hex 字符串toUtf8String(bytes)- bytes → 字符串
Example: Unit Conversion
import { formatEther, parseEther, formatUnits, parseUnits } from 'ethers';
// ETH conversion
const weiValue = parseEther('1.5'); // "1500000000000000000"
const ethValue = formatEther(weiValue); // "1.5"
// Custom decimals (e.g., USDC has 6 decimals)
const usdcAmount = parseUnits('100', 6); // "100000000"
const readable = formatUnits(usdcAmount, 6); // "100.0"8. 交易与 Gas
理解交易结构和 gas 机制。
交易结构
to- 接收地址value- 发送的 ETH 数量(Wei)data- 调用数据(合约函数)gasLimit- gas 上限gasPrice / maxFeePerGas- gas 价格(EIP-1559)nonce- 交易序号
Gas 估算
发送交易前估算 gas 消耗。
import { BrowserProvider, parseEther } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// Estimate gas for a transaction
const gasEstimate = await provider.estimateGas({
to: '0x...',
value: parseEther('0.1')
});
console.log('Estimated gas:', gasEstimate.toString());
// Get gas price
const feeData = await provider.getFeeData();
console.log('Gas price:', feeData.gasPrice);发送交易
使用 Signer 发送交易并等待确认。
import { BrowserProvider, parseEther } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// Send transaction
const tx = await signer.sendTransaction({
to: '0x...',
value: parseEther('0.1'),
gasLimit: 21000 // Optional
});
console.log('Transaction sent:', tx.hash);
// Wait for 1 confirmation
const receipt = await tx.wait();
console.log('Confirmed!', receipt);
// Wait for 3 confirmations
const receipt3 = await tx.wait(3);交易回执
status- 1 成功,0 失败blockNumber- 区块号gasUsed- 实际消耗的 gaslogs- 事件日志contractAddress- 部署的合约地址(如果是部署交易)
9. 事件监听
监听合约事件获取实时数据。
监听一次
on() 持续监听,once() 只监听一次。
import { Contract, BrowserProvider } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const contract = new Contract(contractAddress, abi, provider);
// Listen to Transfer events
contract.on('Transfer', (from, to, value) => {
console.log(`Transfer: ${from} -> ${to}, Amount: ${value}`);
});
// Listen once
contract.once('Transfer', (from, to, value) => {
console.log('First transfer detected!');
});查询历史事件
使用 queryFilter 查询过去的事件。
import { Contract, BrowserProvider } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const contract = new Contract(contractAddress, abi, provider);
// Query historical Transfer events
const filter = contract.filters.Transfer(null, myAddress);
const events = await contract.queryFilter(filter, -10000); // Last 10000 blocks
events.forEach(event => {
console.log('From:', event.args.from);
console.log('To:', event.args.to);
console.log('Value:', event.args.value.toString());
});移除监听器
使用 off() 或 removeAllListeners() 移除监听器,避免内存泄漏。
// Remove specific listener
contract.off('Transfer', listenerFunction);
// Remove all listeners for Transfer event
contract.removeAllListeners('Transfer');
// Remove all listeners for all events
contract.removeAllListeners();10. 学习资源
精选的 Ethers.js 学习资料和工具。
官方资源
Ethers.js 官方文档 ↗
最权威的参考资料,涵盖所有 API
Ethers.js GitHub ↗
源代码和示例
教程与课程
Alchemy University ↗
免费的 Web3 开发课程,包含 Ethers.js 教程
LearnWeb3 ↗
从零到精通的 Web3 学习路径
Ethers.js Playground ↗
在线测试 Ethers.js 代码
开发工具
Hardhat ↗
流行的开发框架,内置 Ethers.js
Wagmi ↗
React Hooks for Ethereum(基于 Ethers.js)
RainbowKit ↗
美观的钱包连接组件(使用 Wagmi)
实战项目
Uniswap Interface ↗
Uniswap 前端源码,大量 Ethers.js 使用案例
OpenSea Seaport ↗
OpenSea 的 NFT 交易协议 SDK