前端教程

Ethers.js 完整教程

从连接钱包到合约交互,掌握 Ethers.js v6 所有核心功能

🎮 实战:ERC20 交互

🎮 ERC20 合约交互演示

💡 部署合约后,将 constants/erc20.ts 中的占位符替换为实际合约地址即可使用。

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 MethodDescription
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 个标准事件

EventDescriptionParameters
accountsChanged用户切换账户时触发accounts: string[]
chainChanged用户切换网络时触发chainId: string (hex)
connectProvider 连接成功{ chainId: string }
disconnectProvider 断开连接{ 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. 1. DApp 调用 ethers.js API(如 provider.getBalance())
  2. 2. BrowserProvider 将调用转换为 EIP-1193 的 request() 调用
  3. 3. window.ethereum.request() 发送 JSON-RPC 请求到钱包
  4. 4. 钱包处理请求(可能需要用户确认)
  5. 5. 钱包通过 RPC 节点查询区块链或签名交易
  6. 6. 钱包返回结果给 window.ethereum
  7. 7. BrowserProvider 将结果转换为 ethers.js 类型
  8. 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) - 查询 nonce
  • getCode(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) - 人类可读 → Wei
  • formatEther(value) - Wei → ETH
  • parseEther(value) - ETH → Wei

哈希函数

  • keccak256(data) - Keccak-256 哈希
  • solidityPackedKeccak256(types, values) - Solidity packed hash
  • hashMessage(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 - 实际消耗的 gas
  • logs - 事件日志
  • 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