MonoRepo

0 阅读7分钟

一、什么是 Workspace

Workspace 是包管理器提供的一种多包管理机制,允许在一个仓库(monorepo)中管理多个相互关联的 npm 包。核心思想是:将多个 package 视为一个整体进行依赖管理、版本控制和构建。

my-monorepo/ ├── package.json # 根 package.json,声明 workspaces ├── packages/ │ ├── pkg-a/ │ │ └── package.json # 子包 A │ ├── pkg-b/ │ │ └── package.json # 子包 B │ └── shared/ │ └── package.json # 共享包 └── apps/ └── web/ └── package.json

二、核心工作原理

  1. 依赖提升(Hoisting)

将所有子包的依赖提升到根目录 node_modules,避免重复安装。

根 node_modules/ ├── react/ ← 提升上来(多个子包共用) ├── lodash/ ← 提升上来 └── ... packages/pkg-a/node_modules/ └── (空或仅有冲突版本)

好处:节省磁盘、加速安装、统一版本。 风险:幽灵依赖(Phantom Dependency)——代码能 require 到未在自己 package.json 声明的包。

  1. 符号链接(Symlink)

包之间的本地引用通过 symlink 实现:

packages/app/node_modules/@my/shared → symlink → packages/shared

当 app 中 import '@my/shared' 时,Node.js 解析到本地源码,修改 shared 立即生效,无需 publish。

  1. 协议标识

通过特殊协议声明本地依赖:

