一、什么是 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
二、核心工作原理
- 依赖提升(Hoisting)
将所有子包的依赖提升到根目录 node_modules,避免重复安装。
根 node_modules/ ├── react/ ← 提升上来(多个子包共用) ├── lodash/ ← 提升上来 └── ... packages/pkg-a/node_modules/ └── (空或仅有冲突版本)
好处:节省磁盘、加速安装、统一版本。 风险:幽灵依赖(Phantom Dependency)——代码能 require 到未在自己 package.json 声明的包。
- 符号链接(Symlink)
包之间的本地引用通过 symlink 实现:
packages/app/node_modules/@my/shared → symlink → packages/shared
当 app 中 import '@my/shared' 时,Node.js 解析到本地源码,修改 shared 立即生效,无需 publish。
- 协议标识
通过特殊协议声明本地依赖:
{ "dependencies": { "@my/shared": "workspace:*", // pnpm/yarn berry "@my/utils": "workspace:^1.0.0" } }
发布时包管理器会自动替换为真实版本号。
三、各包管理器的 Workspace 差异
- npm workspaces(npm 7+)
- 结构:扁平 node_modules,强提升
- 配置:根 package.json 中 "workspaces": ["packages/*"]
- 特点:实现简单,但幽灵依赖最严重,没有 workspace: 协议(npm 7-8),npm 9+ 才部分支持
- 命令:npm install -w pkg-a lodash
- yarn workspaces
- classic (v1):扁平提升,类似 npm
- berry (v2+):引入 PnP(Plug'n'Play)模式,不再使用 node_modules,通过 .pnp.cjs 文件做依赖解析
- 支持 workspace: 协议
- 特点:berry 完全消除了幽灵依赖,但生态兼容性需要适配
- pnpm workspaces ⭐ 推荐
- 结构:非扁平 + 硬链接 + symlink
- 所有包真实存储在全局 ~/.pnpm-store
- 项目 node_modules/.pnpm/ 下是硬链接副本
- 子包 node_modules/ 只放显式声明的依赖(symlink 指向 .pnpm)
- 严格模式:彻底杜绝幽灵依赖
- 磁盘效率最高:跨项目共享同版本依赖
- pnpm-workspace.yaml 单独配置:
packages:
- 'packages/*'
- 'apps/*'
- 对比表
这里主要指 Yarn v1 (Classic)。现在还有一个更新的 Yarn 2+ (Berry),它通过 即插即用(Plug'n'Play) 技术放弃了 node_modules 目录,用 .pnp.cjs 文件记录依赖映射,具有零安装等优势,但学习曲线较陡峭,且部分工具可能不兼容。
四、WorkSpace解决的核心问题
- 本地联调:改溢出依赖包,所有引用立刻看到————告别npm link的混乱
- 统一依赖版本:避免同一项目里出现多个React版本
- 原子化提交:跨包修改可以在一个commit/PR中完成
- 共享构建配置:tsConfig、esLint,构建脚本统一管理
- 配合构建工具: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'
- configs包
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"
}
]
}