Vite HMR outside the root
Vite monorepo HMR 时灵时不灵:root 之外的源码为什么监听不住
This page is only available in Chinese. It is shown in its original language.
现象一句话版:
pnpm dev下改packages/vue/src或packages/core/src,浏览器有时更新、有时纹丝不动,重启 dev server 必然见效——症状是概率性的,和改了什么无关。
这篇文章记录一次开发体验问题的完整定位与修复:为什么 Vite 的 watcher 会漏掉 monorepo 里的源码变更,为什么「给根目录加个 watch」不等于修好,以及我们最后把「监听集合」和「源码解析集合」绑成同一个不变量。
一、现象:改了源码,浏览器不动
pnpm dev 起的是 packages/vue 的预览服务器:
# packages/vue/package.json
"dev": "vite --config preview/vite.config.ts"改 packages/vue/src/components/*.vue,或者 packages/core/src/**/*.ts,浏览器有时热更新、有时保持旧代码。因为「时灵时不灵」,很容易先怀疑 HMR 缓存、浏览器缓存、编辑器保存方式——但真因在更上游:变更事件根本没进到 Vite 的 watcher。
二、定位:root 和它要服务的源码不在同一棵树
preview 配置里的 root 是 packages/vue/preview:
// packages/vue/preview/vite.config.ts
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
// ...
})而实际加载的库源码全在 root 之外:
preview/App.vue相对导入../src/index(packages/vue/src);resolve.alias把 core 的导出映射到packages/core/src;resolve.alias把 agent-runtime 映射到packages/agent-runtime/src。
Vite 建 watcher 时只递归监听 root,外加 config、env、public:
const watcher = chokidar.watch(
[
...(config.experimental.bundledDev ? [] : [root]),
...config.configFileDependencies,
...getEnvFilesForMode(config.mode, config.envDir),
...(publicDir && publicFiles ? [publicDir] : []),
],
resolvedWatchOptions,
)(vite 8.3.0 dist/node/chunks/node.js:24288,内置 chokidar 3.6.0。)
packages/vue/src、packages/core/src、packages/agent-runtime/src 都不在这个数组里。目录树递归监听没有覆盖它们,问题就出在这里。
三、为什么是「概率性」:root 外的文件靠逐文件补挂
Vite 对 root 外的文件并非完全不监听。它在文件被加载进模块图时,调用 ensureWatchedFile 把这一个文件单独补进 watcher:
function ensureWatchedFile(watcher, file, root) {
if (
file &&
// 只需要处理 root 之外的文件
!file.startsWith(withTrailingSlash(root)) &&
// 部分 rollup 插件用 \0 开头的私有 id
!file.includes('\0') &&
fs.existsSync(file)
) {
watcher.add(path.resolve(file))
}
}(vite 8.3.0 node.js:2449,调用点在 transform 流水线 node.js:8361。)
关键差异是:这是逐文件、加载时的补挂,不是目录树递归监听。于是有三类稳定的失效场景:
- 新增文件在首次被加载之前不会进入 watcher;
- 文件被删除重建(编辑器安全写、清理
dist)后,原单文件 watch 失效; - Windows 上单文件
fs.watch的事件更容易丢。
这三点都与「改了什么」无关,只与「文件当下的监听状态」有关,所以症状表现为概率性;一旦重启 dev server,所有已加载文件重新补挂,必然生效。这也解释了为什么 packages/vue/src 和 packages/core/src 表现一致——它们同属 root 外。
四、「让 root 覆盖源码」只解决一半
一个自然的想法是:把 root 提到仓库根,让 watcher 自动覆盖所有包。这能解决「被监听」,但解决不了另一半——被加载的到底是不是源码。
core 和 agent-runtime 是通过 resolve.alias 才落到 src 的。如果去掉 alias,import 会经 workspace:* 软链和 package exports 落到 dist。dist 里是构建产物,改源码当然不会影响它,也就谈不上 HMR。
所以真正要维持的不变量是:
被 alias 解析到
src的文件集合,必须落在被 watcher 监听的目录树集合之内。
把 root 提到仓库根会让监听集合变大到覆盖一切,但解析集合仍需 alias 单独维护。两半缺一不可。
五、解法:把 workspace 源码目录整树加入 watcher
修复落在唯一的 dev 入口 packages/vue/preview/vite.config.ts:加一个插件,在 configureServer 阶段把各包的 src 目录整树加入 watcher。
/**
* 收集所有 workspace 包的源码目录 packages/<pkg>/src。
* 直接扫描 packages 目录,新增包会自动纳入,无需维护清单。
*/
function collectWorkspaceSourceDirs(repoRoot: string): string[] {
const packagesDir = path.join(repoRoot, 'packages')
if (!existsSync(packagesDir)) return []
return readdirSync(packagesDir)
.map((name) => path.join(packagesDir, name, 'src'))
.filter((dir) => statSync(dir, { throwIfNoEntry: false })?.isDirectory())
}
/**
* 把 workspace 源码目录整树加入 watcher,使 root 外源码获得与 root 内一致的目录监听。
*/
function watchWorkspaceSources(repoRoot: string): Plugin {
return {
name: 'klinechart:watch-workspace-sources',
configureServer(server) {
server.watcher.add(collectWorkspaceSourceDirs(repoRoot))
},
}
}三个设计点:
- 目录树,而不是单文件。
chokidar.add(dir)是递归监听,新增、删除、重写文件全部覆盖,等价于文件本来就在 root 内。 - 扫描
packages/*/src,而不是维护一份清单。 新增 workspace 包自动纳入,不会因为漏挂目录而复发同一问题。 - 只加
src,不加整包。dist不在监听集合里,因此不需要额外的watch.ignored降噪,也不会因为构建产物抖动误触发。
补一句:Vite 文档提示 linked 包需要出现在依赖图中才能触发 HMR。preview 配置里已有 optimizeDeps.exclude: ['@363045841yyt/klinechart-core'],这一半本来就是对的——缺的只是监听。
六、验证:不用开浏览器
HMR 是否触发难以在单测里断言,但「watcher 到底在监听什么」可以直接查。用同一个 config 以 middlewareMode 起服务器,等 watcher ready,读 server.watcher.getWatched():
const server = await createServer({
configFile: '.../packages/vue/preview/vite.config.ts',
server: { middlewareMode: true },
})
await new Promise((resolve) => server.watcher.once('ready', resolve))
const watchedDirs = Object.keys(server.watcher.getWatched())结果:
watched packages/core/src: 111 dirs OK
watched packages/vue/src: 19 dirs OK
watched packages/agent-runtime/src: 11 dirs OK
add event (new file in packages/core/src): OK在 packages/core/src 下新建一个文件能触发 add 事件——这正对应第三节里失效场景 1,修复前不会发生。
七、同源问题:examples/vue-test
examples/vue-test 是按包名导入的:
import { KLineChart } from '@363045841yyt/klinechart'
import { formatTimestamp } from '@363045841yyt/klinechart-core'它经 package exports 解析到 dist(packages/vue/package.json 的 exports 指向 ./dist/*)。这个 example 的定位是发布产物冒烟测试,把它 alias 到 src 会改变它的用途,所以正确的处理不是给它的源码加监听,而是把边界写清楚:改库源码后要 pnpm build:packages;需要源码级 HMR 就用 pnpm dev。README 里已补上这条说明。
八、沉淀下来的三条原则
- 排查 HMR「时灵时不灵」,先看实际监听集合。
server.watcher.getWatched()一眼看穿,比怀疑缓存高效得多。 - 把「监听集合」和「解析到源码的集合」绑成同一个不变量。 monorepo 里它们分别由 watcher root 和
resolve.alias决定,很容易各自漂移;只要保证alias 到 src⊆watcher 监听,HMR 才可靠。 - 修根因用目录树 + 派生,别去补失效的单文件 watch。 「逐文件补挂」本身就是薄弱环节,正确的做法是让 root 外的源码重新获得目录级监听。
结语
这个问题的表象是「HMR 概率性失效」,本质是 dev server 的 root 与它服务的源码不在同一棵树:目录树监听覆盖不到,退化成逐文件补挂,而逐文件监听在新增、删除重建和 Windows 上都不可靠。修复不是给某个文件再补一次 watch,而是把 workspace 的源码目录整树交回给 watcher,并确保它与 alias 解析的集合同源。
相关代码:KLineChartQuant,改动位于 packages/vue/preview/vite.config.ts 与 examples/vue-test/README.md(issue #194)。可参考 Vite 官方 server.watch 文档,以及 vitejs/vite#16399「root 外文件 rename 后 HMR 断链」——同一个 ensureWatchedFile 根因。
Frame transactions and effects
这篇文章以 KLineChartQuant 引擎的真实重构为主线,讲清楚三个容易被混为一谈的概念:帧事务(Frame Transaction)、时序(Timing)、effect(副作用)。它们不是三套独立机制,而是同一件事的三个侧面:当"状态"和"画面"要严格一致时,谁来、在哪、以什么顺序把状态变成像素。…
Keeping pointer work out of Vue
这篇文章记录一次完整的性能排查与架构收敛:默认 K 线 tooltip、鼠标坐标、hover 光标、Pane 头部、主图图例,这些高频交互状态是如何一遍遍穿过 Vue VDOM 的,以及如何把它们逐条挪回「RAF 内直接更新 DOM」的路径。