{ "dependencies": { "@my/shared": "workspace:*", // pnpm/yarn berry "@my/utils": "workspace:^1.0.0" } }

发布时包管理器会自动替换为真实版本号。

三、各包管理器的 Workspace 差异

  1. npm workspaces(npm 7+)
  • 结构:扁平 node_modules,强提升
  • 配置:根 package.json 中 "workspaces": ["packages/*"]
  • 特点:实现简单,但幽灵依赖最严重,没有 workspace: 协议(npm 7-8),npm 9+ 才部分支持
  • 命令:npm install -w pkg-a lodash
  1. yarn workspaces
  • classic (v1):扁平提升,类似 npm
  • berry (v2+):引入 PnP(Plug'n'Play)模式,不再使用 node_modules,通过 .pnp.cjs 文件做依赖解析
  • 支持 workspace: 协议
  • 特点:berry 完全消除了幽灵依赖,但生态兼容性需要适配
  1. pnpm workspaces ⭐ 推荐
  • 结构:非扁平 + 硬链接 + symlink
    • 所有包真实存储在全局 ~/.pnpm-store
    • 项目 node_modules/.pnpm/ 下是硬链接副本
    • 子包 node_modules/ 只放显式声明的依赖(symlink 指向 .pnpm)
  • 严格模式:彻底杜绝幽灵依赖
  • 磁盘效率最高:跨项目共享同版本依赖
  • pnpm-workspace.yaml 单独配置: packages:
    • 'packages/*'
    • 'apps/*'
  1. 对比表
特性pnpmnpmYarn (v1 Classic)
配置方式pnpm-workspace.yaml,更灵活package.json 的 workspaces 字段package.json 的 workspaces 字段
安装模型内容寻址存储 + 符号链接,通过硬链接将包指向全局store,磁盘效率极高传统的扁平化 node_modules,兼容性好,但存在依赖提升(hoisting)传统的扁平化 node_modules,与npm类似,但速度稍快
严格模式与幻影依赖默认严格,子项目只能访问自己在 package.json 中声明的依赖,避免了幻影依赖问题宽松依赖提升可能导致幻影依赖(即代码使用了未声明的依赖)宽松,与npm类似,存在幻影依赖风险
适用场景大型复杂 Monorepo,对磁盘空间、速度和严格性要求高中小型项目,简单场景,团队熟悉,配置成本低中大型项目,性能优异,生态兼容性好

这里主要指 Yarn v1 (Classic)。现在还有一个更新的 Yarn 2+ (Berry),它通过 即插即用(Plug'n'Play)  技术放弃了 node_modules 目录,用 .pnp.cjs 文件记录依赖映射,具有零安装等优势,但学习曲线较陡峭,且部分工具可能不兼容。 四、WorkSpace解决的核心问题

  1. 本地联调:改溢出依赖包,所有引用立刻看到————告别npm link的混乱
  2. 统一依赖版本:避免同一项目里出现多个React版本
  3. 原子化提交:跨包修改可以在一个commit/PR中完成
  4. 共享构建配置:tsConfig、esLint,构建脚本统一管理
  5. 配合构建工具:Turborepo,Nx,Rush在workspace基础上做增量构建+缓存+依赖图分析 五、典型工程实践

pnpm 示例

pnpm install # 安装所有 workspace 依赖 pnpm --filter @my/app add lodash # 给指定包加依赖 pnpm --filter @my/app... build # 构建 app 及其依赖 pnpm -r run test # 递归在所有 workspace 中运行 test

总结

Workspace的本质是"用symlink+依赖提升/隔离,把多个package在物理上统一管理,逻辑上互相可见。三大包管理器的核心差异在于依赖布局策略:

  • npm/yarn classic->扁平化(简单但混乱)
  • yarn berry->零node_modules(激进但兼容性差)
  • pnpm ->严格嵌套(综合最优,monorepo首选) 如果是新项目搭建 monorepo,推荐 pnpm workspaces + Turborepo/Nx 的组合。

实战举例

packages:
  - 'packages/*'
  - 'apps/*'
  - 'configs/*' # 存放公共配置(ESLint/TSConfig/Prettier等)
sharedDependencies:
  - 'wujie-react'
  - '@ant-design/icons'
  - 'antd'
  - 'antd-style'
  - 'react'
  - 'react-dom'
  - 'react-router-dom'
  - 'typescript-eslint'
  - 'zustand'
  - '@eslint/js'
  - '@types/react'
  - '@types/react-dom'
  - '@types/react-router-dom'
  - '@vitejs/plugin-legacy'
  - '@vitejs/plugin-react'
  - '@ebay/nice-modal-react'
  - 'core-js'
  - 'eslint'
  - 'eslint-plugin-react-hooks'
  - 'eslint-plugin-react-refresh'
  - 'globals'
  - 'regenerator-runtime'
  - 'typescript'
  - 'typescript-eslint'
  - 'vite'
  1. configs包

image.png

1.1 api-config

{
  "name": "@config/api-config",
  "main": "index.js",
  "type": "module"
}
export const DEV_TARGET = '';

export const DEV_PORT_M = '';

export const DEV_PORT_P = '';

export const PROXY_CONFIG = {
  '/XXXX': {
    target: `http://${DEV_TARGET}:${DEV_PORT_P}`,
    changeOrigin: true,
    pathRewrite: {
      '^/uai-ac-app': '/uai-ac-app',
    },
  },
  '/aa/bb/cc': {
    target: `http://${DEV_TARGET}:${DEV_PORT_M}`,
    changeOrigin: true,
  },
  'a/b/c': {
    target: `http://${DEV_TARGET}:${DEV_PORT_P}`,
  },
};

1.2eslint-config

{
  "name": "@config/eslint",
  "type": "module",
  "main": "index.js",
  "dependencies": {
    "@eslint/js": "^9.22.0",
    "@typescript-eslint/parser": "^8.30.1",
    "eslint": "^9.22.0",
    "eslint-plugin-react": "^7.33.2",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript-eslint": "^8.26.1"
  },
  "devDependencies": {
    "eslint-plugin-unused-imports": "^4.1.4"
  }
}
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import unusedImports from 'eslint-plugin-unused-imports';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
      'unused-imports': unusedImports,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
      '@typescript-eslint/no-explicit-any': 'off',
      'react-hooks/exhaustive-deps': 'off',
      '@/semi': ['error', 'always'],
      'unused-imports/no-unused-imports': 'error',
      'no-restricted-globals': ['error', 'require'],
      'no-restricted-syntax': [
        'error',
        {
          selector: "CallExpression[callee.name='require']",
          message: 'Use ESM import syntax instead',
        },
      ],
    },
  },
);

1.3 min-vite-config

{
  "name": "@config/mini-vite-config",
  "type": "module",
  "devDependencies": {
    "@babel/core": "^7.27.1",
    "@babel/plugin-transform-modules-commonjs": "^7.27.1",
    "@babel/plugin-transform-react-jsx": "^7.27.1",
    "@babel/plugin-transform-runtime": "^7.27.1",
    "@babel/preset-env": "^7.27.2",
    "@babel/preset-react": "^7.27.1",
    "@babel/preset-typescript": "^7.27.1",
    "@babel/runtime": "^7.27.1",
    "@config/api-config": "workspace:*",
    "@rollup/plugin-babel": "^6.0.4",
    "@vitejs/plugin-legacy": "^6.1.0",
    "@vitejs/plugin-react": "^4.3.4",
    "autoprefixer": "^10.4.21",
    "babel-plugin-import": "^1.13.8",
    "postcss": "^8.5.3",
    "postcss-prefix-selector": "^2.1.1",
    "postcss-preset-env": "^10.1.6",
    "postcss-selector-not": "^8.0.1",
    "vite": "^6.3.1",
    "vite-plugin-compression": "^0.5.1",
    "vite-plugin-mock": "^3.0.2",
    "vite-plugin-stylelint": "^6.0.0",
    "vite-plugin-svgr": "^4.3.0"
  }
}

