前端教程

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