Vue 基础体系 · 第 58/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue Monorepo:Workspace、共享包、构建图、版本和边界
在单个 Vue 应用中,源码、构建配置、依赖和发布流程通常属于同一个项目。随着系统拆分为多个应用、组件库、设计令牌包、API 客户端和工具包,问题会变成:
- 多个项目如何放在同一个仓库中并安装依赖?
- 一个应用如何可靠地使用另一个本地包?
- 修改底层共享包时,哪些包需要重新构建?
- 包之间的版本如何声明和发布?
- “放在同一个仓库里”是否意味着它们可以随意互相导入?
这些问题分别对应 Workspace、共享包、构建图、版本和边界。它们不是同一个概念:
- Workspace 解决仓库中的包如何被识别、安装和链接。
- 共享包 是可被多个消费者依赖的独立发布单元。
- 构建图 描述包之间的依赖顺序和任务传播关系。
- 版本 描述包接口变化对消费者的兼容性。
- 边界 限制哪些代码可以被哪些包使用,防止仓库逐渐退化成“共享文件夹”。
下面的示例基于 Vue 3、Composition API、TypeScript、Vite 和 pnpm。Workspace 规范由具体包管理器实现,因此命令和细节会随 pnpm、npm 或 Yarn 版本变化;示例中的 workspace: 协议属于常见的 pnpm/Yarn 工作区能力,不是 npm 包规范本身。
一、先区分仓库、应用和包
1. 仓库不等于包
一个 Git 仓库可以包含多个独立的 npm package:
vue-monorepo/
├── apps/
│ ├── admin/
│ └── storefront/
├── packages/
│ ├── ui/
│ ├── tokens/
│ ├── api-client/
│ └── eslint-config/
├── package.json
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
这里至少有两种不同角色:
apps/admin和apps/storefront是可运行的应用,通常有页面入口、HTML、部署配置。packages/ui、packages/tokens和packages/api-client是可复用的包,通常有明确的导出入口和构建产物。
Workspace 识别的是目录中的 package,而不是识别“应用”或“共享代码”这两个概念。
只要目录中存在一个 package.json,并且被 Workspace glob 匹配,它就可能成为工作区成员:
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
根目录的 package.json 通常不是业务包,而是仓库级工具包:
{
"name": "vue-monorepo",
"private": true,
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev:admin": "pnpm --filter @acme/admin dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck"
},
"devDependencies": {
"typescript": "^5.6.0"
}
}
private: true 的作用是阻止根目录包被误发布到 npm。它不会自动阻止子包发布,也不会自动建立架构边界。
2. package name 才是依赖关系的身份
apps/admin/package.json:
{
"name": "@acme/admin",
"private": true,
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@acme/ui": "workspace:*",
"vue": "^3.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0",
"vue-tsc": "^2.2.0",
"typescript": "^5.6.0"
}
}
packages/ui/package.json:
{
"name": "@acme/ui",
"version": "1.0.0",
"private": false,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
},
"./style.css": "./dist/style.css"
},
"files": [
"dist"
],
"scripts": {
"build": "vue-tsc --declaration --emitDeclarationOnly && vite build",
"typecheck": "vue-tsc --noEmit"
},
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.0",
"vite": "^6.0.0",
"typescript": "^5.6.0",
"vue": "^3.5.0",
"vue-tsc": "^2.2.0"
}
}
这里的 @acme/ui 才是应用依赖的身份。应用不应该通过相对路径导入:
// 不推荐:跨越包的源代码边界
import AcmeButton from '../../../packages/ui/src/components/AcmeButton.vue'
而应该使用包的公共入口:
import { AcmeButton } from '@acme/ui'
import '@acme/ui/style.css'
相对路径导入会绕过 package.json 的 exports、版本声明和构建产物,使包边界形同虚设。
二、Workspace 到底做了什么
1. Workspace 的核心是“识别 + 解析 + 链接”
当应用声明:
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
包管理器通常会执行以下过程:
- 扫描
pnpm-workspace.yaml指定的目录。 - 找到名称为
@acme/ui的工作区包。 - 将应用对
@acme/ui的依赖解析到本地工作区包,而不是从远程 registry 下载。 - 在
node_modules中建立包管理器管理的链接或虚拟存储映射。 - 根据
pnpm-lock.yaml固化解析结果。
因此,Workspace 主要解决的是依赖安装和本地包发现,不是自动解决:
- TypeScript 类型声明;
- Vite 构建顺序;
- 包的版本发布;
- 循环依赖;
- Vue 重复实例;
- 包之间的架构边界。
这些问题需要其他机制配合。
2. 一个可运行的最小目录
packages/ui/
├── src/
│ ├── components/
│ │ └── AcmeButton.vue
│ └── index.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts
apps/admin/
├── src/
│ ├── App.vue
│ └── main.ts
├── package.json
├── tsconfig.json
└── vite.config.ts
共享组件:
<!-- packages/ui/src/components/AcmeButton.vue -->
<script setup lang="ts">
defineProps<{
label: string
disabled?: boolean
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
type="button"
:disabled="disabled"
class="acme-button"
@click="emit('click', $event)"
>
{{ label }}
</button>
</template>
<style>
.acme-button {
border: 0;
border-radius: 4px;
padding: 8px 12px;
background: #42b883;
color: white;
cursor: pointer;
}
.acme-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
</style>
包入口:
// packages/ui/src/index.ts
import AcmeButton from './components/AcmeButton.vue'
export { AcmeButton }
应用使用:
<!-- apps/admin/src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { AcmeButton } from '@acme/ui'
const count = ref(0)
</script>
<template>
<main>
<h1>Admin</h1>
<AcmeButton
label="增加"
@click="count++"
/>
<p>count: {{ count }}</p>
</main>
</template>
安装并构建:
pnpm install
pnpm --filter @acme/ui build
pnpm --filter @acme/admin dev
预期结果是:
pnpm install将@acme/ui解析为工作区中的本地包;pnpm --filter @acme/ui build生成packages/ui/dist;- Vite 启动
apps/admin,应用从@acme/ui的exports读取dist/index.js; - 浏览器中显示按钮,点击后计数增加。
如果只执行 pnpm --filter @acme/admin dev 而没有先构建 ui,可能出现:
Failed to resolve entry for package "@acme/ui"
这是因为 Workspace 找到了包,但 package.json 的入口指向 dist/index.js,而 dist 尚未生成。“包已链接”不等于“包已构建”。
3. workspace:* 的含义和限制
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
在本地开发时,它要求依赖来自当前 Workspace。发布时,包管理器通常会将 workspace:* 转换为实际版本范围。例如被发布包当前版本为 1.2.3,依赖可能被重写为类似:
{
"dependencies": {
"@acme/ui": "1.2.3"
}
}
具体重写格式取决于包管理器和发布命令。需要注意两点:
workspace:*是开发仓库中的解析约束,不是远程消费者能够理解的运行时协议。- 如果某个包需要独立发布,应在发布前检查生成的 tarball,而不能只检查仓库里的
package.json。
可以使用:
pnpm --filter @acme/ui pack
tar -tf packages/ui/acme-ui-*.tgz
检查最终包中是否包含:
dist/index.js;dist/index.d.ts;package.json;- 被
exports声明的资源; - 必要的 CSS 或静态文件。
三、Vite、源码包和构建产物
Vue 官方工具链通常使用 Vite 处理开发服务器、模块转换和生产构建;Vite 的应用配置可参考其官方 Guide,Vue 工具选择可参考 Vue Tooling Guide。
在 Monorepo 中,一个共享包有两种常见消费方式。
1. 消费已构建的 dist
包入口指向构建产物:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
优点是接近真实发布环境:
- 应用消费的是发布时会提供的文件;
- 构建边界清楚;
- 包可以单独发布;
- CI 可以验证包是否完整。
代价是开发时需要先构建包,或者同时运行包的 watch 构建:
{
"scripts": {
"build": "vue-tsc --declaration --emitDeclarationOnly && vite build",
"build:watch": "vite build --watch"
}
}
然后在两个终端运行:
pnpm --filter @acme/ui build:watch
pnpm --filter @acme/admin dev
当 packages/ui/src 变化时,Vite library mode 重新生成 dist,应用开发服务器再感知到入口文件变化。
2. 让 Vite 直接处理工作区源码
也可以让应用在开发环境中直接读取共享包源码。例如某些包会通过条件导出或开发专用配置指向 src。这种方式启动更快,但会引入额外条件:
- 应用的 Vite 必须能处理包中的 TypeScript 和 Vue SFC;
- 包源码必须满足 Vite 的 ESM 处理方式;
- 生产构建和发布包可能走不同入口;
- Node、测试工具、Storybook 等其他消费者未必能处理同一份源码。
Vite 对工作区外的链接依赖有专门的依赖扫描和预构建行为。被链接的包通常会按源码依赖处理,但如果包格式、入口或模块系统不符合预期,就可能出现开发环境能运行、生产构建失败的差异。遇到这类问题,应优先检查:
{
"type": "module"
}
以及 exports 指向的真实文件,而不是先随意增加别名。
3. Vite library mode 不等于完整 npm 包
共享包的 Vite 配置可以是:
// packages/ui/vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'node:path'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: 'index'
},
rollupOptions: {
external: ['vue']
}
}
})
这里有一个关键因果关系:
external: ['vue']告诉 Rollup 不要把 Vue 打进@acme/ui;peerDependencies.vue告诉消费者:运行这个包需要由上层提供 Vue;- 应用的
dependencies.vue提供实际运行时; - 因此整个应用应尽量使用同一份 Vue 实例。
Vite 的 library build 主要生成 JavaScript 和资源文件。它不会因为使用 TypeScript 就自动生成完整的 .d.ts,所以示例中的:
vue-tsc --declaration --emitDeclarationOnly
负责生成声明文件,vite build 负责生成 JavaScript。两者缺一时,发布包可能分别出现“运行入口缺失”或“类型入口缺失”。
四、共享包不是“把文件放到 packages 目录”
共享包必须具有稳定的公共 API。公共 API 至少包括:
- 包名;
- 入口文件;
- 导出的运行时值;
- 导出的类型;
- 资源入口;
- 对外声明的依赖和 peer dependency;
- 可兼容的版本范围。
例如:
// packages/ui/src/index.ts
export { default as AcmeButton } from './components/AcmeButton.vue'
export type { ButtonSize } from './types'
如果直接导出内部文件:
import { AcmeButton } from '@acme/ui/src/components/AcmeButton.vue'
消费者实际上依赖了内部目录结构。之后即使组件行为不变,只要把文件移动到 src/button/AcmeButton.vue,所有消费者都可能失败。这种依赖没有经过版本 API 的控制。
1. 使用 exports 限制入口
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./button": {
"types": "./dist/button.d.ts",
"import": "./dist/button.js"
},
"./style.css": "./dist/style.css"
}
}
当消费者尝试:
import InternalThing from '@acme/ui/dist/internal/thing.js'
Node 和支持 exports 的工具链可以拒绝这个未声明路径。这样,exports 将“哪些路径是公开的”从约定变成了机器可检查的接口。
但 exports 不能阻止所有越界行为:
- 消费者仍可能通过相对路径访问仓库源码;
- 某些旧工具链对
exports支持不完整; - TypeScript 的
paths可以把任意源目录映射成别名; - 测试工具可能使用特殊 resolver 绕过包入口。
因此真正的边界需要包入口、代码审查和依赖规则共同实现。
2. 类型导出也属于公共 API
// packages/tokens/src/index.ts
export const colors = {
primary: '#42b883',
danger: '#e34c26'
} as const
export type ColorName = keyof typeof colors
消费者:
import { colors, type ColorName } from '@acme/tokens'
如果只导出运行时值而没有声明类型,TypeScript 消费者会得到不完整的类型体验。反过来,如果导出的类型依赖包内部未发布的路径,也会导致:
Cannot find module '@acme/tokens/dist/internal/types'
所以声明文件必须使用同样公开的包入口和依赖关系。
五、Vue 共享包中的依赖分类
一个 Vue 组件包通常不应把 Vue 作为普通生产依赖打包进去。
1. dependencies
当包运行时必须使用某个库,并且这个库可以由包自行携带时,使用:
{
"dependencies": {
"date-fns": "^4.0.0"
}
}
包管理器会为它安装运行时依赖。
2. peerDependencies
当包必须和消费者共享某个运行时,或者必须由消费者决定版本时,使用:
{
"peerDependencies": {
"vue": "^3.4.0"
}
}
Vue 组件库通常属于这种情况。@acme/ui 不应自己创建或携带一个与应用不同的 Vue 运行时。
为了让组件包在本地构建和类型检查时也能解析 Vue,通常同时声明:
{
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"vue": "^3.5.0"
}
}
这两个声明用途不同:
peerDependencies是对消费者的运行时兼容要求;devDependencies是包自身开发、构建和类型检查时的工具依赖。
3. 重复 Vue 的失败表现
如果最终应用中存在两份不兼容或重复的 Vue,常见问题包括:
- 组件运行时警告;
provide/inject无法按预期共享;- 响应式对象跨实例边界表现异常;
- 插件注册在一份 Vue 上,但组件使用另一份 Vue;
- 构建产物体积增加。
根因通常不是“Monorepo 不能使用 Vue”,而是:
- 组件包把 Vue 放进了
dependencies; - 构建没有将 Vue external 化;
- 应用和组件包解析出了不同版本;
- 使用了本地链接、别名或嵌套依赖,改变了模块解析路径。
诊断时可以检查:
pnpm why vue
pnpm list vue -r
并检查构建产物:
grep -R "from ['\"]vue['\"]" packages/ui/dist
构建后的组件代码应保留对外部 Vue 的导入,或者由构建格式以 external 方式处理,而不是包含完整 Vue runtime。
六、构建图:从包依赖到任务顺序
1. 包依赖图的形式化定义
设工作区中的包集合为:
如果包 的 package.json 中声明了对 的依赖,则建立一条有向边:
这里的箭头表示“p_a 依赖 p_b”,也就是构建 p_a 前通常需要准备 p_b。
例如:
@acme/admin → @acme/ui → @acme/tokens
含义是:
admin使用ui;ui使用tokens;tokens不依赖前两个包。
若每个包的构建都需要依赖包的产物,则合法构建顺序必须满足:
因此:
tokens → ui → admin
2. 为什么构建图通常要求无环
假设出现:
ui → tokens → ui
要构建 ui,先要构建 tokens;要构建 tokens,又要先构建 ui。于是得到:
同时:
两者无法同时成立。因此,依赖图中的环会使基于拓扑排序的独立构建顺序不存在。
包管理器可能允许安装这种循环依赖,某些开发服务器也可能暂时通过源码加载运行,但这不代表它适合生产构建。循环还会造成:
- 初始化顺序不稳定;
- 增量构建无法准确判断起点;
- 任务缓存失效;
- 包发布顺序无法确定;
- 类型声明互相引用。
3. 包图和模块图不是一回事
Monorepo 中至少存在三种图:
包依赖图
由 package.json 的依赖字段表达:
admin → ui → tokens
它回答:
哪个 package 依赖哪个 package?
模块图
由 import 和 export 形成:
App.vue → @acme/ui/index.ts → AcmeButton.vue
它回答:
运行时或构建时,哪个模块引用哪个模块?
任务图
由脚本和任务依赖形成:
build:tokens → build:ui → build:admin
它回答:
哪些任务必须先完成,哪些任务可以并行?
三个图可能不完全相同。例如 admin 在运行时依赖 ui,但如果 ui 的开发入口直接指向源码,开发时可能不需要先执行 build:ui;生产构建则可能必须依赖 build:ui 的 dist。
4. pnpm -r 不等于任意情况下的正确构建图
pnpm -r build
表示对递归发现的工作区包执行 build。它能根据工作区依赖关系安排部分顺序,但具体行为受 pnpm 版本、过滤参数、脚本存在情况和包管理器配置影响。
更明确的命令是:
pnpm --filter @acme/admin... build
这里的 ... 表示选择 @acme/admin 及其工作区依赖,具体过滤语法属于 pnpm 能力,使用前应确认当前 pnpm 版本的 filter 规则。
如果项目使用 Nx、Turborepo 或 Lage 等任务编排工具,任务图通常会额外考虑:
build依赖^build;- 源文件变化影响哪些任务;
- 任务输出目录;
- 缓存键;
- 并行度;
- CI 中的分片执行。
例如抽象地表示:
build(admin)
depends on build(ui)
depends on build(tokens)
但任务编排器并不会凭空知道 ui 的输出在哪里。必须声明输出,例如:
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
具体配置格式因工具而异,但原理相同:依赖决定先后,输出决定缓存和失效范围。
七、完整构建算例:修改一个设计令牌会发生什么
假设仓库有以下包:
@acme/tokens
@acme/ui
@acme/api-client
@acme/admin
@acme/storefront
依赖关系:
@acme/ui → @acme/tokens
@acme/admin → @acme/ui
@acme/storefront → @acme/ui
@acme/api-client → 无
可以写成:
flowchart LR
tokens["@acme/tokens"]
ui["@acme/ui"]
api["@acme/api-client"]
admin["@acme/admin"]
storefront["@acme/storefront"]
ui --> tokens
admin --> ui
storefront --> ui
当 @acme/tokens 的颜色文件发生变化时:
tokens的源文件变化;tokens的构建任务失效;ui使用了tokens的新产物,因此ui的构建任务失效;admin和storefront使用了ui,两者的构建任务也失效;api-client没有依赖tokens,不需要重新构建。
结果传播集合为:
而不是整个仓库的所有包。
如果反过来只修改 @acme/api-client:
前提是没有应用同时通过相对路径或隐式别名依赖它的内部文件。越界导入会让声明的包图与真实模块图不一致,增量构建便可能漏掉受影响的任务。
一个常见错误
// apps/admin/src/api.ts
import { createRequest } from '../../../packages/api-client/src/request'
admin/package.json 没有声明 @acme/api-client,所以包依赖图看不到这条关系;但模块图中实际存在依赖。这会导致:
pnpm --filter @acme/admin... build不包含api-client;- CI 在只安装生产依赖时可能找不到隐式依赖;
- 任务缓存错误地复用旧结果;
api-client发布后,应用仍然读取仓库源码。
正确方式是:
{
"dependencies": {
"@acme/api-client": "workspace:*"
}
}
import { createRequest } from '@acme/api-client'
八、版本:Workspace 版本和发布版本不是一回事
1. 依赖范围描述兼容性
假设 @acme/ui 当前版本为 1.4.0:
{
"dependencies": {
"@acme/ui": "^1.4.0"
}
}
在常见 SemVer 语义下,^1.4.0 允许:
>=1.4.0 <2.0.0
因此:
- 修复 bug,通常增加 patch:
1.4.1; - 增加向后兼容功能,通常增加 minor:
1.5.0; - 删除导出、修改必填属性或改变行为契约,通常增加 major:
2.0.0。
但这是版本协议的约定,不是 TypeScript 或 Vue 自动证明的事实。下面这种变化可能编译通过,却仍是破坏性变化:
// 旧版本
defineProps<{
size?: 'small' | 'large'
}>()
// 新版本
defineProps<{
size?: 'small' | 'medium' | 'large'
}>()
新增联合成员通常是运行时兼容的,但对某些消费者的穷尽判断可能造成类型检查变化:
switch (props.size) {
case 'small':
break
case 'large':
break
default:
// 旧代码可能认为这里不可能到达
}
所以版本判断必须同时考虑:
- 运行时行为;
- JavaScript 导出;
- TypeScript 类型;
- CSS 类名和 DOM 结构;
- peer dependency 范围;
- 构建产物格式。
2. 固定版本和独立版本
Monorepo 常见两种版本策略。
固定版本(fixed version)
所有发布包共用一个版本:
@acme/ui 3.2.0
@acme/tokens 3.2.0
@acme/api-client 3.2.0
优点是发布和回滚简单,消费者容易理解。缺点是一个小包的改动可能导致所有包都增加版本,即使它们没有实际变化。
独立版本(independent version)
每个包单独版本:
@acme/ui 2.1.0
@acme/tokens 1.8.3
@acme/api-client 4.0.0
优点是版本语义更精确,缺点是跨包变更需要正确更新依赖范围和发布顺序。
Workspace 只负责本地包解析,不决定采用哪一种版本策略。pnpm、Changesets、Nx Release、Lerna 等工具可以帮助执行版本计算或发布流程,但工具不会替开发者判断某个 API 变化是否破坏兼容性。
3. peerDependencies 的版本约束
组件包:
{
"peerDependencies": {
"vue": "^3.4.0"
}
}
表示消费者需要提供满足该范围的 Vue。若应用使用:
{
"dependencies": {
"vue": "^3.2.0"
}
}
就可能出现 peer warning,甚至在严格 CI 中安装失败。
扩展 peer 范围时也要考虑真实测试范围:
{
"peerDependencies": {
"vue": ">=3.4.0 <4"
}
}
这不是“写得越宽越好”。如果包实际上依赖 Vue 3.5 才提供的能力,却声明支持 >=3.4.0,消费者会在安装阶段通过、运行阶段失败。
4. Lockfile 的作用
pnpm-lock.yaml 固化的是一次安装得到的依赖解析结果,包括:
- registry 包的具体版本;
- 完整依赖树;
- peer 组合;
- 工作区包之间的解析关系。
因此生产 CI 应使用:
pnpm install --frozen-lockfile
如果 package.json 与 lockfile 不一致,命令应失败,而不是静默修改锁文件。这样可以避免开发者本地安装成功、CI 使用另一套依赖树。
九、架构边界:依赖方向必须有规则
Workspace 让包可以互相引用,但没有规定“谁可以引用谁”。边界必须由架构定义。
一种常见分层是:
apps
↓
features
↓
ui
↓
tokens
例如:
tokens:颜色、间距、字体等稳定设计令牌;ui:通用视觉组件;features:业务功能组件;apps:路由、页面、权限和部署入口。
允许方向:
admin → features → ui → tokens
不允许方向:
tokens → ui
ui → admin
ui → 某个具体业务 feature
如果 tokens 反过来依赖 ui,低层包就依赖了高层包,依赖图会变得难以复用;如果通用 ui 依赖 admin,组件库就无法脱离某个具体应用发布。
1. 通过 API 传递业务状态,而不是反向依赖应用
不好的设计:
// packages/ui/src/components/AcmeButton.vue
import { useAdminPermission } from '@acme/admin'
这样 ui 依赖了应用权限实现。
更合理的设计是让应用传入状态:
<AcmeButton
label="删除"
:disabled="!canDelete"
@click="deleteItem"
/>
组件只关心 disabled 和 click 这类公共契约。数据流变为:
应用状态 → props → UI 渲染
用户操作 → emits → 应用处理
这保持了 ui 的通用性,也避免了构建图中的反向边。
2. 用 exports 保护包内边界
组件包内部可以有很多实现文件:
packages/ui/src/
├── components/
├── composables/
├── internal/
└── index.ts
只在入口导出稳定 API:
// src/index.ts
export { default as AcmeButton } from './components/AcmeButton.vue'
export { useDialog } from './composables/useDialog'
不要导出:
export * from './internal/normalizeProps'
除非它确实是公共 API。一次导出就可能使内部函数成为消费者依赖的接口,之后删除或重命名会产生版本责任。
3. 路径别名不能代替包边界
根目录 tsconfig.json 可能配置:
{
"compilerOptions": {
"paths": {
"@acme/ui/*": ["packages/ui/src/*"]
}
}
}
这只告诉 TypeScript 如何解析类型路径,不会自动改变 Vite、Node、Vitest 或发布包中的运行时解析。如果没有同步配置其他工具,可能出现:
- IDE 能跳转,Vite 运行失败;
- TypeScript 检查通过,Node 执行失败;
- 测试读取源码,生产读取
dist; - 应用绕过
@acme/ui的exports。
如果目标是包间依赖,应优先使用真实 package name 和 Workspace 依赖。路径别名适合包内部源码组织,不适合替代包管理器依赖。
十、开发、构建和发布的三条路径
1. 开发路径
sequenceDiagram
participant Dev as 开发者
participant PM as pnpm Workspace
participant Vite as 应用 Vite
participant UI as @acme/ui
participant Browser as 浏览器
Dev->>PM: pnpm install
PM->>UI: 建立本地包解析
Dev->>Vite: pnpm --filter @acme/admin dev
Vite->>UI: 解析 package entry 或工作区源码
Vite->>Browser: 转换模块并提供 HMR
Browser->>Vite: 请求 App.vue 和依赖模块
开发时修改 .vue 文件,Vite 通常只重新转换受影响的模块,并通过 HMR 更新浏览器。这个模块级图与包级构建图不同,因此开发服务器可以在不完整构建所有包的情况下运行。
2. 生产构建路径
如果入口都指向 dist:
pnpm --filter @acme/tokens build
↓
pnpm --filter @acme/ui build
↓
pnpm --filter @acme/admin build
每一步必须满足前一步的输出存在且有效。完整 CI 可以分开执行:
pnpm install --frozen-lockfile
pnpm --filter @acme/tokens... typecheck
pnpm --filter @acme/tokens... build
pnpm --filter @acme/admin build
也可以使用任务编排工具一次性根据图调度。关键不是命令形式,而是任务必须表达真实依赖。
3. 发布路径
发布前至少验证:
pnpm --filter @acme/ui typecheck
pnpm --filter @acme/ui build
pnpm --filter @acme/ui pack
然后检查压缩包:
tar -tf packages/ui/acme-ui-*.tgz
如果 tarball 中没有 dist/index.js,消费者会在安装后失败;如果没有 dist/index.d.ts,TypeScript 会失去类型入口;如果 exports 中声明了 ./style.css 但文件未进入 tarball,应用导入 CSS 时会失败。
十一、常见失败表现和诊断路径
1. Cannot find module '@acme/ui'
按以下顺序检查:
pnpm list @acme/ui -r
pnpm why @acme/ui
cat apps/admin/package.json
cat packages/ui/package.json
重点确认:
@acme/ui是否被pnpm-workspace.yaml匹配;- 包名是否完全一致;
- 应用是否声明了
workspace:*; exports、main或module是否指向正确文件;dist是否已经生成;- 是否锁文件与 package manifest 不一致。
不要先在应用中添加一个指向 packages/ui/src 的路径别名。那可能只是掩盖包入口或构建顺序错误。
2. 开发正常,生产构建失败
常见原因是开发和生产消费了不同文件:
开发:@acme/ui → packages/ui/src
生产:@acme/ui → packages/ui/dist
生产失败时检查:
pnpm --filter @acme/ui build
find packages/ui/dist -maxdepth 2 -type f
如果 dist 中缺少入口、声明文件或 CSS,修复包构建配置,而不是修改应用 import 路径。
3. 类型检查失败但浏览器能运行
浏览器只需要 JavaScript,TypeScript 还需要声明文件。典型原因:
types指向不存在的文件;vue-tsc没有执行;.vue文件的声明生成配置不正确;exports.types与types指向不同位置;- 声明文件引用了未发布的内部路径。
可以直接检查:
cat packages/ui/package.json
test -f packages/ui/dist/index.d.ts
pnpm --filter @acme/ui typecheck
4. No matching version found for ...@workspace:*
这通常表示当前命令运行在不支持该协议的发布或安装环境中,或者包已经脱离 Workspace 被单独处理。
修复思路是:
- 在仓库内使用支持 Workspace 的包管理器执行安装;
- 发布前让包管理器将
workspace:重写为实际版本; - 检查最终 tarball 中的
package.json; - 不要把未处理的
workspace:*直接交给普通远程消费者。
5. 循环依赖
可以先查看依赖关系:
pnpm list --depth Infinity -r
再搜索跨包 import:
rg "from ['\"]@acme/" apps packages
若发现:
ui → feature-a → ui
不要简单地把两个包合并,先判断循环中的依赖是否只是一个小型类型或工具。常见拆法是提取低层契约包:
feature-a → contracts ← ui
其中 contracts 只包含类型、事件名或无业务实现的数据结构。
十二、哪些内容应该放进共享包
共享包适合承载具有稳定复用价值的内容:
适合放入 @acme/tokens
export const spacing = {
sm: '4px',
md: '8px',
lg: '16px'
} as const
以及对应的 CSS variables、主题名称和颜色契约。
适合放入 @acme/ui
- 通用按钮、输入框、弹窗;
- 与具体业务无关的表单交互;
- 明确的 props、emits 和 slots;
- 可测试的展示逻辑。
适合放入 @acme/api-client
- HTTP 请求封装;
- DTO 类型;
- API 错误解析;
- 与具体页面无关的请求函数。
例如错误应该保留可判断的信息:
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string
) {
super(message)
this.name = 'ApiError'
}
}
应用可以根据状态处理认证失败:
try {
await getCurrentUser()
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
router.push('/login')
} else {
throw error
}
}
不适合放入通用包的是:
- 某个应用的路由实例;
- 某个应用的 Pinia store;
- 某个业务页面的权限规则;
- 只被一个应用使用且变化频繁的实现;
- 通过隐式全局变量才能工作的代码。
共享的标准不是“未来可能复用”,而是已经存在稳定契约,且复用收益大于包管理、构建和版本维护成本。
十三、Monorepo 的真实边界
1. Workspace 边界不是运行时隔离
同一个仓库中的包仍可能:
- 共享根目录配置;
- 共享 lockfile;
- 使用相同 Node 和 pnpm 版本;
- 通过本地源码互相引用;
- 在 CI 中一起发布。
如果需要权限隔离、独立审计、独立部署权限或不同合规周期,Monorepo 不能单独提供这些隔离能力。它主要是源码和依赖协作模型,而不是安全边界。
2. 共享包会增加发布责任
一个组件从应用内部代码变成共享包后,以下内容都可能成为契约:
- 组件名;
- props 名称和类型;
- emits 事件;
- slot 名称;
- CSS class;
- DOM 结构;
- CSS variables;
- peer dependency 范围;
- ESM/CJS 支持范围。
因此抽包不是单纯移动目录。移动后必须建立入口、构建、类型、版本和变更验证。
3. 包数量不是架构质量指标
包拆得过细会造成:
- 构建图节点过多;
- 版本发布频繁;
- 类型声明链复杂;
- 本地开发需要同时运行多个 watch;
- 调试需要跨越更多入口。
包拆得过粗又会造成:
- 依赖方向模糊;
- 任意模块可以被导入;
- 一个小改动触发大范围构建;
- 发布版本无法表达真实变化。
合理的拆分粒度应由稳定边界决定:哪些代码有独立消费者、独立版本责任和独立构建入口,就更适合成为包。
十四、建立一套可验证的最小规则
一个 Vue Monorepo 至少应能验证以下条件:
Workspace 条件
pnpm install --frozen-lockfile
pnpm list -r --depth -1
确认所有预期包都被识别,且依赖来自工作区或锁定的 registry 版本。
包入口条件
pnpm --filter @acme/ui build
pnpm --filter @acme/ui pack
确认 main、module、types 和 exports 指向的文件都存在,并且文件进入 tarball。
构建图条件
pnpm --filter @acme/admin... build
确认应用所需的工作区依赖被纳入构建范围,底层包先于上层包完成。
Vue 单实例条件
pnpm why vue
pnpm list vue -r
确认组件包将 Vue 声明为 peer dependency,并在构建中 external 化 Vue。
边界条件
rg "packages/.*/src" apps packages
rg "from ['\"]@acme/.*/src" apps packages
确认消费者没有通过相对路径或 /src 深层路径绕过公共入口。
这些命令不是完整架构检查,但它们分别对应 Workspace、构建、运行时、发布和边界的关键失败面。
十五、最终模型
可以用下面的关系理解 Vue Monorepo:
Workspace
负责:发现包、安装依赖、建立本地链接
共享包
负责:提供稳定的运行时 API、类型 API 和资源入口
构建图
负责:表达依赖顺序、影响范围、并行与缓存
版本
负责:描述公共契约的兼容性变化
边界
负责:限制依赖方向,防止消费者依赖内部实现
它们之间的因果关系是:
package.json声明包身份和依赖;- Workspace 根据这些声明解析本地包;
- 包依赖形成包图;
- 包图和任务配置形成构建图;
- 构建图决定哪些包先构建、哪些任务可以复用;
exports和公共入口定义可消费边界;- 公共 API 的变化决定版本变化;
- peer dependency 和 external 配置决定 Vue 等共享运行时是否保持一致。
如果只配置 Workspace,得到的是“多个目录可以互相安装”;如果再建立公共入口,才得到共享包;如果补全构建图,才得到可预测的交付流程;如果正确管理版本和边界,Monorepo 才不会随着规模增长退化成一个互相穿透的源码目录。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue PWA:Service Worker、缓存更新、离线、安装和回退
- 下一篇:Vue 组件库发布:构建、类型、样式、按需加载和语义化版本
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论