import path from 'path';
import stylelint from 'vite-plugin-stylelint';
import svgr from 'vite-plugin-svgr';
/** @ts-expect-error ... */
import react from '@vitejs/plugin-react';
/** @ts-expect-error ... */
import legacy from '@vitejs/plugin-legacy';
/** @ts-expect-error ... */
import ProcessPreset from 'postcss-preset-env';
/** @ts-expect-error ... */
import ProcessSelectNot from 'postcss-selector-not';

import viteCompression from 'vite-plugin-compression';

/** @ts-expect-error */
import { PROXY_CONFIG } from '@config/api-config';
import { defineConfig } from 'vite';

// https://vite.dev/config/
export const getMiniAppViteConfig = (props, __dirname, PORT = 8170) => {
  const mode = props.mode;

  const isDevMode = mode === 'development';

  const isPreviewMode = props.isPreview;

  const devUrl = `http://localhost:${PORT}`;

  return defineConfig({
    cacheDir: `.vite`,
    optimizeDeps: {
      force: false,
    },
    server: {
      host: '0.0.0.0',
      origin: devUrl,
      port: PORT, // 指定端口
      strictPort: false, // 如果端口被占用则直接退出
      hmr: true, // 启用热更新
      cors: true,
      headers: isDevMode
        ? {
            'Access-Control-Allow-Origin': '*', // 允许所有域访问
          }
        : {},
      proxy: PROXY_CONFIG, // 开发环境其实没有生效,直接走的主应用的代理,配置成生效的还比较麻烦
    },
    preview: {
      port: PORT,
    },
    base: isDevMode ? devUrl : '/mini-app-self-name/', // 生产环境静态资源路径(需与主应用部署路径匹配)
    plugins: [
      react(
        isDevMode
          ? {}
          : {
              babel: {
                configFile: path.resolve(__dirname, './babel.config.js'), // 启用 .babel.config.ts 配置文件parserOpts: { plugins: ['jsx', 'importMeta'] },
                parserOpts: {
                  plugins: ['jsx', 'importMeta', 'topLevelAwait'],
                },
                generatorOpts: {
                  jsescOption: {
                    minimal: true,
                    references: false,
                  },
                },
              },
            },
      ),
      // viteMockServe({
      //   mockPath: path.resolve(__dirname, '../main-app/mock'), // Mock 文件存放目录
      //   enable: false && (isDevMode || isPreviewMode), // 仅在开发环境启用
      //   watchFiles: true, // 监听 Mock 文件变化
      //   logger: true, // 控制台显示请求日志
      //   ignore: /^_/, // 忽略以 _ 开头的文件
      // }),
      svgr({
        svgrOptions: {
          icon: true, // 可选图标优化配置
        },
      }), // 默认导出 ReactComponent,放在react()后
      legacy({
        targets: ['chrome >= 49'],
        additionalLegacyPolyfills: ['regenerator-runtime/runtime', 'core-js/stable/promise'],
        modernPolyfills: true,
        renderLegacyChunks: !isDevMode, // 确保开发环境不生效
      }),
      stylelint({
        fix: true, // 自动修复可修复的问题
        include: ['src/**/*.{less,css}'],
        exclude: ['node_modules', '**/antd/**'],
      }),
      viteCompression(),
      // 这里按理说要有antd按需加载的配置,但是antd5.x已经内置了esm支持
    ],
    esbuild: {
      target: 'es2015',
    },
    build: {
      outDir: '../../dist/mini-app-self-name',

      // target: ['es2015'], // 降级至 ES2015 语法
      cssTarget: 'chrome49', // CSS 兼容目标
      cssCodeSplit: true,
      minify: 'terser', // 使用 terser 压缩
      // minify: false,
      assetsDir: 'assets',
      assetsInlineLimit: 4096, // 小于 4KB 资源转为 Base
      chunkSizeWarningLimit: 500,
      commonjsOptions: {
        include: isDevMode ? '' : /node_modules/, // 强制转换 node_modules 中的模块
        transformMixedEsModules: !isDevMode, // 强制转换混合模块
      },
      rollupOptions: {
        external: () => false, // 排除外部依赖
        output: {
          assetFileNames: 'assets/[name].[hash][extname]',
          // CSS 分包策略
          chunkFileNames: 'assets/[name]-[hash].js',
          manualChunks: (id) => {
            if (id.includes('node_modules')) {
              // 将 node_modules 按包拆分
              if (id.includes('react')) {
                return 'vendor-react'; // React 相关单独分包
              }
              if (id.includes('ant')) {
                return 'vendor-antd';
              }

              return 'vendor-common'; // 其他依赖合并
            }

            if (id.includes('packages/core/dist')) {
              return 'uai-common-core';
            }

            if (id.includes('packages/components/dist')) {
              return 'uai-common-component';
            }

            // 按路由拆分业务代码(需配合动态导入)
            if (id.includes('/src/pages/')) {
              const name = id.split('/src/pages/')[1].split('/')[0];
              return `page-${name}`;
            }
          },
        },
      },
      terserOptions: {
        compress: {
          drop_console: true, // 生产环境移除 console
        },
      },
    },
    optimizeDeps: {
      include: isDevMode
        ? ['react', 'react-dom']
        : [
            'react',
            'react-dom',
            'antd/es/locale/zh_CN',
            '@babel/runtime/helpers/interopRequireDefault',
            '@babel/runtime/helpers/extends',
            'core-js/stable',
            'regenerator-runtime/runtime',
            '@packages/components',
            '@packages/core',
          ],
      // exclude: ['@vitejs/plugin-legacy'], // 排除已处理插件
      esbuildOptions: {
        target: 'es2015', // 对齐构建目标
        format: 'esm',
        // banner: {
        //   js: `import { createRequire } from "module"; const require = createRequire(import.meta.url);`,
        // },
      },
    },
    css: {
      postcss: {
        plugins: [
          ProcessSelectNot({ preserve: false }), // 转换 :not(a,b) → :not(a):not(b)
          ProcessPreset({
            browsers: 'chrome >= 49', // 自动添加 CSS 前缀
            autoprefixer: { grid: true },
          }),
        ],
      },
      modules: {
        scopeBehaviour: 'local', // 关键配置:启用局部作用域
        localsConvention: 'camelCase', // 驼峰命名
        generateScopedName: '[name]__[local]___[hash:base64:5]', // 开发环境友好类名
      },
      preprocessorOptions: {
        less: {
          additionalData: `@import "@packages/styles/base.less";`,
          javascriptEnabled: true,
          modifyVars: {
            '@primary-color': '#365fd9;', // 覆盖 Ant 主题变量
          },
        },
      },
      transformer: 'postcss',
    },
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src'),
        '@packages': path.resolve(__dirname, '../../packages'),
        '@react-refresh': '/@react-refresh',
        'core-js/modules': 'core-js/stable', // 强制指向稳定版路径
      },
      extensions: ['.ts', '.tsx', '.js', '.jsx'], // 让 Vite 自动尝试这些扩展名
    },
  });
};

