Skip to content
源码版本47f9438 (dsh@0.1)

卸载与热重载

dsh 有两条 HMR 链:

  • 服务端(vendor/hmr):用 chokidar 监听文件,通过 Node 内部 ModuleLoader 的 loadCache 找依赖图,分类 accepted/declined,清 ESM+CJS 双缓存后重新 import 并 registry.delete → reload。
  • 浏览器端(packages/client/hmr):stat-poll bundle 文件 mtime,通过 /plugins/events SSE 推 rebuilt 帧,client 半 invalidate → prefetch → registry-first teardown → entry.refresh() 热替换 fiber。

服务端 HMR

Hmr 服务

vendor/hmr/src/index.ts:86-125class Hmr extends Service:
  • static inject = ['loader', 'timer']
  • 持有 internal: ModuleLoaderexternals/accepted/declined 三个分类集合、stashed 待处理文件

启动:Service.init

vendor/hmr/src/index.ts:199-295[Service.init] async generator:
  1. 启动 chokidar watcher
  2. loadDependencies(mainJob) 算 externals(CLI 入口依赖树,变更触发 loader.exit() 全重启)
  3. 绑定 add/change/unlink 路由

Externals = CLI 入口依赖树:任何外部依赖变更触发 loader.exit() 全进程重启——框架代码不参与 partial reload。

反向依赖图传播

vendor/hmr/src/index.ts:345-398analyzeChanges():从 stashed 出发,沿 getLinked 反向传播——
  • 任一 dependent accepted → accepted
  • 所有 descendant declined → declined

partialReload

vendor/hmr/src/index.ts:400-449partialReload():对每个 loader entry 重新 resolve 模块 specifier,若其依赖树含 accepted 文件则纳入 reloads。

缓存清理与重建

vendor/hmr/src/index.ts:461-549 的核心流程:
  1. 缓存清理(ESM loadCache + CJS require.cache)
  2. 重新 import
  3. registry.delete(plugin)
  4. 对每个旧 fiber registry.plugin(plugin, oldFiber._config) 重建 fiber 并 entry.fiber = fiber
  5. 失败回滚备份

双缓存清理是 Node 24 兼容关键:loadCache 的 delete 只置 slot undefined,必须用 Map.prototype.delete.call 彻底清;CJS require.cache 也要同步清,否则 CJS 模块 stale。

单文件 config watch

vendor/hmr/src/index.ts:134-187registerConfig(filename, refresh):单文件 watch,findWatchRoot 处理不存在父目录,refreshConfig 串行 coalesce + 重试循环,disposer 关闭 watcher 并 drain。

浏览器端 HMR

Host 半:stat-poll + SSE

packages/client/hmr/src/index.ts:57-191apply(ctx, config) host 半:
  1. statSync 轮询 bundle mtime+size
  2. clientModules.rebuilt(id) 重新哈希
  3. /plugins/events SSE 推 {type:'rebuilt', id, rev}

PluginsEventFrame

packages/client/hmr/src/events.ts:10-16PluginsEventFrame union:
ts
{type:'graph'}                     // 连接时全量
{type:'rebuilt', id, rev}          // 增量
EVENTS_ENDPOINT = '/plugins/events'

Browser 半:EventSource

packages/client/hmr/src/client/index.ts:98-164apply(ctx) browser 半:
  1. new EventSource(EVENTS_ENDPOINT)
  2. handle(frame) → 串行队列 reload(id)
  3. 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) return no-op
  • loader self-dispose 会把 entry 标 disabled: true 永久禁用

必须先 registry.delete(callback) 再 refresh。

packages/client/hmr/src/client/index.ts:104-140reload(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-92removeOwnedStyles(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 局部重挂。

接下来读什么