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 ETH:const 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 在链上是10000000000000000(Wei)。这个数远超 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 更大的交易都会排队,哪怕你重新提交也未必有效。排查时,可以先打印一下这个账户的 nonce:const 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 恰恰是那种“五分钟能见到效果”的库,它的价值不是帮你写业务逻辑,而是帮你把链上交互的复杂度收敛在一个很舒服的抽象层里。先动手,等你在交易和合约上栽过几个小跟头,再回去看共识算法、看状态树,你会发现一切都变得特别好懂。别怕踩坑,坑本身就是这条路上最好的老师。
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 ETH:const 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 在链上是10000000000000000(Wei)。这个数远超 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 更大的交易都会排队,哪怕你重新提交也未必有效。排查时,可以先打印一下这个账户的 nonce:const 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 恰恰是那种“五分钟能见到效果”的库,它的价值不是帮你写业务逻辑,而是帮你把链上交互的复杂度收敛在一个很舒服的抽象层里。先动手,等你在交易和合约上栽过几个小跟头,再回去看共识算法、看状态树,你会发现一切都变得特别好懂。别怕踩坑,坑本身就是这条路上最好的老师。