卸载与热重载
dsh 有两条 HMR 链:
- 服务端(
vendor/hmr):用 chokidar 监听文件,通过 Node 内部 ModuleLoader 的 loadCache 找依赖图,分类 accepted/declined,清 ESM+CJS 双缓存后重新 import 并registry.delete→ reload。 - 浏览器端(
packages/client/hmr):stat-poll bundle 文件 mtime,通过/plugins/eventsSSE 推rebuilt帧,client 半 invalidate → prefetch → registry-first teardown →entry.refresh()热替换 fiber。
服务端 HMR
Hmr 服务
vendor/hmr/src/index.ts:86-125 的 class Hmr extends Service:static inject = ['loader', 'timer']- 持有
internal: ModuleLoader、externals/accepted/declined三个分类集合、stashed待处理文件
启动:Service.init
vendor/hmr/src/index.ts:199-295 的 [Service.init] async generator:- 启动 chokidar watcher
loadDependencies(mainJob)算 externals(CLI 入口依赖树,变更触发loader.exit()全重启)- 绑定
add/change/unlink路由
Externals = CLI 入口依赖树:任何外部依赖变更触发
loader.exit()全进程重启——框架代码不参与 partial reload。
反向依赖图传播
vendor/hmr/src/index.ts:345-398 的 analyzeChanges():从 stashed 出发,沿 getLinked 反向传播——- 任一 dependent accepted → accepted
- 所有 descendant declined → declined
partialReload
vendor/hmr/src/index.ts:400-449 的 partialReload():对每个 loader entry 重新 resolve 模块 specifier,若其依赖树含 accepted 文件则纳入 reloads。缓存清理与重建
vendor/hmr/src/index.ts:461-549 的核心流程:- 缓存清理(ESM loadCache + CJS
require.cache) - 重新 import
registry.delete(plugin)- 对每个旧 fiber
registry.plugin(plugin, oldFiber._config)重建 fiber 并entry.fiber = fiber - 失败回滚备份
双缓存清理是 Node 24 兼容关键:loadCache 的
delete只置 slot undefined,必须用Map.prototype.delete.call彻底清;CJSrequire.cache也要同步清,否则 CJS 模块 stale。
单文件 config watch
vendor/hmr/src/index.ts:134-187 的 registerConfig(filename, refresh):单文件 watch,findWatchRoot 处理不存在父目录,refreshConfig 串行 coalesce + 重试循环,disposer 关闭 watcher 并 drain。浏览器端 HMR
Host 半:stat-poll + SSE
packages/client/hmr/src/index.ts:57-191 的 apply(ctx, config) host 半:statSync轮询 bundle mtime+sizeclientModules.rebuilt(id)重新哈希/plugins/eventsSSE 推{type:'rebuilt', id, rev}帧
PluginsEventFrame
packages/client/hmr/src/events.ts:10-16 的 PluginsEventFrame union:ts
{type:'graph'} // 连接时全量
{type:'rebuilt', id, rev} // 增量
EVENTS_ENDPOINT = '/plugins/events'Browser 半:EventSource
packages/client/hmr/src/client/index.ts:98-164 的 apply(ctx) browser 半:new EventSource(EVENTS_ENDPOINT)handle(frame)→ 串行队列reload(id)- invalidate → prefetch → registry.delete →
entry.refresh()
registry-first teardown:顺序为什么关键
packages/client/hmr/src/client/index.ts:38-63 的模块注释解释了为什么不能直接 entry.fiber.dispose():Entry.fiber不在 dispose 时清空refresh()会if (this.fiber) returnno-op- loader self-dispose 会把 entry 标
disabled: true永久禁用
必须先 registry.delete(callback) 再 refresh。
packages/client/hmr/src/client/index.ts:104-140 的 reload(id) 完整顺序:invalidate(id)
→ prefetch(id)
→ 拿 entry.fiber
→ registry.delete(runtime.callback) // ← registry-first
→ drain oldFiber.inertia
→ delete entry.fiber
→ removeOwnedStyles(id)
→ entry.refresh()
→ entry.fiber?.await()Client 半的 "registry-first teardown" 注释是写 HMR 页的现成叙事:
registry.delete(runtime.callback)必须先于 fiber dispose,否则 Loader 的internal/plugin自销毁分支会把 entry 标disabled: true永久禁用,后续 refresh 就再起不来。
CSS 清理
packages/client/hmr/src/client/index.ts:86-92 的 removeOwnedStyles(id):document.querySelectorAll('style[data-plugin]') 按 attribute 严格比对 id 移除——CSS 注入用 stable tag id,重新 materialize 时 idempotency guard 生效。失败回滚
rollback() 恢复 ESM+CJS 缓存,然后重新注册旧 plugin——无 rollback policy 只适用于无法回滚的运行时失败。
官方教程:HMR debounce
docs/cordis-tutorial/06-composition-and-hmr.md:23-59 的教程:HMR 通过 inject: ['timer'] debounce,改 hello.ts 触发 reload plugin at hello.ts,改 cordis.yml 按 id diff 局部重挂。