本文基于 pnpm workspace 实现 Monorepo 架构,从核心设计到落地实操,手把手教你搭建 Vue3 + Vite 中后台多项目、组件库与公共包统一管理方案,解决 Vue 依赖、热更新、循环依赖、TS 类型等工程化高频问题。
📑 文章目录
- [一、开篇:Monorepo 是什么?什么时候用?](#一、开篇:Monorepo 是什么?什么时候用?)
- 二、场景一:多个中后台项目怎么管?
- 三、场景二:内部组件库怎么抽?
- 四、场景三:公共包(utils、API、类型)怎么拆?
- [五、工具选型:pnpm workspace + 简单脚本](#五、工具选型:pnpm workspace + 简单脚本)
- 六、从零搭建:完整目录与配置
- [6.1 目录结构](#6.1 目录结构)
- [6.2 根目录
pnpm-workspace.yaml](#6.2 根目录 pnpm-workspace.yaml) - [6.3 根目录
package.json](#6.3 根目录 package.json) - [6.4 根目录
.npmrc(可选但推荐)](#6.4 根目录 .npmrc(可选但推荐)) - [6.5 包之间的依赖示例](#6.5 包之间的依赖示例)
- [6.6 App 端 Vite 配置(Vue3 + Vite + TS)](#6.6 App 端 Vite 配置(Vue3 + Vite + TS))
- [6.7 ESLint / Prettier 统一配置](#6.7 ESLint / Prettier 统一配置)
- [6.8 组件库打包完整配置](#6.8 组件库打包完整配置)
- 七、依赖管理:内部包怎么互相引用?
- 八、构建与发布:怎么打包、怎么发版?
- [8.1 CI/CD:只构建变更的包](#8.1 CI/CD:只构建变更的包)
- 九、常见踩坑与解决方案
- [9.1 Vue 找不到依赖(如找不到
vue)](#9.1 Vue 找不到依赖(如找不到 vue)) - [9.2 改
packages代码,app 不热更新](#9.2 改 packages 代码,app 不热更新) - [9.3 循环依赖](#9.3 循环依赖)
- [9.4 TypeScript 路径与类型](#9.4 TypeScript 路径与类型)
- [9.5 不同 app 的 Vue / Element 版本不一致](#9.5 不同 app 的 Vue / Element 版本不一致)
- [9.1 Vue 找不到依赖(如找不到
- [十、小结:何时用 Monorepo,何时不用](#十、小结:何时用 Monorepo,何时不用)
同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~
(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)
你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?
你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?
就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。
一天只有24小时,时间永远不够用,常常感到力不从心。
技术行业,本就是逆水行舟,不进则退。
如果你也有同样的困扰,别慌。
从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲。
这一次,我们一起慢慢来,扎扎实实变强。
不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,
咱们一起稳步积累,真正摆脱「面向搜索引擎写代码」的尴尬。
一、开篇:Monorepo 是什么?什么时候用?
一句话解释:Monorepo 是把多个相关项目放在同一个仓库里,通过统一工具管理依赖和构建的一种组织方式。
和 Multirepo 的区别:
| 维度 | Monorepo | Multirepo |
|---|---|---|
| 仓库数量 | 一个仓库多个包 | 多个仓库多个包 |
| 依赖共享 | 容易,直接 workspace 引用 | 需要发 npm 或 git submodule |
| 联调改公共代码 | 改完立刻生效 | 要发版、再安装 |
| 权限隔离 | 同一仓库,难精细控制 | 可按项目拆仓库 |
适合用 Monorepo 的典型场景:
- 多个中后台(管理端 A、管理端 B、运营端等)共用一套组件和工具
- 公司内部组件库 + 多个业务项目
- 大量公共包(utils、API、类型定义)被多个项目复用
一句话:多个强关联项目 + 需要频繁联调,就值得考虑 Monorepo。
[⬆ 返回目录](#⬆ 返回目录)
二、场景一:多个中后台项目怎么管?
很多公司会拆成:管理后台、运营后台、客服后台、数据看板等,它们风格接近,但路由、权限、业务不同。
传统做法:每个后台一个仓库
Plain
├── admin-a/ # 仓库1
├── admin-b/ # 仓库2
├── admin-c/ # 仓库3
└── common-ui/ # 仓库4(组件库)
问题:改 common-ui 一个按钮,要发版 → 各后台 npm update,联调成本高。
Monorepo 做法 :所有项目放一个仓库,共享 packages 里的公共包。
Plain
my-monorepo/
├── apps/
│ ├── admin-a/ # 管理后台 A
│ ├── admin-b/ # 管理后台 B
│ └── dashboard/ # 数据看板
├── packages/
│ ├── ui/ # 组件库
│ ├── utils/ # 公共工具
│ └── api/ # 接口封装
├── pnpm-workspace.yaml
└── package.json
改 packages/ui 里的组件,各 apps 直接依赖 workspace 包,热更新即可,无需发布。
[⬆ 返回目录](#⬆ 返回目录)
三、场景二:内部组件库怎么抽?
多个后台共用的表格、表单、弹窗、布局等,适合放到 packages/ui。
需要解决的问题:
- 组件库如何被各个 app 引用?
- 开发时如何做本地预览(类似 Storybook)?
- 如何打包成可发布的 umd/es 产物?
推荐结构:
Plain
packages/ui/
├── src/
│ ├── Button/
│ ├── Table/
│ └── index.ts # 统一导出
├── package.json
├── vite.config.ts # 或 webpack 配置
└── examples/ # 可选:本地开发示例
每个 app 通过 workspace 引用:
json
// apps/admin-a/package.json
{
"dependencies": {
"@my/ui": "workspace:*"
}
}
这样改组件库代码时,各 app 会实时生效,无需发布到 npm。
[⬆ 返回目录](#⬆ 返回目录)
四、场景三:公共包(utils、API、类型)怎么拆?
除组件库外,还有三类常见公共包:
| 包名 | 作用 | 示例 |
|---|---|---|
@my/utils |
工具函数 | 日期格式化、防抖、权限判断 |
@my/api |
接口封装 | axios 实例、请求拦截、接口方法 |
@my/types |
类型定义 | 接口返回类型、枚举、业务类型 |
拆分原则:
- 工具函数和类型一般无依赖或依赖少,可单独成包
- API 包可依赖
@my/utils和@my/types,形成清晰的依赖链
[⬆ 返回目录](#⬆ 返回目录)
五、工具选型:pnpm workspace + 简单脚本
常见方案:
| 方案 | 特点 | 适合 |
|---|---|---|
| pnpm workspace | 依赖提升、磁盘省空间、配置简单 | 推荐首选 |
| Yarn workspace | 成熟,但 pnpm 更省空间、更快 | 已有 yarn 的项目 |
| Lerna | 老牌,发版能力强 | 需要复杂发版流程时 |
| Turborepo | 增量构建、缓存、并行任务 | 大型 Monorepo |
对中后台场景,pnpm workspace 通常足够:安装快、依赖清晰、配置少。下面示例都用 pnpm。
[⬆ 返回目录](#⬆ 返回目录)
六、从零搭建:完整目录与配置
6.1 目录结构
Plain
my-monorepo/
├── apps/
│ ├── admin-a/ # 管理后台 A
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ └── admin-b/ # 管理后台 B
│ ├── src/
│ ├── package.json
│ └── vite.config.ts
├── packages/
│ ├── ui/ # 组件库
│ │ ├── src/
│ │ ├── package.json
│ │ └── vite.config.ts
│ ├── utils/ # 工具函数
│ │ ├── src/
│ │ └── package.json
│ ├── api/ # 接口封装
│ │ ├── src/
│ │ └── package.json
│ └── types/ # 类型定义
│ ├── src/
│ └── package.json
├── pnpm-workspace.yaml
├── package.json
├── .npmrc
├── .eslintrc.cjs
└── .prettierrc
[⬆ 返回目录](#⬆ 返回目录)
6.2 根目录 pnpm-workspace.yaml
YAML
packages:
- 'apps/*'
- 'packages/*'
含义:apps 和 packages 下所有子目录都是 workspace 包。
[⬆ 返回目录](#⬆ 返回目录)
6.3 根目录 package.json
json
{
"name": "my-monorepo",
"private": true,
"scripts": {
"dev:admin-a": "pnpm -C apps/admin-a dev",
"dev:admin-b": "pnpm -C apps/admin-b dev",
"dev": "pnpm -r --parallel run dev",
"build": "pnpm run build:packages && pnpm run build:apps",
"build:packages": "pnpm -r --filter './packages/*' run build",
"build:apps": "pnpm -r --filter './apps/*' run build",
"lint": "eslint . --ext .vue,.ts,.tsx",
"lint:fix": "eslint . --ext .vue,.ts,.tsx --fix",
"format": "prettier --write \"**/*.{vue,ts,tsx,js,json}\""
},
"devDependencies": {
"pnpm": "^8.0.0"
},
"packageManager": "pnpm@8.15.0"
}
说明:
pnpm -C apps/admin-a dev:在apps/admin-a下执行devpnpm -r run build:在所有 workspace 包中执行build--filter './packages/*':只对packages下包执行
[⬆ 返回目录](#⬆ 返回目录)
6.4 根目录 .npmrc(可选但推荐)
Plain
shamefully-hoist=true
strict-peer-dependencies=false
shamefully-hoist:把依赖提升到根 node_modules,避免 Vue 等框架找不到依赖的问题。
[⬆ 返回目录](#⬆ 返回目录)
6.5 包之间的依赖示例
json
// packages/utils/package.json
{
"name": "@my/utils",
"version": "1.0.0",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "vite build"
}
}
json
// packages/api/package.json
{
"name": "@my/api",
"version": "1.0.0",
"dependencies": {
"@my/utils": "workspace:*",
"@my/types": "workspace:*",
"axios": "^1.6.0"
}
}
json
// apps/admin-a/package.json
{
"name": "admin-a",
"private": true,
"dependencies": {
"@my/ui": "workspace:*",
"@my/api": "workspace:*",
"@my/utils": "workspace:*",
"vue": "^3.4.0"
},
"scripts": {
"dev": "vite",
"build": "vite build"
}
}
workspace:* 表示使用当前 workspace 内的最新版本,改完公共包即可在 app 里直接生效。
[⬆ 返回目录](#⬆ 返回目录)
6.6 App 端 Vite 配置(Vue3 + Vite + TS)
在 Monorepo 中,packages 下的内部包可能和 app 共用 Vue、Element Plus 等,需要处理依赖去重和预构建。
ts
// apps/admin-a/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
dedupe: ['vue', 'vue-router', 'pinia', 'element-plus'],
alias: {
'@': resolve(__dirname, 'src'),
},
},
optimizeDeps: {
include: ['@my/ui', '@my/utils', '@my/api', '@my/types'],
},
server: {
fs: {
allow: ['..', '../..'],
},
watch: {
ignored: ['!**/packages/**'],
},
},
})
说明:
resolve.dedupe:保证 Vue、Pinia、Element Plus 只解析一份实例optimizeDeps.include:把内部包加入预构建,避免开发时解析异常server.fs.allow:允许访问 workspace 根目录server.watch.ignored:监听packages变更,保证 HMR 生效
[⬆ 返回目录](#⬆ 返回目录)
6.7 ESLint / Prettier 统一配置
在根目录配置,各子包继承即可。
根目录 .eslintrc.cjs:
js
module.exports = {
root: true,
env: { browser: true, es2021: true, node: true },
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['vue', '@typescript-eslint'],
ignorePatterns: ['dist', 'node_modules', '**/*.min.js'],
rules: { 'vue/multi-word-component-names': 'warn' },
}
根目录 .prettierrc:
json
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100
}
根目录安装:
bash
pnpm add -Dw eslint prettier eslint-config-prettier eslint-plugin-vue @typescript-eslint/eslint-plugin @typescript-eslint/parser vue-eslint-parser
[⬆ 返回目录](#⬆ 返回目录)
6.8 组件库打包完整配置
Vue3 组件库(Vite Library Mode)
Plain
packages/ui/
├── src/
│ ├── Button/Button.vue
│ ├── Table/Table.vue
│ └── index.ts
├── package.json
└── vite.config.ts
packages/ui/package.json:
json
{
"name": "@my/ui",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"sideEffects": ["**/*.css"],
"scripts": { "build": "vite build" },
"peerDependencies": { "vue": "^3.4.0" },
"devDependencies": {
"vite": "^5.0.0",
"vite-plugin-dts": "^3.6.0",
"vue": "^3.4.0",
"@vitejs/plugin-vue": "^5.0.0"
}
}
packages/ui/vite.config.ts:
ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { resolve } from 'path'
export default defineConfig({
plugins: [
vue(),
dts({ insertTypesEntry: true, outDir: 'dist', include: ['src/**/*.ts', 'src/**/*.vue'] }),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyUI',
fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'),
formats: ['es', 'cjs'],
},
rollupOptions: {
external: ['vue'],
output: {
globals: { vue: 'Vue' },
assetFileNames: (info) => (info.name === 'style.css' ? 'style.css' : 'assets/[name][extname]'),
},
},
cssCodeSplit: true,
sourcemap: true,
},
})
Vue2 组件库(Webpack)
Plain
packages/ui-v2/
├── src/
│ ├── Button/Button.vue
│ └── index.ts
├── package.json
└── webpack.config.js
packages/ui-v2/package.json:
json
{
"name": "@my/ui-v2",
"version": "1.0.0",
"main": "./dist/index.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": { "build": "webpack --config webpack.config.js" },
"peerDependencies": { "vue": "^2.6.0" },
"devDependencies": {
"vue": "^2.7.0",
"webpack": "^5.0.0",
"vue-loader": "^15.10.0",
"vue-template-compiler": "^2.7.0",
"ts-loader": "^9.0.0",
"css-loader": "^6.0.0",
"vue-style-loader": "^4.0.0"
}
}
packages/ui-v2/webpack.config.js:
js
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
module.exports = {
mode: 'production',
entry: './src/index.ts',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.js',
library: { name: 'MyUIV2', type: 'umd', umdNamedDefine: true },
globalObject: "typeof self !== 'undefined' ? self : this",
},
resolve: { extensions: ['.ts', '.vue', '.js'] },
externals: { vue: { root: 'Vue', commonjs: 'vue', commonjs2: 'vue', amd: 'vue' } },
module: {
rules: [
{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ },
{ test: /\.vue$/, use: 'vue-loader' },
{ test: /\.css$/, use: ['vue-style-loader', 'css-loader'] },
],
},
plugins: [new VueLoaderPlugin()],
}
[⬆ 返回目录](#⬆ 返回目录)
七、依赖管理:内部包怎么互相引用?
规则:
- 内部包用
workspace:*,不要用^1.0.0等具体版本 - 明确依赖链:
api依赖utils和types,ui可依赖utils,apps按需依赖 - 安装依赖:根目录用
pnpm add xxx -w,或pnpm add xxx --filter admin-a给指定包安装
常见误区 :在 packages/utils 里 pnpm add lodash,只装到 utils。多个包都要用的话,要么各自声明,要么在根用 pnpm add lodash -w,视是否要统一版本而定。
[⬆ 返回目录](#⬆ 返回目录)
八、构建与发布:怎么打包、怎么发版?
开发阶段:一般不需要单独构建公共包,Vite 会通过 resolve 直接读源码。
生产构建 :先构建 packages,再构建 apps。
json
{
"scripts": {
"build": "pnpm run build:packages && pnpm run build:apps",
"build:packages": "pnpm -r --filter './packages/*' run build",
"build:apps": "pnpm -r --filter './apps/*' run build"
}
}
发布到私有 npm:
bash
pnpm publish -r --filter "@my/ui"
8.1 CI/CD:只构建变更的包
使用 Turborepo 做增量构建和缓存:
根目录 turbo.json:
json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"cache": true
}
}
}
根目录添加 turbo 和脚本:
json
{
"devDependencies": { "turbo": "^1.10.0" },
"scripts": {
"build": "turbo run build",
"build:filter": "turbo run build --filter='...[origin/main]'"
}
}
仅用 pnpm 时:
bash
pnpm -r --filter './packages/*' run build
pnpm -r --filter '@my/ui...' run build
[⬆ 返回目录](#⬆ 返回目录)
九、常见踩坑与解决方案
9.1 Vue 找不到依赖(如找不到 vue)
原因:pnpm 默认非扁平化,子包有时找不到根 node_modules 里的 vue。
解决:根 .npmrc 加 shamefully-hoist=true,或各 app 显式声明 vue。
[⬆ 返回目录](#⬆ 返回目录)
9.2 改 packages 代码,app 不热更新
可能原因:
- 公共包未在
package.json中正确声明依赖 - Vite 的
optimizeDeps.exclude排除了内部包,需要检查 - 开发时指向构建产物而非源码,检查
main/module/exports是否指向src
[⬆ 返回目录](#⬆ 返回目录)
9.3 循环依赖
例如:api 依赖 utils,utils 又依赖 api。
解决:把公共逻辑放到更底层的包(如 utils),api 只做接口封装。
[⬆ 返回目录](#⬆ 返回目录)
9.4 TypeScript 路径与类型
公共包需要:
- 正确配置
types或exports.types - 构建时生成
.d.ts(Vite 可用vite-plugin-dts)
否则 app 引用时会报类型错误或缺失。
[⬆ 返回目录](#⬆ 返回目录)
9.5 不同 app 的 Vue / Element 版本不一致
建议:在根 package.json 用 pnpm overrides 或 resolutions 统一版本,避免多份 Vue/UI 库被打进不同包。
[⬆ 返回目录](#⬆ 返回目录)
十、小结:何时用 Monorepo,何时不用
适合用 Monorepo:
- 多个中后台 + 共享组件 / 工具
- 内部组件库需要和业务项目紧密联调
- 团队在同一套技术栈下协作
可以不急着用:
- 只有一两个后台,公共代码不多
- 团队很小,发版联调成本不高
- 项目之间技术栈差异大、关联弱
[⬆ 返回目录](#⬆ 返回目录)
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战的方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~