Ethers.js 开发入门:从本地链转账到合约交互与事件监听
1. 先搞清楚 Ethers.js 到底在帮你省什么事很多新手刚接触 Web3 开发时第一反应是跑去读共识算法、看 Solidity 语法折腾一星期连一条真实交易都没发过然后就被劝退了。我的建议正好相反想入门先直接把 Ethers.js 用起来在一台本地开发链上做一笔转账、调一个合约方法把“链上交互”这个闭环跑通再去补底层原理你会发现一切都顺了。你可能会问Ethers.js 是什么简单说它可以让你用 JavaScript 的语法去读写以太坊上的数据。以前要是想读一个地址的余额你得自己拼 JSON-RPC 请求自己处理十六进制结果自己解析错误现在你只需要拿到一个 provider然后调用provider.getBalance(address)。底层那些复杂的编码、签名、网络通信都被封装掉了。1.1 从中心化应用的“用户体系”说起传统互联网应用里用户登录靠的是账号密码数据存在自己的数据库里。Web3 世界里没有“数据库里的一行记录”能用密码改所有状态都保存在区块链上状态变更必须通过交易来触发。这意味着两件事第一你的代码要会构造交易第二你的代码要能签名证明你有权操作某个账户。Ethers.js 把这两件事拆成了两个概念provider 负责连接网络、查询状态signer 负责持有私钥、签名交易。这种分离初看有点啰嗦但它和你真实面对的编程模型完全一致。读数据不需要花钱也不需要身份写数据一定要有账户签名并且要支付 gas。所以学习 Ethers.js 不只是学一个 API而是在学以太坊自己的“权限模型”。1.2 为什么新项目我更愿意用它市面上的 Web3 交互库不少老一些的教程里更多是另一套库的写法我自己早期也用过但后来的项目基本都换到了 Ethers.js。原因有三个API 设计更符合直觉你不需要记大量与链上协议强绑定的规则写出来的代码更接近“面向对象”的用法比如contract.balanceOf(addr)就是一个普通方法调用。类型信息更完整如果你用 TypeScript它能给你非常清楚的类型提示。合约的方法名、参数类型都能在你写代码时被编辑器检查出来这对项目维护太重要了。内部数据表示更现代新版已经迁移到原生BigInt不再依赖自带的大数类跟 Node.js 和浏览器的兼容性都好很多。当然不是说别的库一无是处只是从新项目选型角度看Ethers.js 的生态和更新节奏更让我放心。1.3 Provider、Signer、Contract三个核心抽象整个 Ethers.js 的日常使用可以浓缩成三个对象Provider链的只读窗口。传入一个 RPC 地址你就能查区块高度、账户余额、合约状态。Signer有私钥的账户。它扩展了 provider 的能力可以签名并广播交易。Contract合约地址与 ABI 的组合。有了它你调用合约方法就像调用普通函数。我见过不少人把这三个概念混在一起用最后写出的代码非常拧巴。比如只传 provider 给合约却在里面调用转账方法结果报错“missing signer”。你先在脑子里理清楚这三个抽象后面所有例子都会好懂很多。2. 动手前的准备把本地项目连上一条链不要一开始就连接主网去操作真实资产谁的钱都不是大风刮来的。安全、便宜、还能反复折腾的方式是先搭一个本地开发链。你在本机跑一个开发链后可以为所欲为地制造区块还不用担心把测试币耗尽。2.1 搭建最小项目骨架打开终端建一个目录初始化 npm 项目mkdir ethers-playground cd ethers-playground npm init -y npm install ethers装完之后确认一下版本。如果你看到package.json里的版本是 6.x就按本文的语法来如果看到 5.x很多 API 是不一样的。接着创建一个index.js先用最少的代码连接网络const { JsonRpcProvider } require(ethers); const provider new JsonRpcProvider(http://127.0.0.1:8545); (async () { const network await provider.getNetwork(); const blockNumber await provider.getBlockNumber(); console.log(chainId:, network.chainId); console.log(当前区块高度:, blockNumber); })();如果你本地已经跑起了开发链运行node index.js应该能看到 chainId 和区块高度。2.2 RPC 节点与网络配置里最容易踩的坑很多新人第一次连接网络就卡住不是因为代码写错而是 RPC 地址给错了。请记住一个原则RPC URL 决定了你连接的是哪条链。如果你连接的是公共测试网官方会提供一个公开 RPC 地址如果你连接的是本地开发链通常是http://127.0.0.1:8545。后者是明文 HTTP只能在本地调试时用千万不能拿它去传主网私钥。生产环境一定要用加密通道并且建议把 RPC 地址和 API Key 放进环境变量里别硬编码进代码提交到仓库。一个更稳妥的做法是在代码里校验当前网络是不是预期的链const expectedChainId 1337n; const network await provider.getNetwork(); if (network.chainId ! expectedChainId) { throw new Error(网络不匹配期望 chainId ${expectedChainId}实际 ${network.chainId}); }这个检查能帮你挡住“测试网地址填错、连上主网”这种致命问题。2.3 测试币从哪里来本地开发链有一个内置账户通常全自动生成里面有一堆 ETH你可以直接拿它的私钥做实验。如果想模拟更接近真实的环境也可以用公共测试网去水龙头网站上领一些测试币。说句实话测试币虽然不值钱但领起来也烦。我觉得最好的上手方式还是本地开发链因为它的账户余额充足也没有网络延迟所有交易秒级确认。等你把逻辑都调通了再去公共测试网做一次完整的部署和交互体验会好很多。下面是一个简单的对比网络类型用途获取测试币难度适合阶段本地开发链日常调试无需领取初学、测试逻辑公共测试网模拟真实环境需要去水龙头上线前验证主网真实资产无测试币概念生产环境3. 第一笔转账代码量不大细节却不少网络通了下一步就是发一笔真正上链的交易。第一次看到自己能转账是很上头的。但激动归激动还是要稳。3.1 钱包是怎么来的在 Ethers.js 里你想发交易首先得有一个 signer。它的来源主要有三种const { Wallet } require(ethers); // 方式一从私钥导入 const wallet1 new Wallet(process.env.PRIVATE_KEY, provider); // 方式二随机生成一个新钱包 const wallet2 Wallet.createRandom(); // 方式三从助记词恢复 const wallet3 Wallet.fromPhrase(process.env.MNEMONIC, provider);方式一最常用。开发时你把私钥放在.env文件里通过环境变量读取。方式二适合创建新测试账户它会生成一组助记词记录好了下次还能恢复。方式三是给用户做导入用的比如你写了一个钱包工具需要支持用户输入助记词登录。注意私钥就是账户的唯一凭证谁拿到谁就有权动用里面的所有资产。测试网上还好主网上一旦泄露神仙都救不回来。3.2 完整转账代码与参数逐行拆解假设你已经从私钥创建好了wallet现在想给另一个地址转 0.01 ETHconst tx await wallet.sendTransaction({ to: 0xRecipientAddress, value: parseEther(0.01), gasLimit: 21000n, maxFeePerGas: parseUnits(20, gwei), maxPriorityFeePerGas: parseUnits(1, gwei), });让我逐行解释to接收方地址。如果写错了交易会发送到一个别人可能永远无法找回私钥的地址资产直接消失。value转账金额。这里用parseEther(0.01)把 ETH 转成最小单位 Wei。千万不要直接写0.01链上都是整数单位前端展示才用浮点数。gasLimit这笔交易最多消耗多少 gas。普通 ETH 转账固定是 21000不需要多给。maxFeePerGas你愿意支付的单份 gas 价格上限。maxPriorityFeePerGas给矿工/验证者的小费用来让交易更快被打包。相比旧版教程里只有一个gasPrice现在以太坊主要使用 EIP-1559 模式理解这两个参数能帮你排查交易一直“待确认”的问题。3.3 交易发出去了不代表已经成功很多人以为await wallet.sendTransaction(...)执行完交易就成功了。实际情况是sendTransaction只是把交易广播了出去返回一个 Promise里面是交易响应对象tx。交易能不能被确认取决于网络是否接受了它以及 gas 是否足够。你必须再等一个确认const receipt await tx.wait(); if (receipt.status 1) { console.log(交易成功tx hash:, tx.hash); } else { console.log(交易失败回执状态, receipt.status); }receipt.status为 1 表示成功为 0 表示执行失败。失败同样会消耗 gas因此调试合约调用时如果失败总是反复发生你得先看回执里的日志不能盲目重试。4. 打合约从 ABI 到事件监听一次说清转账只是单方动作真正让 Web3 世界丰富多彩的是智能合约。Ethers.js 让你调用合约方法就像调用本地函数一样但它并不是魔法它背后依赖一个关键的东西ABI。4.1 ABI 决定你能调什么ABI 是一个 JSON 数组描述了合约有哪些函数、参数类型、返回值类型还会告诉你某个函数是可读还是可写。没有 ABI你就不知道该怎么编码调用数据只能拿着原始十六进制数据发呆。获取 ABI 通常有两个渠道一是你自己编译智能合约时生成的构建产物二是从区块浏览器上复制别人合约的 ABI。总之只要合约地址和 ABI 都是真的你就能和这个合约交互。创建合约实例时要特别注意第三个参数const { Contract } require(ethers); const contract new Contract(contractAddress, abi, signer);第三个参数如果传的是 provider那么这个合约只能做只读操作如果传的是 signer那它既能读也能写。反过来你必须清楚当前调用到底需不需要签名。4.2 只读方法零 gas 也能拿到链上数据合约里的view或pure方法属于只读调用不会改变链上状态所以不需要签名也不需要消耗用户的 gas。用 Ethers.js 调用它们非常简单const name await contract.name(); const totalSupply await contract.totalSupply(); const balance await contract.balanceOf(0xSomeAddress);这里有一个很常见的误解不是所有网络读取都是完全“免费”的。节点执行这些调用仍然会消耗计算资源只不过费用不由发起者承担而是由节点自己消化。对用户来说体验确实像免费查询。4.3 写方法签名、广播、等待确认缺一不可如果方法是transfer、mint、swap这类会修改状态的那就得用 signer 调用并等待交易确认const tx await contract.transfer(0xRecipientAddress, parseEther(1.5)); const receipt await tx.wait(); if (receipt.status 1) { console.log(合约调用成功hash:, tx.hash); }有些合约的写方法还要求你附带 ETH比如购买 NFT 或铸造代币。Ethers.js 允许你传入一个额外的覆盖参数const tx await contract.mint({ value: parseEther(0.1), });这里的{ value: ... }就是给合约转账 0.1 ETH。你还可以同时覆盖 gas 参数比如gasLimit、maxFeePerGas。4.4 事件监听从“查状态”到“等通知”链上数据的另一种常见用法是监听事件。比如你关心某个代币的转账可以通过合约事件接口订阅contract.on(Transfer, (from, to, value, event) { console.log(${from} 转账给 ${to}: ${value}); });但要记住一个关键前提HTTP 连接无法主动接收节点推送。如果你用的是JsonRpcProvider(http://...)直接调用on是无效的因为 HTTP 是单向请求。要么换成支持订阅的WebSocketProvider要么定期用queryFilter去扫历史事件。const events await contract.queryFilter(Transfer, startBlock, endBlock);对入门项目来说轮询虽然笨但胜在稳定也不需要额外维护长连接。5. 真正让我印象深刻的是这些坑Ethers.js 整体不难但实际写起来有些坑几乎每个人都踩过。我把它们放在一起希望能帮你节省几天时间。5.1 BigInt 精度不是玄学是你没看清类型链上金额通常用最小单位表示比如 0.01 ETH 在链上是10000000000000000Wei。这个数远超 JavaScript 普通number的安全整数范围所以在 Ethers.js 新版里数值类型统一用的是原生BigInt。我见过很多报错都长这样const amount parseEther(0.01); console.log(amount 1); // 报错BigInt 和 number 不能直接相加解决办法很简单所有数值运算都用BigInt用人眼看着恶心但精度绝对靠谱。展示给用户前再调用formatEther(amount)转回字符串。5.2 Nonce 卡住一笔 pending 交易堵住整个账户每笔交易都会带一个nonce代表这个账户发出的第几笔交易。节点要求交易必须按照 nonce 顺序执行如果一个账户有一笔 nonce 为 5 的交易始终没被确认那你后面所有 nonce 更大的交易都会排队哪怕你重新提交也未必有效。排查时可以先打印一下这个账户的 nonceconst nonce await wallet.getNonce(); console.log(下一笔可用 nonce:, nonce);如果发现有交易卡了很久有两种处理思路一是用更高 gas 重新广播同一笔交易覆盖旧的二是以nonce作为参数发送一笔 0 ETH 转账给自己手动把它“顶掉”。这里面的操作细节很多但至少你得先知道问题出在 nonce 上面而不是无脑重试。5.3 Gas 估算与限额宁可多留余地也别卡死在半路Ethers.js 里有个estimateGas能在发送前估算交易需要的 gas。但估算结果不一定完全准确因为它基于当前链上状态做模拟合约执行路径一变实际消耗可能比估算值高。更稳妥的做法是给gasLimit留一个缓冲比如估算出来是 50000你就设 60000甚至写死。对普通 ETH 转账 21000 是固定的不需要缓冲对合约调用建议多留 10% 到 20%。Gas 价格同理。如果你把maxFeePerGas设得太低交易就会一直滞留。测试环境无所谓主网上要留意当前网络的平均费率别凭感觉填。5.4 网络环境切换同一个私钥在不同链上的教训本地开发链、公共测试网、主网它们的 chainId 完全不同。同一个私钥在主网和测试网都能推导出相同的地址但你如果拿着测试网的 RPC 去发主网交易极大概率会直接失败。以前我在一个脚本里同时配了多个网络结果忘了改环境变量差点把主网交易发到测试网。从那以后我习惯在代码启动时强制检查 chainId把错误暴露在第一时间而不是等交易失败了再回头看日志。6. 进阶方向从脚本到 DApp当你已经把转账和合约调用跑通接下来无非是把这套能力变成产品。这里有几个方向我建议按顺序去扩展。6.1 私钥安全所有 Web3 开发的底线在脚本里直接碰私钥关起门自己玩可以但一旦要给别人用就不能再用服务器私钥签名了。安全底线是私钥不写入代码仓库用环境变量或密钥管理服务。助记词不截图、不粘贴到聊天工具、不存云笔记。主网真实资产尽量走硬件钱包或者通过浏览器钱包让用户自行授权签名。对任何看似“免费领取”的签名请求保持警惕授权签名可能让你损失资产。Ethers.js 本身只是一个库它不会替你做安全决策但你应该从一开始就养成习惯。6.2 接浏览器钱包EIP-1193 是标准现在大多数 DApp 并不是在服务器端用私钥签名而是在浏览器里连接用户自己的钱包。Ethers.js 对这种场景有专门的支持const { BrowserProvider } require(ethers); // 浏览器环境里钱包会往 window.ethereum 注入一个 provider const provider new BrowserProvider(window.ethereum); const signer await provider.getSigner();这里不需要你管理私钥用户自己点击确认授权你的代码拿到的是一个受限的 signer。这种方式虽然牺牲了自动化能力但安全性和用户体验都远好于“服务器保存用户私钥”。6.3 下一步还能玩什么基础链路已经通了你完全可以往这些方向继续探索批量转账工具读取 CSV循环调用代币合约的transfer。链上监控机器人监听某个合约的特定事件触发告警。简单的资产管理面板用 provider 查询多个地址的币种余额。合约部署脚本用ContractFactory把 Solidity 编译产物部署到测试网。给你一个最实在的下一步建议找一个你感兴趣的 ERC-20 代币合约 ABI把它接到你的本地开发链测试合约上写一个自动转账加监听事件的脚本。两三个小时跑下来你对 Web3 开发的核心链路就不会再恐惧了。最后说一点个人体会。我每次带新人入门都不会让他们先背一堆概念而是让代码先跑起来。Ethers.js 恰恰是那种“五分钟能见到效果”的库它的价值不是帮你写业务逻辑而是帮你把链上交互的复杂度收敛在一个很舒服的抽象层里。先动手等你在交易和合约上栽过几个小跟头再回去看共识算法、看状态树你会发现一切都变得特别好懂。别怕踩坑坑本身就是这条路上最好的老师。