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/adminapps/storefront 是可运行的应用,通常有页面入口、HTML、部署配置。
  • packages/uipackages/tokenspackages/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.jsonexports、版本声明和构建产物,使包边界形同虚设。


二、Workspace 到底做了什么

1. Workspace 的核心是“识别 + 解析 + 链接”

当应用声明:

{
  "dependencies": {
    "@acme/ui": "workspace:*"
  }
}

包管理器通常会执行以下过程:

  1. 扫描 pnpm-workspace.yaml 指定的目录。
  2. 找到名称为 @acme/ui 的工作区包。
  3. 将应用对 @acme/ui 的依赖解析到本地工作区包,而不是从远程 registry 下载。
  4. node_modules 中建立包管理器管理的链接或虚拟存储映射。
  5. 根据 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/uiexports 读取 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"
  }
}

具体重写格式取决于包管理器和发布命令。需要注意两点:

  1. workspace:* 是开发仓库中的解析约束,不是远程消费者能够理解的运行时协议。
  2. 如果某个包需要独立发布,应在发布前检查生成的 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 至少包括:

  1. 包名;
  2. 入口文件;
  3. 导出的运行时值;
  4. 导出的类型;
  5. 资源入口;
  6. 对外声明的依赖和 peer dependency;
  7. 可兼容的版本范围。

例如:

// 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”,而是:

  1. 组件包把 Vue 放进了 dependencies
  2. 构建没有将 Vue external 化;
  3. 应用和组件包解析出了不同版本;
  4. 使用了本地链接、别名或嵌套依赖,改变了模块解析路径。

诊断时可以检查:

pnpm why vue
pnpm list vue -r

并检查构建产物:

grep -R "from ['\"]vue['\"]" packages/ui/dist

构建后的组件代码应保留对外部 Vue 的导入,或者由构建格式以 external 方式处理,而不是包含完整 Vue runtime。


六、构建图:从包依赖到任务顺序

1. 包依赖图的形式化定义

设工作区中的包集合为:

P={p1,p2,,pn}P = \{p_1, p_2, \ldots, p_n\}

如果包 pap_apackage.json 中声明了对 pbp_b 的依赖,则建立一条有向边:

papbp_a \rightarrow p_b

这里的箭头表示“p_a 依赖 p_b”,也就是构建 p_a 前通常需要准备 p_b

例如:

@acme/admin  →  @acme/ui  →  @acme/tokens

含义是:

  • admin 使用 ui
  • ui 使用 tokens
  • tokens 不依赖前两个包。

若每个包的构建都需要依赖包的产物,则合法构建顺序必须满足:

order(pb)<order(pa)order(p_b) < order(p_a)

因此:

tokens → ui → admin

2. 为什么构建图通常要求无环

假设出现:

ui → tokens → ui

要构建 ui,先要构建 tokens;要构建 tokens,又要先构建 ui。于是得到:

order(ui)<order(tokens)order(ui) < order(tokens)

同时:

order(tokens)<order(ui)order(tokens) < order(ui)

两者无法同时成立。因此,依赖图中的环会使基于拓扑排序的独立构建顺序不存在。

包管理器可能允许安装这种循环依赖,某些开发服务器也可能暂时通过源码加载运行,但这不代表它适合生产构建。循环还会造成:

  • 初始化顺序不稳定;
  • 增量构建无法准确判断起点;
  • 任务缓存失效;
  • 包发布顺序无法确定;
  • 类型声明互相引用。

3. 包图和模块图不是一回事

Monorepo 中至少存在三种图:

包依赖图

package.json 的依赖字段表达:

admin → ui → tokens

它回答:

哪个 package 依赖哪个 package?

模块图

importexport 形成:

App.vue → @acme/ui/index.ts → AcmeButton.vue

它回答:

运行时或构建时,哪个模块引用哪个模块?

任务图

由脚本和任务依赖形成:

build:tokens → build:ui → build:admin

它回答:

哪些任务必须先完成,哪些任务可以并行?

三个图可能不完全相同。例如 admin 在运行时依赖 ui,但如果 ui 的开发入口直接指向源码,开发时可能不需要先执行 build:ui;生产构建则可能必须依赖 build:uidist

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 的颜色文件发生变化时:

  1. tokens 的源文件变化;
  2. tokens 的构建任务失效;
  3. ui 使用了 tokens 的新产物,因此 ui 的构建任务失效;
  4. adminstorefront 使用了 ui,两者的构建任务也失效;
  5. api-client 没有依赖 tokens,不需要重新构建。

结果传播集合为:

Affected(tokens)={tokens,ui,admin,storefront}Affected(tokens) = \{tokens, ui, admin, storefront\}

而不是整个仓库的所有包。

如果反过来只修改 @acme/api-client

Affected(api-client)={api-client}Affected(api\text{-}client) = \{api\text{-}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"
/>

组件只关心 disabledclick 这类公共契约。数据流变为:

应用状态 → 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/uiexports

如果目标是包间依赖,应优先使用真实 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

重点确认:

  1. @acme/ui 是否被 pnpm-workspace.yaml 匹配;
  2. 包名是否完全一致;
  3. 应用是否声明了 workspace:*
  4. exportsmainmodule 是否指向正确文件;
  5. dist 是否已经生成;
  6. 是否锁文件与 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.typestypes 指向不同位置;
  • 声明文件引用了未发布的内部路径。

可以直接检查:

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

确认 mainmoduletypesexports 指向的文件都存在,并且文件进入 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 和资源入口

构建图
  负责:表达依赖顺序、影响范围、并行与缓存

版本
  负责:描述公共契约的兼容性变化

边界
  负责:限制依赖方向,防止消费者依赖内部实现

它们之间的因果关系是:

  1. package.json 声明包身份和依赖;
  2. Workspace 根据这些声明解析本地包;
  3. 包依赖形成包图;
  4. 包图和任务配置形成构建图;
  5. 构建图决定哪些包先构建、哪些任务可以复用;
  6. exports 和公共入口定义可消费边界;
  7. 公共 API 的变化决定版本变化;
  8. peer dependency 和 external 配置决定 Vue 等共享运行时是否保持一致。

如果只配置 Workspace,得到的是“多个目录可以互相安装”;如果再建立公共入口,才得到共享包;如果补全构建图,才得到可预测的交付流程;如果正确管理版本和边界,Monorepo 才不会随着规模增长退化成一个互相穿透的源码目录。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。