1. 先想明白:Ethers.js 到底帮你解决什么问题
很多刚接触 Web3 开发的朋友,一上来就被概念砸晕:节点、私钥、Gas、ABI、nonce、Signer、Provider……每个词单独看都能懂,合在一起就不知道先干嘛。
我给你的建议是:不要一开始就去啃文档,先弄明白你手上要做什么。绝大部分入门的区块链开发任务,绕不开三件事——查数据、发交易、调合约。而 Ethers.js 这个库,几乎就是为这三件事量身定做的。
它是什么?一句话概括:一个帮你和以太坊节点对话的 JavaScript 工具库。你不需要自己拼十六进制数据、不需要手动算手续费、不需要纠结签名算法的细节,只需要调用它封装好的方法,就能完成一笔转账、部署一份合约、读取链上某个状态。
很多人会拿它和另一个老牌库对比:web3.js。我的实际体验是,Ethers.js 的 API 设计更现代,体积也更小,TypeScript 支持更好,而且它对钱包和合约交互的分层做得更清晰。新手直接学 Ethers.js 是更省力的路径,社区资源也多,遇到问题更容易搜到答案。
这篇文章适合谁?适合刚入门前端、懂一点 JS 语法、对以太坊有个模糊概念、但不知道从哪里下手的开发者。我会用一笔转账、一个合约发布和调用,串起来整个开发流程。你跟着敲一遍,基本就能摸清 Web3 开发的整体脉络。
1.1 核心概念先打个底:Provider、Signer、Contract
在写任何代码之前,有三个角色你必须理解清楚,因为 Ethers.js 的所有功能几乎都是围绕它们展开的。
Provider是一个"只读"的连接器,它帮你连到以太坊网络,负责查询余额、获取区块信息、读取合约状态,但你不能通过它发起交易。可以把它理解成一个管业务的窗口:你问它什么问题,它给你查答案,但它不帮你改资料。
Signer则代表一个身份,这个身份拥有私钥,能够对交易做签名。只有通过 Signer 发起的交易,网络才会认可。类比来说,Provider 是前台,Signer 是掌握了公司盖章的人,合同上用哪个章,得由 Signer 说了算。
Contract是合约对象的封装,你用合约地址 + ABI 把它实例化。一旦实例化成功,你就可以像调用一个普通 JS 对象的方法一样去调用合约里公开的函数。比如合约里有个balanceOf(address),你在代码里写contract.balanceOf(someAddress)就能拿到结果。
这三个概念会贯穿整篇教程,后面所有技能点都是给它们套动作。
1.2 环境搭建思路:小步快跑而不是一步到位
很多入门教程一上来就让你搭 Hardhat、配环境变量、启动本地节点,流程跑完一两个小时过去了,你还不确定自己做的东西对不对。
我更推荐反向操作:先在浏览器里跑通,再去搭工程化脚手架。
这样做的好处很明显:任何一个小环节出错,你都能快速定位。Node 环境没装好、npm 版本太低、网络连不上、代码写错,这些坑混在一起的时候,新手根本分不清是哪个出了问题。但如果你一开始只下载一个 HTML 文件,引入 CDN 版的 Ethers.js,再往任一个公共节点发请求,半小时内就能完成一次真实查询。
里程碑应该这样定义:先成功读到一个区块高度,再成功查到一个地址余额,然后发送一笔真实转账,最后是部署合约和调用合约。每完成一步,你对它的理解就会牢一点。
2. 动手之前:节点、钱包与网络切换的准备工作
2.1 节点怎么选:公共节点和自建节点的取舍
连上以太坊网络需要一个入口,也就是 RPC URL。你可以理解为你要往某个服务器发 HTTP 请求,这个服务器背后连接的就是链条上的数据。
新手期用公共节点就够了,像 Infura、Alchemy 这些服务商都提供免费层级的接口。免费额度对个人开发和 Demo 来说是绰绰有余的,除非你写的是高频查询脚本,否则不用担心限流。
我个人的经验是:Alchemy 的免费额度更新比较大方,而且它的调试工具做得不错,能帮你看到每个请求的耗时和报错信息。Infura 是老牌项目,稳定但后台界面相对朴素。如果你只是测试,完全可以选择一些支持多链的公共 RPC 列表,把地址复制过来就能用。
自建节点是另一种选择,用 Geth 或者 Nethermind 跑一个全节点。但这需要磁盘空间至少几百 GB,同步时间按天算,不适合入门阶段。你只需要知道有这条路,等以后需要高频查询或者承担生产环境时再考虑,到时候你会发现公共节点的网络瓶颈和频率限制,问题自然就暴露出来了。
2.2 两种钱包使用场景:浏览器扩展和纯代码私钥
和节点打交道需要身份,身份来自钱包。日常开发中有两种常见方式:
一种是使用 MetaMask 这类浏览器扩展钱包。这种方式不需要你把私钥写进代码里,体验也更贴近真实用户。Ethers.js 里有一个BrowserProvider方法,可以把浏览器注入的window.ethereum封装成 Provider 和 Signer。用户点击确认授权,交易签名发生在钱包内部,私钥全程不会离开用户的机器。
另一种是直接把私钥放代码里,通过new Wallet(privateKey, provider)创建 Signer。这种方式适合脚本、后端服务、自动化任务。它跑起来更快,也不用浏览器环境,但私钥管理就要格外小心。
你要根据自己的场景选择。如果只是本地测试,用代码私钥最省事;如果做面向用户的应用,则要优先考虑用户自己的钱包授权方式,让用户签名而不是替你签名,这在很多场景下是硬性要求。
2.3 网络环境别搞混:主网、测试网和本地网
写代码最常见的低级错误,就是网络没对齐。你明明在测试网部署了合约,结果代码里连的是主网,然后花真金白银做了一次无效操作,这种错误我见过太多次了。
常见的网络有这几类:
| 网络 | 用途 | 特点 |
|---|---|---|
| 主网(Mainnet) | 真实资产环境 | 所有操作消耗真实币,不适宜练习 |
| 测试网(如 Sepolia) | 开发测试 | 测试币免费申请,网络行为和主网一致 |
| 本地网(Hardhat Network) | 本地模拟 | 即时出块,调试方便,完全可控 |
每个网络都有一个 Chain ID,比如 Sepolia 的 Chain ID 是 11155111,主网是 1。这个参数是判断网络的最直接方式。你可以在代码里通过provider.getNetwork()来确认当前连接的链,防止手滑。
另外一个关键是测试币获取。Sepolia 测试网一般通过水龙头获取,不同平台有不同的领取规则。作为替代方案,你也可以自己部署一条本地网,在本地挖矿,余额随便用,适合想反复试验合约的状况。
提示:私钥一旦在代码中出现,就绝对不能再往公开仓库里提交。写测试代码时可以放在环境变量里,或者用一个单独的文件妥善保管,并确保该文件被加入 .gitignore。
3. 上代码:交易、合约部署与合约调用全流程解析
3.1 从最普通的 ETH 转账说起
先从一个最简单的需求切入:把一点 ETH 从 A 地址转给 B 地址。这能帮你建立对交易的整体认知。
完整代码大概长这样:
const { ethers } = require('ethers'); // 指定 RPC 节点 const provider = new ethers.JsonRpcProvider('https://your-rpc-url'); // 用自己的私钥创建签名者 const privateKey = '0x你的私钥'; const wallet = new ethers.Wallet(privateKey, provider); async function sendTransaction() { // 目标地址 const to = '0x接收方地址'; // 转账金额,单位是 wei const amount = ethers.parseEther('0.01'); // 发起交易 const tx = await wallet.sendTransaction({ to, value: amount, }); // 等待交易被确认 const receipt = await tx.wait(); console.log('交易哈希:', tx.hash); console.log('区块号:', receipt.blockNumber); } sendTransaction();这里面有三个细节值得展开讲。
第一,金额为什么要用parseEther?以太坊里最小单位是 wei,1 ETH 等于 10 的 18 次方 wei。如果你直接往字段里写小数,链上并不认识。parseEther('0.01')做的事情就是把字符串转换成适合链上使用的底数表示。反向操作也有一个对应方法叫formatEther,用它可以把 wei 转回 ETH 单位展示给用户。
第二,tx.wait()的作用。sendTransaction方法在交易广播出去后就会返回,但此时交易还没被确认。你需要在wait()等待它打包进区块,才能真正知道交易是否成功。开发中就见过一些朋友没等确认就继续下一步,结果前面报错了还找不到原因,实际上就是一步没等异步完成。
第三,你想用 MetaMask 来做签名,代码也只需要换成这样:
const provider = new ethers.BrowserProvider(window.ethereum); const signer = await provider.getSigner();后面的交易调用方式差不多,但是弹窗确认的流程由钱包接管了。你不需要接触任何私钥字符串。
3.2 带手续费参数的转账为什么重要
交易广播出去后能不能快速被矿工打包,一定程度上跟手续费出价有关。Ethers.js 在默认情况下会帮你估算手续费,但默认方案有时候会偏保守,尤其在链上拥堵的时候,你可能等很久交易才被确认。
所以了解手手续费的字段结构是有必要的,它由三部分组成:
gasLimit:这笔交易最多消耗多少 Gas。普通 ETH 转账一般 21000 左右。maxFeePerGas:你愿意为每单位 Gas 支付的最高价格。maxPriorityFeePerGas:给验证者的小费,用于激励优先打包。
你可以不手动设置,但如果你想加快确认速度,可以提高maxPriorityFeePerGas的值。
const feeData = await provider.getFeeData(); const tx = await wallet.sendTransaction({ to, value: amount, maxFeePerGas: feeData.maxFeePerGas, maxPriorityFeePerGas: ethers.parseUnits('3', 'gwei'), });这里getFeeData()会从网络读取一个推荐的费率基线,你再把优先费上调到 3 gwei,多数场景下确认速度会快不少。
注意:不要无限调高优先费,尤其是测试网,没必要烧钱。先理解字段含义,再按场景微调即可。
3.3 部署一份你自己的智能合约
对很多入门者来说,部署合约是第一个激动人心的里程碑。但我们要先解决合约从哪来的问题。
你可以用 Solidity 写一个简单的合约,然后用编译器得到 ABI 和字节码。但为了聚焦 Ethers.js 的核心用法,我先假设你已经拿到了这两样资料。
假设合约是一个存储数字的小合约,功能是setValue(uint256)和value()。
部署时的核心代码是这样的:
const { ethers } = require('ethers'); // ABI 是描述合约接口的 JSON 数组,这里简写示意 const abi = [ 'function setValue(uint256 _value) public', 'function value() public view returns (uint256)', ]; // 字节码是合约编译产物 const bytecode = '0x...'; async function deployContract() { const provider = new ethers.JsonRpcProvider('https://your-rpc-url'); const wallet = new ethers.Wallet('0x你的私钥', provider); const factory = new ethers.ContractFactory(abi, bytecode, wallet); const contract = await factory.deploy(); await contract.waitForDeployment(); console.log('合约地址:', contract.target); } deployContract();部署期间发生了什么?factory.deploy()会构造一笔创建合约的特殊交易,它没有to字段,数据区放的是合约字节码。waitForDeployment()是专门等待合约创建成功的调用,当返回的contract对象拿到地址后,你就可以在 Etherscan 上搜到自己的合约了。
有个细节经常被忽略:合约返回值里的地址是用contract.target获取的。这是新版 Ethers.js 的字段,跟旧版的contract.address不一样。如果你看到教程里用.address拿地址却拿到了 undefined,很有可能是版本差异导致的接口不匹配。
3.4 合约的读操作:只读函数为什么不需要付钱
读合约是高频操作,比如查询代币余额、查看参数配置。它的代码逻辑很直观:
const contract = new ethers.Contract(contractAddress, abi, provider); async function readValue() { const val = await contract.value(); console.log('当前数值:', val.toString()); } readValue();注意到我把provider传进去了,而不是wallet。这是因为读操作不需要签名,不需要消耗 Gas。在链上的视角里,这只是节点替你在本地状态里查一个值,并不产生状态变化。
这里的value()方法之所以能直接调用,全靠 ABI。ABI 就是合约函数的"说明书",里面定义了函数名、参数类型、返回值类型。没有 ABI,Ethers.js 不知道该怎么编码调用数据。
返回值还有一个新手必踩的坑:很多 Solidity 里的整数类型,Ethers.js 返回的是 BigInt。你如果直接拿它参与运算或者拼接字符串,有可能得到让人看不懂的结果。最简单的方式是调用.toString()转成普通十进制字符串,或者用Number()转换。不过要注意,如果数字超过 JavaScript 的安全整数范围,直接用Number()可能丢精度,这种场景建议就用字符串处理。
3.5 合约的写操作:付 Gas、等确认、多一步
写操作跟读操作完全不一样。调用合约里会改变状态的函数,需要签名、需要 Gas、需要等待打包。这跟发一笔 ETH 转账本质上是一个逻辑。
const contract = new ethers.Contract(contractAddress, abi, wallet); async function setValue() { const tx = await contract.setValue(42); console.log('交易哈希:', tx.hash); const receipt = await tx.wait(); console.log('交易已确认, Gas 消耗:', receipt.gasUsed.toString()); } setValue();唯一的区别是这次的Contract对象是用wallet实例化的,所以它具备签名能力。调setValue时,Ethers.js 会帮你把函数签名和方法参数编码到交易数据里,然后广播出去。
这里有一个值得关注的点:交易等待确认后,你最好再查一次数值变化,确认状态确实被更新了。原因在于,链上交易虽然确认了,但如果合约函数内部有特殊逻辑(例如只接受特定条件),状态变化可能并没有按照你预期的方式发生。第二章第 1 节讲的那条链路在这里就形成闭环了:Provider 用于查询先前状态,Signer 负责形成状态变化,再回到 Provider 验证结果。
3.6 监听合约事件:让应用自动响应链上变化
如果你做的是一个前端应用,用户可能希望页面在合约状态变化时能自动刷新数据,不用手动点击。这就是事件监听的用武之地。
Solodity 合约里可以定义event,比如:
event ValueChanged(address indexed caller, uint256 newValue);Ethers.js 里监听事件的写法是:
contract.on('ValueChanged', (caller, newValue, event) => { console.log('谁改的:', caller); console.log('新数值:', newValue.toString()); });当合约执行emit ValueChanged时,你的监听回调就会触发。这背后的原理是:Ethers.js 会通过 WebSocket 连接到节点,订阅相关日志。节点发现有匹配的日志,就推送给你。
有一点要提醒:如果要使用事件监听,RPC 地址需要支持 WebSocket。普通的 HTTPS 链接很难做实时推送,通常是一个wss://开头的地址。目前服务商基本都会同时提供两种接口,你直接换成 WebSocket 端点即可。另外,监听器不在页面销毁时取消订阅的话,可能会造成内存泄漏,所以最好在组件卸载时调用contract.removeAllListeners()。
4. 踩坑记录:开发中高频出现的问题排查思路
4.1 交易一直 Pending,到底出了什么问题
我曾经遇到过一笔交易卡在半路,等了十几分钟都没动静。排查下来原因很简单:私钥对应的地址余额不够支付手续费。但节点没有直接报错,而是把交易放在 pending 队列里等它超时。
出现 pending 问题,按顺序查这几项:
- 账号余额是否足够支付
gasLimit * gasPrice,计算一下就知道了。 - 是否还有上一笔交易没有确认,而且 nonce 被占用了。由于网络要求交易按顺序处理,前面没确认,后面只能排队。
- 手续费是否给得太低,低于网络当时的最低要求。
针对第 2 点展开说一下 nonce。nonce 是你这个地址发起交易的一个序号,从 0 开始递增。节点会按照这个值排序,只有前面的交易被处理完,后面才轮到。如果你用脚本多次发送交易,有其中一笔失败或者卡住,你就得处置这个 nonce 或者等它过期。
调试时可以这样获取当前 pending 的交易数:
const provider = new ethers.JsonRpcProvider('https://your-rpc-url'); const count = await provider.getTransactionCount(address, 'pending'); console.log('pending 交易数:', count);4.2 合约结果显示不正确,优先查询 ABI 是否匹配
合约部署成功、调用也成功了,但返回的数据跟预想不一致。这种问题我遇到最多的场景,是 ABI 不匹配。
ABI 里定义了什么,Ethers.js 就按什么解析。如果合约升级了但 ABI 文件还是旧的,字段对不上,返回值自然奇怪。再或者你从链上复制他方合约地址,却没找到正确的 ABI,只在某个页面看到别人贴的一段内容,拿过来就用,分分钟出问题。
正确做法是从区块浏览器的合约页面直接获取经过验证的 ABI,或者从项目官方包里拿。确保合约地址和 ABI 是同一份代码的产物,比你绞尽脑汁解析异常结果靠谱得多。
4.3 钱包弹出授权,但签名后没有任何反应
用浏览器钱包时,用户点了签名,页面却没反应,这可能是前端异步处理没接好。
最常见的错误是:你的代码实例化了BrowserProvider,却在初始化阶段调用了getSigner(),但用户还没有连接钱包。或者你调用的时候没有await,代码已经往后面走了。
更稳妥的写法是先主动请求用户授权:
await provider.send('eth_requestAccounts', []); const signer = await provider.getSigner();等这个流程走完,再执行后面的交易操作。不少朋友以为跟普通 JS 一样,用户点完签名弹窗就完事了,实际上整个授权握手有很多异步交互,缺一个 await 就可能导致流程中断。这是我的亲身体会:在页面控制台看到 Promise 一直 pending 的时候,先检查一下每个调用前面是不是漏了 await。
4.4 助记词和私钥格式的正确打开方式
常见的助记词是 12 个或 24 个英文单词,私钥则是一长串 64 位的十六进制字符串。用 Ethers.js 可以从助记词派生钱包:
const wallet = ethers.Wallet.fromPhrase('your phrase words');但要注意,助记词本身只是起点,钱包地址还可能跟派生路径有关。MetaMask 默认派生的路径是m/44'/60'/0'/0/0。如果你用助记词在别的工具恢复,但发现地址不是你原来的地址,多半是路径不一样。
私钥则可以直接用new ethers.Wallet(privateKey, provider)。不同来源给你的私钥格式可能略有差异,有些带0x前缀,有些不带。Ethers.js 基本上两种都能兼容,但如果遇到"invalid private key"报错,先确认字符串长度是不是 64 位十六进制,前缀有没有被误加或者漏掉。
4.5 版本差异带来的 API 不兼容问题
Ethers.js 在 v5 和 v6 之间有较大的接口变化,很多网上教程停留在 v5,而你最新安装的是 v6,抄代码就会报错。
几个典型差异列表:
| 功能 | v5 写法 | v6 写法 |
|---|---|---|
| 获取合约地址 | contract.address | contract.target |
| 格式化 ETH | ethers.utils.formatEther | ethers.formatEther |
| 解析 ETH | ethers.utils.parseEther | ethers.parseEther |
| 连接钱包 | provider.getSigner() | provider.getSigner()但返回类型有差异 |
安装包的时候留意版本号,查文档时也留意版本标识。你要是看的那篇博客写的是ethers.utils,那大概率是 v5 的代码,需要转换成 v6 的写法再跑。
5. 实操经验:从技术到习惯,减少无效工时
代码层面讲得差不多了,再分享几个开发习惯上的体会。
第一个习惯是:每做一步,打印关键值。查余额就打印用户地址和余额,部署合约就打印合约地址和交易哈希,调用合约后立刻打印返回值。不要嫌麻烦,这些信息在你调试的时候就是救命线索。任何人面对报错需要排查时,有这些日志的人效率至少是没日志的人的两倍。
第二个习惯是:先在测试网完整跑通流程,再考虑主网。别觉得测试网手续费便宜就随意乱试,真正目的在于让你验证业务逻辑。如果业务逻辑在测试网都走不通,你凭什么觉得上主网突然就通了?测试网和主网在调用模式上几乎一致,出问题大概率是你的代码问题,而不是网络问题。
第三个习惯是:理解交易的生命周期。从构造交易、签名、广播,到进入 pending,再到确认,整个过程你都要能在追踪工具里还原。区块浏览器是很好的工具,任何一笔交易哈希、合约地址、钱包地址都可以查到最新状态。遇到问题先去浏览器搜,这比闷头调代码要快得多。
第四个习惯是:合理使用现有工具。类似 OpenZeppelin 提供的合约模板、ABI 在线解码工具、Gas 价格查询站点,这些都能帮你省去大量项目周期中的重复劳动。Web3 开发的便利性其实比以前好太多,善用工具,你的生产力能翻倍。
回看整篇文章,你会发现核心就是三件事:连节点、发交易、调合约。Ethers.js 让这三件事的门槛降到了普通前端开发者就能掌握的程度。真把一整套代码跑完并且理解了每个环节,你再去看其他 Web3 项目的代码,基本不会再是一头雾水。后面如果你发现对 blockchain 的底层越来越有兴趣,再去看数据怎么上链、状态怎么存储、共识怎么达成,这些概念理解起来也会有事半功倍的效果。
最后我想说的是:代码都会跑通,重要的是你愿意花时间去理解运行背后的那些细节。把这个环境搭好,找一个测试网,把一个简单的合约部署上去,真正和链上交互过,你就已经踏入 Web3 的大门了。