资讯动态

Hardhat实战指南:从合约编译到测试部署的完整工作流

发布时间:2026/10/16 9:44:41 来源:尧图企业网站定制
做EVM合约开发本地调试环境是很多人刚起步时最容易卡住的地方。早年我在图形化区块链模拟器和自建节点之间横跳了很久直到切到Hardhat才算把编译、部署、测试这一串流程真正串成了一个整体。Hardhat是一个面向Solidity智能合约的开发框架它自带一个轻量的本地EVM网络也内置了合约编译、脚本执行、自动化测试和插件扩展能力目标很直接让合约迭代像写普通服务端代码一样顺畅。这篇文章会按我实际项目的使用路径从初始化项目、编译部署、测试调试到插件化工作流和常见坑位完整过一遍适合有JavaScript基础、正准备系统性进入合约开发的读者。1. 为什么选择Hardhat从脚本式开发到工程化开发1.1 合约开发的老难题以前做合约最常用的方法是在一个本地区块链模拟器上手动部署然后把交互命令写在脚本里。这样做的痛点很明显模拟器里的链上状态和脚本是分离的一旦合约逻辑变了脚本里的地址、ABI、nonce都需要重新整理而且很难在中间步骤做断言。尤其是多个合约互相调用时临时脚本的调用顺序、错误回滚全靠人肉保证出一次问题就要全部重来。后来有人开始用自动化测试框架去管理部署步骤但测试框架和合约编译工具、节点工具之间又没有统一的数据模型。你写完一个测试想要验证一个状态变更往往要同时启动节点、编译合约、再单独跑测试进程。这里面的时间损耗看起来不大叠加到每次修改代码的重跑循环里就非常难受。我自己早期有一半时间不是花在写合约逻辑上而是花在“怎么让环境再跑起来”上。1.2 Hardhat的核心设计思路Hardhat实际上不只是工具链而是一个以任务为中心的执行框架。它提供了一个运行时环境HRE编译、部署、测试、网络操作都被封装成可组合的任务。好处是整个项目共享同一个配置、同一套依赖不会出现“部署用的是网络A测试脚本却在找网络B”的割裂。Hardhat Network是内置在框架里的一个完整本地EVM节点。它和传统模拟器最大的区别是它执行的是真实的字节码状态转换和EVM规则一致只是去掉了POW和出块等待。跑测试时每一个describe/it都可以获得一个干净的链上状态跑完毫秒级回收不用中途重启。这种设计让合约测试从“等外部节点”变成“调用内存中的节点”速度优势非常明显。还有一点是插件机制。Hardhat的几乎每个能力都可以被替换或增强比如部署流程、Gas报告、覆盖率统计、形式化验证工具的接入都有对应的社区插件。这意味着你不需要在项目初期把所有工具都确定好可以先跑通基础流程后续再按需加装。这一点对长期维护的项目来说价值比一时的开发速度更高。1.3 适用场景与不适用场景从我实际使用的情况看Hardhat最适合这三类工作一是ERC标准合约的快速开发和验证二是复杂合约组合的集成测试三是需要自动化构建和部署流程的工程化项目。如果你的项目还要多人协作Hardhat的配置文件和脚本化部署也能让整个流程可审查、可重放。相反的如果只是临时验证一段Solidity代码打开网页版IDE拖拽部署可能更快如果目标链是完全自定义的虚拟机那Hardhat的EVM假设就不适用需要另找方案。另外我建议小白的第一个合约项目也直接上Hardhat因为它把“编译——部署——测试”的基础路径固定住能少走很多弯路。用多了之后你会发现这个框架不挑项目复杂度小从单合约原型大到多合约分层架构都能承接。2. 环境搭建与项目初始化2.1 依赖准备在初始化之前先确认本机的Node环境。Hardhat 2.0之后要求Node 18以上的版本建议用LTS版本避免上了最新偶数版之后某些原生模块编译不过。安装走常规路径node -v npm -v然后建一个目录完成npm初始化mkdir my-hardhat-project cd my-hardhat-project npm init -y npm install --save-dev hardhat这里要注意Hardhat是作为开发依赖安装的不应该进入正式运行依赖。项目里如果有package-lock建议保留它能锁定插件的版本组合减少后面“本地能跑、环境跑不了”的差异问题。如果你在Windows上开发尽量用PowerShell或者Git Bash执行命令某些终端模拟器对npx交互式命令的渲染支持不完整可能会出现菜单显示异常。2.2 初始化项目的两种方式安装完Hardhat之后可以有两种方式初始化交互式创建或者手工搭目录。交互式最省事npx hardhat init执行后会出现一个选择菜单创建JavaScript项目、TypeScript项目或者空配置。我一般选JavaScript项目团队里有人不想碰TS时更方便。生成的结构包括contracts目录、scripts目录、test目录、hardhat.config.js、package.json。如果不想被生成示例代码干扰就选空配置然后自己创建目录。这种方式适合已经很熟悉结构的开发者能完全掌控目录规划。我建议新手还是用交互式生成因为生成的示例合约和测试可以直接跑一遍验证环境是否真的装好了。另外生成的示例项目里一般会带一个Lock合约如果你不需要直接删掉保持目录干净就好。2.3 配置文件hardhat.config.js解读项目根目录下的hardhat.config.js是整个框架的门面。一个最基础的配置长这样require(nomicfoundation/hardhat-toolbox); module.exports { solidity: 0.8.24, networks: { hardhat: { chainId: 31337, } } };第一行引入的是官方聚合插件它把ethers、Mocha、Chai、verify这些常用能力一次性绑定到Hardhat运行时。如果不引入它后面的ethers对象在脚本里就拿不到。solidity字段指定编译器版本这里推荐锁死到和合约里pragma声明一致的主版本避免编译时因为多个版本反复下载solc。networks.hardhat配置本地节点的参数chainId设置为31337是为了配合浏览器钱包识别后面调试DApp时会用到。配置文件改完不需要重启任何常驻服务Hardhat每次执行任务都会重新读取这点比那些带Daemon的框架要清爽得多。如果你项目里同时用了TS那么配置文件可以是hardhat.config.ts但需要额外安装ts-node支持。我不建议非必要上TS配置因为编译打包链路会变长排查问题时多一层变量。3. 合约编写、编译与本地网络实操3.1 一个标准合约的编译流程在contracts目录下新建一个最简单的计数器合约作为整个流程的测试样例// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; contract Counter { uint256 public count; function increment() external { count 1; } function reset() external { count 0; } }合约的要点不多状态变量count是公开的Solidity会自动生成一个同名的getter函数increment和reset分别修改状态。编译命令一行npx hardhat compile第一次编译会下载对应版本的solc网络状况差的时候多等一会儿不要重复开多个终端同时编译容易触发依赖锁。编译完成后项目里会出现artifacts和cache两个目录。很多人习惯把它们加进.gitignore其实我更倾向于保留cache在本地开发环境它能让二次编译走增量逻辑大幅提速只有提交代码时才需要忽略它们。编译输出的JSON文件里包含了ABI、字节码和源码映射信息后续测试和验证都会用到。每次改动合约后Artifacts里的ABI会被覆盖如果你的前端代码引用了旧的ABI记得同步重新生成。Debug时我最常用的是去artifacts/contracts/{ContractName}.sol/{ContractName}.json里查看ABI确认函数签名和参数类型这种操作比在代码里来回猜字段快得多。3.2 本地Hardhat Network的正确使用方法编译干净之后可以在终端启动本地网络npx hardhat node启动成功会打印一串JSON内容给出20个测试账户和对应的私钥每个账户默认有10000个测试ETH这个精度只服务本地开发。这些私钥是公开固定的仅供测试绝不能用在任何正式链上。这里有三个实操要点第一这个节点进程要一直保持前台运行关掉终端链上状态就没了第二默认监听127.0.0.1:8545如果你的DApp住在Docker里需要额外配置hostname让容器内也能访问第三节点启动时输出的账户和Hardhat运行时自动生成的账户是两套如果脚本里要用这些特定账户需要在脚本里显式导入私钥而不是依赖默认注入。我一般会把这步和“单独开一个终端”绑定起来把它当作开发环境的常驻进程。只有在跑自动化测试时才不启动节点因为测试会用in-process的内存节点效率更高。如果遇到端口被占用可以用--port参数换一个比如8546对应钱包连接时记得改RPC地址。3.3 部署脚本编写与账户管理部署脚本放在scripts目录下Hardhat推荐的文件名是deploy.js当然可以随便取。一个可运行的部署脚本如下const { ethers } require(hardhat); async function main() { const Counter await ethers.getContractFactory(Counter); const counter await Counter.deploy(); await counter.deployed(); console.log(合约地址:, counter.target); } main().catch((error) { console.error(error); process.exitCode 1; });这里的ethers是从Hardhat运行时拿到的它自动绑定了当前网络配置。在本地节点进程存在的情况下执行npx hardhat run scripts/deploy.js --network localhost如果不加--network默认会走hardhat内置的内存网络也就是说部署完脚本进程结束合约地址如果不记下来就彻底丢了。我在早期就因为这个丢过好几次合约地址。账户管理方面最简单的方式是通过环境变量注入私钥例如module.exports { ... networks: { myChain: { url: http://127.0.0.1:8545, accounts: [process.env.PRIVATE_KEY] } } };注意私钥不要写进代码仓库用.env文件配合dotenv加载。就算只是测试网私钥泄露也会带来不必要的纠纷风险。部署脚本里还有一个经验如果合约构造函数需要参数部署时一定要先确认构造参数的顺序ethers不会帮你重排顺序错了合约就会被部署成“另一个人”。4. 测试驱动与调试技巧4.1 测试框架选型Hardhat的测试默认基于Mocha断言库用Chai。装上官方toolbox之后还可以用ethers的合约对象以及Chai匹配器写起来很像自然语言。给Counter写个测试const { expect } require(chai); const { ethers } require(hardhat); describe(Counter, function () { it(初始值应该是0, async function () { const Counter await ethers.getContractFactory(Counter); const counter await Counter.deploy(); expect(await counter.count()).to.equal(0); }); it(increment之后值变成1, async function () { const Counter await ethers.getContractFactory(Counter); const counter await Counter.deploy(); await counter.increment(); expect(await counter.count()).to.equal(1); }); });跑测试命令是npx hardhat test每个it都会在一个快照状态下执行不会相互污染。这里有个性能小技巧如果一个测试文件里部署合约的步骤重复可以在before里统一部署并保存引用然后把多个断言放在同一个it里减少重复的状态部署开销。当然这要牺牲一些“测试用例相互独立”的纯粹性实际项目里可以在速度和独立性之间做个平衡。另一个注意点是Solidity提供external和public两种可见性测试里调用合约方法时如果是view或pure函数ethers会直接调用eth_call不会消耗gas而状态修改方法会返回交易响应需要等待确认才能拿到结果。在处理异步事务时很多人忘记await交易响应本身导致断言执行时交易还没被打包结果跑出一个莫名其妙的旧状态。我建议在测试代码里统一使用await语法不要省略中间步骤。4.2 用console.log做合约级调试Hardhat最让人舒服的调试方式是在合约里直接打日志。导入Hardhat自带的console合约import hardhat/console.sol; contract Counter { uint256 public count; function increment() external { count 1; console.log(increment count to, count); } }然后在测试或脚本中调用increment终端就能看到日志输出。这个console合约在Hardhat Network上被编译成一次特殊的内部调用不需要额外部署本地调试极其方便。但我必须提醒一句在准备部署到正式链之前一定要把测试用的console.log从需发布的合约里清掉。原因有两个一是它会留在字节码里产生额外支出虽然不大但不是零二是如果正式链上触发了这个日志调用会因为目标地址不是EVM内置而出现预期外的行为。我自己的习惯是在合并代码之前跑一次全局搜索凡是import了hardhat/console.sol的合约都需要处理。console.log支持常见的值类型并且能直接打印地址、字符串、uint256。遇到复杂结构比如自定义结构体就不能直接打印了需要手动把字段拆开打。调试时如果日志太多还可以在命令前加--verbose查看更详细的执行路径不过日常场景下默认输出就够用了。4.3 覆盖率与Gas报告如果团队要求测试覆盖率直接装solidity-coverage插件npm install --save-dev solidity-coverage然后在hardhat.config.js里require它跑npx hardhat coverage它会生成一个coverage目录包含每个合约文件的分支和行覆盖率。覆盖率数字不用追求100%但合约的分支覆盖率尤其重要很多漏洞都藏在分支条件里。比如一个转移函数里判断余额是否充足测试只覆盖“余额充足”这一条路径不行。另一个有用插件是hardhat-gas-reporter它在跑测试的时候会在终端输出每笔调用的Gas消耗配置起来很简单module.exports { ... gasReporter: { enabled: true, } };它输出的是每个函数的部署和调用Gas均值用来发现那些“费gas大户”很有用。对普通开发者来说不要求你背出每条EVM指令的成本但至少要知道合约存储比计算贵循环里写存储是很容易把Gas推高的反面教材。用Gas报告配合覆盖率工具基本能把代码质量的粗略画像画出来测试覆盖到哪里哪里最耗资源。5. 插件生态与高级工作流5.1 核心插件选型Hardhat的插件机制很成熟我实际项目中保留比较固定的组合是官方toolbox负责基础能力hardhat-deploy负责部署脚本和地址簿再加一个测试覆盖率插件。这里逐个说一下它们解决什么问题。官方toolbox是一组插件的集合包括ethers、Mocha/Chai、verify功能。它解决了90%的日常需求配好之后不用在hardhat.config.js里攒一长串require。hardhat-deploy是社区里非常流行的部署管理工具它能给每个部署脚本打标签自动记录已经部署过的地址重复部署时会跳过或者更新配合--tag参数可以只在某个环境下执行指定合约的部署这对多合约项目非常关键。覆盖率和Gas报告插件在前面提过就不再重复。选插件时我有个原则插件引入的能力要和项目的主版本匹配。Hardhat版本迭代很快旧插件有时会拖慢node启动甚至直接报错。装新插件前先看它的版本兼容范围是否覆盖当前Hardhat主版本。如果一个插件长期不更新但GitHub上issue区很活跃也要谨慎一点可能它的API已经和主框架脱节了。5.2 自定义任务与脚本化操作当项目里出现一系列重复操作时比如经常要查某个账户的测试ETH余额或批量生成合约地址就可以把它们封装成Hardhat任务。一个自定义任务示例task(balance, 打印指定账户的ETH余额) .addParam(account, 账户地址) .setAction(async (taskArgs) { const { ethers } require(hardhat); const balance await ethers.provider.getBalance(taskArgs.account); console.log(taskArgs.account, :, ethers.formatEther(balance)); });保存到hardhat.config.js后终端执行npx hardhat balance --account 0x...任务机制的好处在于它和其他Hardhat命令共用运行时配置脚本里不用担心怎么解析网络参数、怎么加载合约工厂。定义任务时建议把具体业务逻辑拆到外部函数里配置文件中保留addParam和setAction的薄壳这样单元测试还能单独测业务逻辑。如果你只是想在跑测试前做点数据准备也可以用task的addFlag增加一个布尔参数让命令行更灵活。5.3 多环境部署策略项目一旦要上测试网和正式网就需要有多环境配置。Hardhat的networks字段天然支持多个网络比如networks: { hardhat: { chainId: 31337 }, localhost: { url: http://127.0.0.1:8545 }, testnet: { url: process.env.TESTNET_RPC_URL, accounts: [process.env.TESTNET_PRIVATE_KEY] } }部署时通过--network切换。这里容易踩的坑是环境变量没加载配置里的url变成undefined脚本会连本地失败。所以一定要在配置顶部加载dotenvrequire(dotenv).config();面对多个合约且它们之间有构造依赖时用hardhat-deploy的脚本模式比手动部署更稳。示例module.exports async ({ getNamedAccounts, deployments }) { const { deploy } deployments; const { deployer } await getNamedAccounts(); const counter await deploy(Counter, { from: deployer, log: true }); };这样部署出来的合约地址会记录到deployments目录之后的脚本可以通过deployments.get(Counter)拿到地址不用自己维护JSON文件。多环境部署时我还会在部署脚本里根据network.name判断是否要额外执行初始化操作比如给另一个合约设置权限这样可以把环境差异收拢到一个脚本里。6. 常见问题与排查实录6.1 依赖安装与版本不兼容我遇到过的第一个高频问题是npm安装完Hardhat之后npx hardhat version直接报错大多是Node版本过低。用nvm切换Node版本后就好了。第二个高频问题是插件版本冲突尤其是hardhat-deploy和toolbox里的某些依赖互相覆盖。解决办法是删除node_modules和package-lock.json重新安装或者直接用npm ls查找冲突包后升级对应插件的版本。第三个是solc下载失败这通常和网络有关系但我会先检查hardhat.config.js里的solidity版本是否存在拼写错误比如把0.8.24写成0.8.240会导致无限等待。还有一个常被忽略的点某些长期未维护的插件会偷偷带上旧版本的ethers导致运行时同时存在两个ethers实例。这种情况下脚本里的ethers对象和你合约ABI里用到的ethers可能不是同一个版本处理TransactionResponse时会出现字段对不上的怪异问题。遇到这种诡异现象优先检查依赖树。6.2 网络重启与状态丢失本地节点每次重启所有已部署合约地址都会失效。遇到测试一跑就报nonce too low或out of funds时先确认是不是节点被重启过。排查办法很简单看账户剩余余额是否还是10000 ETH如果变了说明状态已经被消耗或重置。另一个连带问题是浏览器钱包经常还缓存着旧的非ce值我习惯在本地节点重启后给浏览器钱包切换一个全新网络或重新连接强制它刷新nonce。链ID固定为31337的做法就是为此服务的避免钱包把本地网络和其他网络混淆。如果你不想每次重启节点后都重新部署一遍合约可以试试在测试脚本里维护一个本地JSON文件记录合约地址列表部署时先检查文件里有没有地址有就直接使用。这个方案适合纯本地验证但对团队协作不友好因为每个人的机器上状态都不一样。更稳妥的方式是用部署插件维护地址簿让状态有单一来源。6.3 合约验证失败的几类原因部署到测试网后想在区块浏览器上验证合约是常规操作但Hardhat的verify任务失败时很容易让人摸不着头脑。常见原因有源码文件里import了本地其他文件但没有把它一起展开或者Solidity版本和部署时不是同一个版本导致字节码对不上。我推荐的排查顺序是先在区块浏览器页面里手动选择编译器版本后检查元数据选项再对照插件生成的验证输入数据。如果浏览器页面上显示的合约源码和本地文件有差异回到Flatten思路把多文件合成单文件再验证。另外如果合约里使用了自定义构造函数参数验证时必须和部署时填的参数完全一致一个空格都不能差。用verify任务的构造参数配置时直接粘贴部署脚本里生成的无前缀十六进制值不要自己手拼减少因为大小写或前导零导致的失败。还有一点部分区块浏览器要求验证时的优化设置和编译时一致Hardhat默认开着优化但你如果手工改过配置就需要把optimizer设置同步过去。最后再补充一点个人体会我用了Hardhat比较长时间最大的感受是它可以让你把精力放在“合约逻辑本身”而不是“环境到底通不通”上。新版本迭代之后建议隔一段时间就去翻官方的changelog特别是breaking changes早发现早调整。如果你刚开始学习合约开发先别急着安装一大串插件把默认的编译、测试、部署跑通再逐步引入部署管理和覆盖面工具。环境是辅助真正值钱的是你对合约状态和Gas模型的理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