Workspaces、react 和 vite:一个关于管理重复库的真实案例研究

发布: (2025年12月5日 GMT+8 03:03)
6 min read
原文: Dev.to

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 安装工作区时,它会尝试将依赖提升到工作区根目录,同时遵守语义化版本约束:

  1. package-a 首先被处理。^6.21.1 的范围解析为最新兼容版本,例如 6.30.2。该版本会被安装在工作区根目录。因此,react-router-dom@6.30.2(依赖 react-router@6.30.2)也会被提升到根目录。

  2. package-b 接下来被处理。它将 react-routerreact-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-routerreact-router-dom 的导入都指向同一物理副本,消除重复并避免相应的运行时错误。


结论

  • npm workspaces 在版本约束不一致时会提升多个版本的同一库。
  • Vite 默认会打包它遇到的每个物理副本,这可能导致体积膨胀和功能错误(如 React 上下文失效)。
  • overridesresolve.dedupe 这样的简单方案可能不足以满足复杂 monorepo 的需求。
  • 使用自定义 Vite 插件将解析重定向到选定的 package,可实现精确的去重控制,确保整个工作区仅使用单一实例的关键库(如 react-router)。
Back to Blog

相关文章

阅读更多 »