让 Monorepo 里的 Workspace 包在 Vite dev 里真·热更新
一次从"改代码不生效"到"拼出奇怪路径"到"终于搞清 Vite alias 语义"的深坑之旅
起因:一个看起来很基础的诉求
我维护一个基于 Vite 的文档站工具。有一类典型使用场景:用户项目是一个 pnpm monorepo,结构大致是:
my-monorepo/
├── package.json # root
├── pnpm-workspace.yaml
└── packages/
├── app/ # 运行 dev server 的包
│ └── package.json # 依赖下面两个 workspace 包
├── pkg-a/ # @scope/pkg-a
│ ├── src/
│ └── dist/ # 可能存在也可能不存在
└── pkg-b/ # @scope/pkg-b
└── src/
app/package.json 里有:
{
"dependencies": {
"@scope/pkg-a": "workspace:*",
"@scope/pkg-b": "workspace:*"
}
}
用户的诉求很朴素:
跑 dev 时,改packages/pkg-a/src/xxx.ts能不能像改app/src/xxx.ts一样自动 HMR?不要每次都 rebuild。
听起来是 Vite + pnpm workspace 的标配能力。但做起来就是各种翻车。
第一个坑:dist 根本不存在
首先用户连 dev server 都起不来。报错:
Failed to resolve import "@scope/pkg-a/style.css" from "app/src/xxx.vue"
看一下 pkg-a 的 package.json:
{
"type": "module",
"exports": {
".": { "import": "./dist/index.js" },
"./style.css": "./dist/index.css"
}
}
而用户刚拉下代码,packages/pkg-a/dist/ 压根没构建。Vite 严格按 exports map 去找 ./dist/index.js,找不到就报错。
最初的方案:手写 workspace-alias 插件
我写了个 Vite 插件,核心逻辑:
// 启动时扫描用户项目的 workspace 依赖
function buildAliasMap(userRoot) {
const pkg = readJson(path.join(userRoot, 'package.json'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
for (const [name, version] of Object.entries(deps)) {
if (!version.startsWith('workspace:')) continue;
// 通过 fs.realpathSync 解开 symlink,拿到真实目录
const depDir = fs.realpathSync(path.join(userRoot, 'node_modules', name));
// 读取这个包的 exports,把每条都映射到 src/ 下的对应文件
const depPkg = readJson(path.join(depDir, 'package.json'));
for (const [subpath, target] of Object.entries(depPkg.exports)) {
const importId = name + subpath.slice(1); // './style.css' → '@scope/pkg-a/style.css'
const srcFile = findSrcFile(depDir, resolveTarget(target));
if (srcFile) aliasMap.set(importId, srcFile);
}
}
}
// Vite 插件
return {
name: 'workspace-alias',
enforce: 'pre',
resolveId(id) {
return aliasMap.get(id) ?? null;
},
config() {
// 排除预构建,保住 HMR(pre-bundle 会把多文件打包成单 chunk,破坏 HMR)
return { optimizeDeps: { exclude: [...workspacePkgs] } };
},
};
findSrcFile 的核心是一个按优先级的查找链:
| 优先级 | 规则 | 适用场景 |
|---|---|---|
| 0 | target 路径直接存在 | "./upload": "./src/upload/index.ts" |
| 1 | dist/foo.js → src/foo.ts |
TS 源码 |
| 2 | dist/foo.js → src/foo.js |
同路径 |
| 3 | src/<basename(target)> |
dist/index.css → src/index.css |
| 4 | src/ 下唯一同扩展名文件 |
dist/index.css → src/style.css |
| 兜底 | src/<basename(subpath key)> |
"./style.css": "./dist/index.css" → src/style.css |
./style.css 就是希望暴露 style.css)。当 target 路径查不到时,用 key 的 basename 去 src 里找,比按文件名猜测更稳定。跑起来一验证——还是报错。
第二个坑:改了代码不生效
先不谈报错内容。一开始我以为是逻辑问题,反复改 findSrcFile。改完让用户重启,他说"还是报错"。但我独立跑一个调试脚本,逻辑明明是对的:
@scope/pkg-b/upload → /abs/packages/pkg-b/src/upload/index.ts ✓
重大疑点:用户实际跑的是哪个代码?
摸清工具的发布链路:
用户项目依赖 @scope/tool-builder@x.y.z
├─ tool-builder/dist/index.mjs 引用 tool-cli/dist
├─ tool-cli/dist/index.mjs 其实是 unbuild --stub 的产物
│ 通过 jiti 直接加载 tool-repo/packages/cli/src/index.ts
└─ cli 源码 import '@scope/tool-core'
→ resolve 到 tool-repo/packages/core/dist/index.mjs
也就是:
- cli 的源码改完立即生效(stub 通过 jiti 跑源码)
- core 的源码改完必须
pnpm build(走 dist)
我一直在改 core 里的 plugin,但没 rebuild 它的 dist。
rebuild core、用户 update 依赖,重新跑。依然报错。
第三个坑:神奇的拼接路径
这次我学聪明了,在插件里加日志:
resolveId(id, importer) {
console.log('[resolveId]', id, 'from:', importer?.slice(-60));
const resolved = aliasMap.get(id);
if (resolved) {
console.log('[HIT]', id, '→', resolved);
return resolved;
}
return null;
}
让用户重启 dev server,把输出发给我。日志海量滚动,我在里面找到了决定性的一行:
resolveId called: /abs/packages/pkg-a/src/index.ts/style.css
from: ...some-demo.vue
/.../src/index.ts/style.css —— 一个 .ts 文件路径,后面接着 /style.css?
这个拼错的 id 说明有人已经把 @scope/pkg-a 解析成了 /.../src/index.ts,然后把剩下的 /style.css 直接拼接上去。
谁干的? 除了我们自己的插件,还有 resolve.alias。
根因:Vite resolve.alias 字符串 find 是前缀匹配
回头看我们同时启用了两层机制:
// dev 配置
resolve: {
alias: [
{ find: '@scope/pkg-a', replacement: '/abs/.../src/index.ts' },
{ find: '@scope/pkg-a/style.css', replacement: '/abs/.../src/style.css' },
// ...
],
},
plugins: [workspaceAliasPlugin()], // enforce: 'pre'
我以为 resolve.alias 里字符串 find 是精确匹配(就像对象 key 一样)。错了。
翻 Vite 文档与源码(内部用 @rollup/plugin-alias):
字符串 find 的匹配规则是前缀匹配。id 以 find 开头就会命中,replacement 接上 id 剩下的部分。
这意味着当 id 是 @scope/pkg-a/style.css 时:
尝试匹配第一条 alias:
find: '@scope/pkg-a'
id: '@scope/pkg-a/style.css'
^^^^^^^^^^^ 剩余 '/style.css'
匹配成功 → replacement + '/style.css'
= '/abs/.../src/index.ts' + '/style.css'
= '/abs/.../src/index.ts/style.css' ❌
第一条 alias 把 @scope/pkg-a/* 全吞了,第二条精确匹配 @scope/pkg-a/style.css 的 alias 根本没轮到执行。
修复:精确匹配正则
Vite alias 支持正则 find,用正则加 ^...$ 就能强制精确匹配:
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
resolve: {
alias: [
{ find: new RegExp(`^${escape('@scope/pkg-a')}$`), replacement: '/abs/.../src/index.ts' },
{ find: new RegExp(`^${escape('@scope/pkg-a/style.css')}$`), replacement: '/abs/.../src/style.css' },
],
}
现在:
@scope/pkg-a只匹配主包本身,不再吞@scope/pkg-a/*@scope/pkg-a/style.css由独立的精确正则命中@scope/pkg-a/unknown.css谁也不匹配,落到 plugin 的resolveId
一个字符替换的小改动,终于跑通了。
回头看整个热更新是怎么工作的
一个清晰的时序图:
1. 启动 dev
└─ workspace-alias plugin.config() 扫描 workspace 依赖
生成 aliasMap:
'@scope/pkg-a' → /abs/pkg-a/src/index.ts
'@scope/pkg-a/style.css' → /abs/pkg-a/src/style.css
'@scope/pkg-b/upload' → /abs/pkg-b/src/upload/index.ts
注入 optimizeDeps.exclude: ['@scope/pkg-a', '@scope/pkg-b']
2. 浏览器请求 some.vue
└─ vite:import-analysis 发现 bare import '@scope/pkg-a/style.css'
调用 pluginContainer.resolveId('@scope/pkg-a/style.css', 'some.vue')
└─ resolve.alias 精确正则匹配 → '/abs/pkg-a/src/style.css'
(或者 plugin 的 resolveId 命中)
3. Vite 加载 /abs/pkg-a/src/style.css
└─ 内置 CSS 处理、Vue SFC 处理正常工作
└─ 文件被 chokidar 监听器注册
4. 开发者改 /abs/pkg-a/src/style.css
└─ chokidar 触发 change 事件
└─ Vite 模块图标记该模块 invalid
└─ 经过 HMR propagation 到使用它的 some.vue
└─ WebSocket 推送更新,浏览器应用新样式
5. 开发者改 /abs/pkg-a/src/index.ts
└─ 同上,SFC 通过 @vitejs/plugin-vue 走完整 HMR
为什么 HMR 能自然工作:
resolveId返回的是fs.realpathSync解开 symlink 后的真实绝对路径- Vite 的文件监听器基于真实路径注册,自然能监听到
optimizeDeps.exclude阻止了 pre-bundle,这些包以源码形式被 Vite 处理- 源码 →
@vitejs/plugin-vue/ CSS plugin → 正常 HMR 流程
本质上,workspace 包被"视作用户项目的一部分"来处理,而不是作为第三方依赖。
为什么 build 模式要走正常流程
dev 的便利反过来会伤到 build:
- 用户发版时通常已经
pnpm -r build把所有包 build 完 - build 产物应该用 dist,不用 src(版本一致性、tree-shaking、类型正确性)
- src 不存在或不规范的包(比如
package.json里 exports 写错了)应该在 build 时暴露出来,而不是被我们的兜底逻辑掩盖
所以插件设计成条件启用:
export function makePlugins(userConfig, isDev = false) {
return [
workspaceAliasPlugin(userConfig, isDev), // isDev=false 时返回 no-op
vue(...),
...
];
}
// dev 命令
plugins: [...makePlugins(userConfig, true)]
// build 命令
plugins: [...makePlugins(userConfig, false)] // 默认 false
workspaceAliasPlugin 在 !isDev 时直接返回:
if (!isDev) return { name: 'workspace-alias' };
build 时 Vite 走标准 exports 解析,dist/index.js 不存在就直接报错,符合工程期望。
为什么保留 plugin + resolve.alias 双保险
理论上 plugin 的 resolveId 能处理所有情况,但我选择保留 resolve.alias 作为第二层兜底:
- Vite 对
resolve.alias的处理在某些内部阶段(例如 CSS@import、HTML 入口)比 plugin resolveId 更早 resolve.alias参与optimizeDeps的 scan 阶段,影响预构建决策- 两套机制独立,一个失效另一个还能兜着
server.fs.allow本来就需要 workspace 包的真实目录,顺便拿到
两层都用精确匹配,不会互相冲突。冗余带来的代码量微不足道,换来的可靠性很值。
一些排错经验总结
1. 加日志优于瞎猜
一开始我以为是 findSrcFile 写得不够全,反复加回退逻辑。真正的突破口只是一行 console.log:
resolveId(id, importer) {
console.log('[resolveId]', id, 'from:', importer?.slice(-60));
...
}
日志里的拼错路径 /.../src/index.ts/style.css 直接指向了 alias 前缀匹配问题。
2. 确认代码真的在跑
改源码不生效,第一件事不是继续改,是确认这份源码是否真的被执行。
可以用的手段:
- 在函数开头加一行
throw new Error('XXX'),如果没报错说明根本没走到 - 看依赖链(
pnpm why/npm ls) - 看
node_modules里的真实文件内容(grep 你加的独特字符串)
我这次的陷阱是 cli 包是 stub、core 包走 dist,两个包代码更新机制不同。
3. 清依赖缓存
Vite 的 node_modules/.vite/deps/ 是持久化的。改 alias 或 optimizeDeps 但 configHash 没变时,可能吃旧缓存。
rm -rf node_modules/.vite,比反复重启 server 有效。4. 读文档不如读代码
Vite alias 是前缀匹配这件事,官方文档有但容易错过。最终让我确认的是翻 @rollup/plugin-alias 的源码。
5. 别被错误栈误导
错误栈里的 vite:import-analysis 很容易让人以为是这个插件的锅。其实它只是最后一个报错点,真正有问题的是它之前某个阶段调 resolveId 传进来的 id 就已经错了。
给后来人的建议
如果你要在 Vite 里实现类似的 "workspace 包源码直跑" 能力,记住这几点:
- 用
enforce: 'pre'的自定义 plugin 在resolveId拦截 bare import,把它重定向到 src 真实路径。不要依赖resolve.alias的字符串形式。 - 所有需要精确匹配的 alias 一律用正则
/^xxx$/。字符串 find 是前缀匹配,坑不容易看出。 optimizeDeps.exclude必须包含目标 workspace 包,否则 pre-bundle 把多个模块合成一个 chunk,HMR 会失效。fs.realpathSync解开 pnpm 的 symlink,把真实绝对路径交给 Vite。用 symlink 路径 Vite 某些监听器会失效。- dev / build 行为要分离。dev 的兜底策略对 build 是隐患(掩盖 dist 缺失、版本不一致)。
- 保留
resolve.alias+ plugin 的双保险。两套机制覆盖不同阶段(scan / HTML / CSS @import / 普通 JS import),冗余是安全的。 findSrcFile的查找策略:按优先级,先直接查 target 路径,再做 dist→src 替换和.js→.ts,最后按 subpath key 文件名兜底。别依赖"唯一同扩展名"这种不稳定规则。
结语
看起来是一个简单的"workspace 包 HMR"需求,真正写出来踩了一圈坑:
- 需要理解 exports map 的 Node 解析规则
- 需要理解 Vite 的插件链 / alias 匹配语义 / optimizeDeps 与 HMR 的关系
- 需要理解 pnpm symlink 对 watch 的影响
- 需要理解自己工具链的构建发布机制(stub vs dist)
每一层的默认行为都是"正确但不够用",要让它们协同工作必须把每一层的语义都搞清楚。
希望这篇踩坑复盘能帮下一个做类似事情的人省点头发。