热重载
使用 graft start --watch
时,已观察的插件模块或依赖发生变化后,会用新代码替换受影响的插件。进程继续运行,不是从插件模块加载的部分也继续运行,例如环境中的服务。
| 本页章节 | 回答的问题 |
|---|---|
| 保存后会发生什么 | 保存文件后 Graft 会做什么? |
| 什么会触发重载 | 哪些文件和变化会被计入? |
| 重载未生效的情形 | 为什么我的修改没有生效? |
| 插件模块的规则 | 如何编写重载友好的代码? |
| 限制 | 热重载做不到什么? |
| 变更检测 | 如何发现变化? |
| 租约与观察 | 哪些文件会被监听,监听到何时? |
保存后会发生什么
Section titled “保存后会发生什么”- 检查。 Graft 先合并原生事件路径,再检查已观察的文件。事件本身不会触发重载;只有文件内容或跟踪的本地解析结果实际变化才会触发。保存相同内容或修改无关文件不会重载插件。
- 失效。 自动重载只让变化的模块实例及直接或间接导入它们的模块失效,包括循环依赖。只有受影响的入口会重新导入;原先导出同一个 Plugin 对象的入口会一起检查。未变的依赖和独立入口保留原实例。
- 校验并替换。 所有候选通过导入、导出身份及解析检查后,才通过一次
Graft.replace事务发布替换。受影响的 fork 按正常的服务撤回顺序重启,共享依赖在整批替换中保持一致。 - 或者保留旧代码。
候选导入失败或没有通过检查时,不替换任何插件。被替换的插件启动失败,或在
activationTimeout(默认 30 秒)内没有启动时,replace会回滚,旧插件重新运行。失败会记录完整 cause;超时警告会指出未启动的插件。
| 情形 | 行为 |
|---|---|
| 导入或检查失败 | 旧插件继续运行。HMR 保留待处理的变更和失败观察,等待下一次变化。 |
| 新插件启动失败 | 结果为 RolledBack,旧插件重新运行。HMR 保留变更的模块,等待下一次变化。 |
| 插件入口文件已删除 | 该入口退出本次重载,已有 fork 继续运行到卸载。 |
| 重载期间收到事件 | 事件会留给下一轮处理。 |
| 写入不完整 | 重载可能失败。Graft 记录完整 cause 并继续监听;后续内容变化即使没有原生事件也能触发恢复。 |
重载尝试返回 Reloaded、RolledBack 或 Skipped 后,CLI 会调用
loader.retry。这会重试失败项并刷新整棵树的 !env
值,因此修复插件后无需重启进程即可再次加载。
公开的 hmr.reload
始终强制重新导入所有已注册入口及其受跟踪的依赖。即使没有入口,它也会推进导入视图,让首次失败的导入可以重试。这与
watch 的增量重载不同。
什么会触发重载
Section titled “什么会触发重载”| 属性 | 说明 |
|---|---|
| 文件 | 配置目录及其子目录中的 .js、.mjs、.cjs、.ts、.mts 和 .cts 文件。 |
| 排除 | node_modules 中的所有文件。 |
| YAML | 不在此处理。YAML 编辑由加载器处理。 |
| 防抖 | 相邻的文件事件会经过 50 毫秒防抖。 |
| 新增入口 | 通过配置新增插件本身不会触发重载。 |
| 解析 | 每个模块实例保留自己的解析视图。旧代码中稍后发生的动态导入,包括激活清理期间的导入,不会切换到全局最新版本。 |
| 动态导入 | 复用的模块保留依赖关系;尚未执行过的动态导入无法提前发现。 |
重载未生效的情形
Section titled “重载未生效的情形”有些变化无法安全地应用。HMR 会保持所有旧插件运行,并报告原因。
| 结果 / 原因 | 起因 | 处理方式 |
|---|---|---|
Skipped / restart-required |
版本化模块导入了 node:module 或 module,或有租约的入口可达 CommonJS 或原生依赖。 |
重启进程。 |
Skipped / restart-required |
入口或 helper 符号链接现在指向不同的目标。 | 重启进程。 |
Skipped / ambiguous-exports |
两个入口原先导出同一个 Plugin 对象,现在导出了不同的对象。会列出模块 URL。 | 重启,或恢复共享导出。 |
Skipped / identity-conflict |
替换身份与运行时 alias 不一致,例如 P → Q, Q → R。会列出模块 URL。 |
重启。 |
Failed |
导入或检查失败。值中包含完整的 Cause。 |
修复代码。监听继续。 |
详细说明:
- 导出歧义:如果恢复共享导出,后续重载可以恢复。校验会在发布 alias 或改变任何 fork 之前拒绝整批替换。
node:module和module:HMR 采取保守策略,要求重启。这在createRequire命中缓存之前登记原生加载能力,覆盖require(esm)和 CommonJS。即使只使用该模块的其他工具函数,也会使这张依赖图退出热重载。需要热重载的依赖请使用 ESM 导入。- 原生加载报告:即使没有文件通知,监听也会报告已知的重启要求,包括启动后才获取该能力的插件。周期性状态检查不会重复报告相同状态;这条警告不表示已经执行重载。
- 符号链接:本地文件入口及其可达的依赖关系,按模块实例保留导入方、原始请求路径和物理目标。导入前、替换前及轮询期间都会核对同一份有归属的图。即使新旧内容相同、原生通知丢失,轮询也能检测目标变化。HMR 不清理 Node 的解析缓存,这项检查也不重新解析裸包名或 package exports 映射。
插件模块的规则
Section titled “插件模块的规则”| 规则 | 原因 |
|---|---|
| 导入模块不得产生副作用。 | Graft 重载时会重新导入受影响的模块,而只有 apply 运行在 Graft 能关闭的 Scope 内。连接、服务器和定时器都应在 apply 内启动。 |
| 使用 ECMAScript 模块。 | CommonJS 和原生依赖需要重启进程。Graft 按模块实例跟踪依赖,包括 apply 中的动态导入。只要仍有入口租约能访问这类依赖,重载就返回 Skipped / restart-required。 |
node_modules 是共享的。 |
其中的包不会重载,Effect 和 Graft 自身必须始终只有一份实例。唯一的例外是插件所在的模块本身:来自某个包的插件会被重新导入,但该包的其他文件不会。 |
| 运行 Node.js 22.19 或更高版本。 | 这与 CLI 的 Effect Node 适配器要求一致,也提供 module.registerHooks。 |
| 限制 | 说明 |
|---|---|
| 长时间会话内存增长 | Node 的 ESM 缓存不会淘汰带版本的模块实例。增量重载减少了新建实例的数量,但长时间开发会话仍会累积。HMR 根据租约及当前导入视图回收源码摘要、依赖观察和可达性缓存;精简的路由视图会保留到会话关闭。进程内存没有固定上限。 |
| 不负责监督 | HMR 不负责监督或重启子进程。需要时请重启进程。 |
| 不适用于生产环境 | 生产环境不要使用 --watch。 |
| 远程插件改为重启 | graft start --watch 会在 local/worker 远程条目的契约或实现文件变化时重启它们。宿主不观察安装在网络 runner 上的实现。 |
| 挂起的导入会阻塞重载 | 如果导入始终无法完成,例如模块顶层的 await 一直不返回,后续重载和关闭流程都会被阻塞,因为动态导入无法取消。 |
| 机制 | 行为 |
|---|---|
| 原生事件 | 即使 stat 未变,也会强制检查内容。 |
| 轮询 | 每秒检查元数据和本地解析结果。stat 快路径会跳过元数据未变的文件读取。 |
| 空闲核查 | HMR 至少每十秒核查一次内容,因此即使保存后的大小和时间戳都相同、通知也丢失了,仍能检测变化。 |
| 基线 | 保存实际加载字节的 SHA-256 摘要,因此也能发现导入后、监听器安装前的保存。 |
| 监听的目录 | 即使设置 recursive: true,也按观察到的依赖图为相关目录安装非递归监听。HMR 不会遍历无关目录或 node_modules,实际加载的 dist 模块仍受观察。缺失路径由最近的现存祖先目录监听,目录集合每秒更新。 |
| 并发 | 检查遵循监听路径、recursive 和 filter,并在流关闭时停止。文件读取在会话锁之外以有限并发执行,只有观察版本仍匹配时才接受结果。 |
观察基线:
- 每次观察都关联具体的导入尝试及实际读取的源码字节。
- 新尝试完成后,轮询以这些字节为基线,因此文件在重载期间恢复为旧快照时,也能触发恢复。
- 失败的尝试若已读取源码字节,同样会更新基线,因此没有新变化时不会陷入失败重试循环。
watcher 失败:
- CLI 会记录 watcher 的 I/O 失败,并在一秒后重新安装 watcher。
- 原生 watcher 在源资源清理完成后、进入后台合并边界之前观察完整
Exit。包含失败的中断会在这里记录完整Cause,只有观察者主动发起且来源匹配的取消保持静默,资源清理自身产生的中断也会报告。普通后端失败继续交给消费方处理,不重复记录。 - 取消期间才完成的失败会在恢复可中断状态前,以完整
Exit移交。HMR 会记录原本可能被取消遮蔽的失败,保留完整Cause。
CLI 的模块登记跟随配置项存活。
| 事件 | 影响 |
|---|---|
| 配置项被移除或禁用 | 释放租约。最后一个租约关闭后,重载不再导入该入口。 |
| 配置项处于 Pending 或 Failed | 保留租约,因此修改代码后仍可恢复。 |
| 配置项被卸载 | 它及其独占的 CommonJS 依赖不会阻塞其余入口。 |
| 某路径的最后一个所有者被释放 | 该路径退出轮询。 |
| 重载成功 | 只回收活跃入口和待重试入口都不再可达的观察信息;复用的依赖实例仍保留归属。 |
| 仍有所有者的路径发生真实 I/O 错误 | watch 流失败。 |
轮询也跟随租约:文件集合来自每个存活入口的运行中依赖图及最近一次尚未提交的导入尝试,保留失败导入和重试所需的缺失本地文件。
首次导入失败后,观察租约仍由配置项持有,与失败时立即关闭的候选资源 scope 分离。因此修正模块后,即使原生通知丢失,也能通过轮询恢复。重试替换观察代次,移除或禁用配置项则关闭观察 scope。
Remote 条目的观察集合不会随激活或重试失败而关闭,详见远程条目的恢复行为。
如需在 CLI 之外使用 HMR,请参阅 HMR API。