1.4 tsconfig-config

{
  "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
  "compilerOptions": {
    "target": "es2015",
    "lib": ["DOM", "ES5", "ES2015", "es2015.promise"],
    "types": ["vite/client", "vite-plugin-svgr/client"],
    "module": "ESNext",
    "strict": true,
    "jsx": "react-jsx",
    "jsxImportSource": "react",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "exclude": ["node_modules"]
}
{
  "name": "@config/tsconfig",
  "main": "base.json"
}
{
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES5",
    "lib": ["DOM", "ES5", "ES2015"], // 兼容旧版浏览器 API
    "types": ["vite/client"],
    "useDefineForClassFields": true,
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
}

{
  "compilerOptions": {
    "forceConsistentCasingInFileNames": true,
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
    "target": "ES5",
    "lib": ["DOM", "ES5", "ES2015"], // 兼容旧版浏览器 API
    "types": ["vite/client"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["vite.config.ts"]
}

1.5 stylelint-config

{
  "name": "@config/stylelint",
  "version": "1.0.0",
  "main": ".stylelintrc.json",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
 "author": "cheng.li04",
  "license": "ISC",
  "dependencies": {
    "postcss-less": "^6.0.0",
    "stylelint": "16.18.0",
    "stylelint-config-standard": "38.0.0",
    "stylelint-less": "3.0.1",
    "stylelint-order": "7.0.0"
  }
}
{
  "extends": ["stylelint-config-standard"],
  "plugins": ["stylelint-order", "stylelint-less"],
  "rules": {
    "at-rule-no-unknown": null,
    "no-descending-specificity": null,
    "selector-class-pattern": null,
    "function-url-quotes": "always",
    "declaration-empty-line-before": "never",
    "less/color-no-invalid-hex": true,
    "less/no-duplicate-variables": true,
    "order/order": ["custom-properties", "declarations"],
    "property-no-vendor-prefix": null,
    "order/properties-order": [
      "position",
      "top",
      "right",
      "bottom",
      "left",
      "display",
      "float",
      "width",
      "height",
      "margin",
      "padding",
      "border",
      "background",
      "color",
      "font",
      "transform",
      "animation"
    ],
    "selector-pseudo-class-no-unknown": [
      true,
      {
        "ignorePseudoClasses": ["global"]
      }
    ],
    "declaration-property-value-no-unknown": [
      true,
      {
        "ignoreProperties": {
          "/.*/": ["/@.*/"]
        }
      }
    ]
  },
  "overrides": [
    {
      "files": ["**/*.less"],
      "customSyntax": "postcss-less"
    }
  ]
}