🏠

让 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,找不到就报错。

💡 解决思路:dev 时我们不走 dist,直接指向 src 源文件。

最初的方案:手写 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.jssrc/foo.ts TS 源码
2 dist/foo.jssrc/foo.js 同路径
3 src/<basename(target)> dist/index.csssrc/index.css
4 src/ 下唯一同扩展名文件 dist/index.csssrc/style.css
兜底 src/<basename(subpath key)> "./style.css": "./dist/index.css"src/style.css
💡 反常识的洞察:exports 的 key 本身就是语义化文件名(作者写 ./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

也就是:

我一直在改 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' },
  ],
}

现在:

一个字符替换的小改动,终于跑通了。


回头看整个热更新是怎么工作的

一个清晰的时序图

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 能自然工作

本质上,workspace 包被"视作用户项目的一部分"来处理,而不是作为第三方依赖。


为什么 build 模式要走正常流程

dev 的便利反过来会伤到 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 作为第二层兜底:

  1. Vite 对 resolve.alias 的处理在某些内部阶段(例如 CSS @import、HTML 入口)比 plugin resolveId 更早
  2. resolve.alias 参与 optimizeDeps 的 scan 阶段,影响预构建决策
  3. 两套机制独立,一个失效另一个还能兜着
  4. 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 前缀匹配问题。

Rule:对第三方机制感到困惑时,先打印它实际看到了什么,而不是猜它应该看到什么。

2. 确认代码真的在跑

改源码不生效,第一件事不是继续改,是确认这份源码是否真的被执行。

可以用的手段:

我这次的陷阱是 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 包源码直跑" 能力,记住这几点:

  1. enforce: 'pre' 的自定义 plugin 在 resolveId 拦截 bare import,把它重定向到 src 真实路径。不要依赖 resolve.alias 的字符串形式。
  2. 所有需要精确匹配的 alias 一律用正则 /^xxx$/。字符串 find 是前缀匹配,坑不容易看出。
  3. optimizeDeps.exclude 必须包含目标 workspace 包,否则 pre-bundle 把多个模块合成一个 chunk,HMR 会失效。
  4. fs.realpathSync 解开 pnpm 的 symlink,把真实绝对路径交给 Vite。用 symlink 路径 Vite 某些监听器会失效。
  5. dev / build 行为要分离。dev 的兜底策略对 build 是隐患(掩盖 dist 缺失、版本不一致)。
  6. 保留 resolve.alias + plugin 的双保险。两套机制覆盖不同阶段(scan / HTML / CSS @import / 普通 JS import),冗余是安全的。
  7. findSrcFile 的查找策略:按优先级,先直接查 target 路径,再做 dist→src 替换和 .js→.ts,最后按 subpath key 文件名兜底。别依赖"唯一同扩展名"这种不稳定规则。

结语

看起来是一个简单的"workspace 包 HMR"需求,真正写出来踩了一圈坑:

每一层的默认行为都是"正确但不够用",要让它们协同工作必须把每一层的语义都搞清楚。

✅ 这就是基础设施类代码的典型场景 —— 功能点一句话能说完,但落地要理解完整的上下游。

希望这篇踩坑复盘能帮下一个做类似事情的人省点头发。