Workspaces、react 和 vite:一个关于管理重复库的真实案例研究
Source: Dev.to
介绍
在使用 npm workspaces 组织项目时,典型的结构是由一个根 package 来协调多个本地 package 和应用:
my-workspace
│ package.json
├─ apps
│ ├─ client
│ └─ server
└─ packages
├─ package-a
│ ├─ src
│ └─ package.json
└─ package-b
├─ src
└─ package.json
这种架构有助于模块化和代码复用,但共享依赖的管理会增加模块解析的复杂度,导致难以诊断的运行时错误。
常见的症状是错误:
Cannot destructure property 'basename' of 'w.useContext(...)' as it is null
这表明组件无法访问路由器上下文,即使已经提供了 Router。根本原因往往是依赖重复。
在本文中我们将分析:
- npm 如何处理版本解析
- Vite 如何处理依赖解析
- 如何使用 Vite 实现有针对性的去重策略
依赖配置
package-a
{
"dependencies": {
"react-router-dom": "^6.21.1"
}
}
package-b
{
"dependencies": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
react-router-dom 依赖于 react-router:
{
"name": "react-router-dom",
"version": "6.30.1",
"dependencies": {
"react-router": "6.30.1"
}
}
安装结果
当 npm 安装工作区时,它会尝试将依赖提升到工作区根目录,同时遵守语义化版本约束:
-
package-a首先被处理。^6.21.1的范围解析为最新兼容版本,例如6.30.2。该版本会被安装在工作区根目录。因此,react-router-dom@6.30.2(依赖react-router@6.30.2)也会被提升到根目录。 -
package-b接下来被处理。它将react-router和react-router-dom固定为6.30.1。由于这些版本与根目录提升的版本不同,npm 会在packages/package-b/node_modules中安装一份独立的副本,并包含自己的react-router@6.30.1。
最终的磁盘结构包含两个不同版本的这两个库:
my-workspace/
├─ node_modules/
│ ├─ react-router@6.30.2
│ └─ react-router-dom@6.30.2
└─ packages/
└─ package-b/
└─ node_modules/
├─ react-router@6.30.1
└─ react-router-dom@6.30.1
打包工具的角色
在现代前端开发中,打包工具(Webpack、Rollup、Vite 等)会遍历应用的依赖图,解析每个 import,并将代码打包供浏览器使用。
如果存在两个不同的物理副本,打包工具会在最终 bundle 中包含 两个 版本。这会导致:
- 包体积增大
- 同一库在代码库的不同位置使用不同版本,行为不一致
对于 react-router,版本不匹配会破坏上下文系统,产生前面展示的运行时错误。
去重策略
1. npm overrides
可以在根 package.json 中添加 overrides 字段,强制整个工作区使用单一版本:
{
"overrides": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
此方法可行,但对某些项目来说可能过于粗糙。
2. Vite resolve.dedupe
Vite 提供了原生的去重选项:
// vite.config.ts
export default {
resolve: {
dedupe: ['react-router', 'react-router-dom']
}
}
resolve.dedupe 会强制 Vite 将列出的包解析为项目根目录下的副本。在上述场景中,根目录包含的是 6.30.2 版本,因此 Vite 仍会打包较新的版本,忽略 package-b 中固定的 6.30.1。
3. 自定义 Vite 插件实现细粒度控制
当需要更精细的控制——例如始终解析到特定 package 中的版本时——可以实现一个拦截模块解析并重定向到指定位置的 Vite 插件。
import fs from 'fs';
import path from 'path';
import { PluginOption } from 'vite';
/**
* Plugin to force deduplication of specific packages toward
* a version contained in a defined relative path.
*
* @param dedupe - Array of package names to deduplicate (e.g. ['react-router'])
* @param packagePath - Relative path to the package to use as "source of truth"
*/
const enhancedDedupePlugin = ({
dedupe,
packagePath,
}: {
dedupe: string[];
packagePath: string;
}): PluginOption => ({
name: 'vite-plugin-enhanced-dedupe',
enforce: 'pre', // Run before Vite's default resolver
resolveId(source, _importer) {
// Only process whitelisted packages
if (!dedupe.includes(source)) return null;
// Build absolute path to the target node_modules
const targetBase = path.resolve(__dirname, packagePath, 'node_modules', source);
if (!fs.existsSync(targetBase)) return null;
// Locate the package's entry point
const pkgJsonPath = path.join(targetBase, 'package.json');
if (!fs.existsSync(pkgJsonPath)) return null;
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
const entry = pkg.module || pkg.main || 'index.js';
return path.join(targetBase, entry);
} catch {
return null; // Fallback to default resolution on error
}
},
});
export default {
plugins: [
enhancedDedupePlugin({
dedupe: ['react-router', 'react-router-dom'],
packagePath: '../package-a' // adjust to the package you trust
})
]
};
该插件:
- 检查导入的模块是否在
dedupe白名单中。 - 将其解析到
packagePath指定的node_modules文件夹。 - 若目标包未找到或出现错误,则回退到 Vite 的默认解析。
通过将 packagePath 指向包含期望版本的 package(例如 ../package-a),即可确保所有对 react-router 和 react-router-dom 的导入都指向同一物理副本,消除重复并避免相应的运行时错误。
结论
- npm workspaces 在版本约束不一致时会提升多个版本的同一库。
- Vite 默认会打包它遇到的每个物理副本,这可能导致体积膨胀和功能错误(如 React 上下文失效)。
- 像
overrides或resolve.dedupe这样的简单方案可能不足以满足复杂 monorepo 的需求。 - 使用自定义 Vite 插件将解析重定向到选定的 package,可实现精确的去重控制,确保整个工作区仅使用单一实例的关键库(如
react-router)。