在 Vue 3 项目里封装工具库时,经常会遇到一个仓库里同时维护多个子包的情况。它们可能分别提供组合式函数、自定义指令、纯工具函数或类型守卫,如果继续沿用单包的配置方式,每个子包都要维护独立的 TypeScript、ESLint 和构建脚本,时间一长就容易出现规则漂移。Monorepo 的价值正是把这些公共约束提升到仓库级别,再通过 workspace 依赖让包与包之间保持清晰的引用关系。

一、用 pnpm workspace 划定多包边界
先确定仓库的顶层结构。一个典型的 Vue 3 工具库 Monorepo 会包含 packages 目录存放可发布的子包,examples 目录存放本地调试用的示例项目,可能还有 internal 目录放置不发包的内部脚本。根目录的 package.json 需要设置为私有包,避免误发布,同时声明整个仓库使用 pnpm 管理。
pnpm workspace 的配置非常轻量,只需要在根目录放置一个 pnpm-workspace.yaml 文件。这个文件告诉 pnpm 哪些目录下的包属于同一个工作空间,后续安装依赖时会统一处理软链和硬链。相比 npm 和 yarn 的 workspace 方案,pnpm 的优势在于依赖隔离更彻底,子包不会意外引用到未声明的包,这对工具库尤为重要,因为发布出去的包必须保证依赖完整。
packages: - "packages/*" - "examples/*" - "internal/*"
每个子包都需要一个标准的 package.json,并且建议使用统一的作用域命名,例如 @vue3-toolkit/use-fetch、@vue3-toolkit/use-local-storage、@vue3-toolkit/shared。这样在 workspace 内互相引用时,依赖关系一目了然。包之间的本地引用不要写具体版本号,而是使用 workspace 协议,pnpm 会在发布前自动替换成真实版本。
{
"name": "@vue3-toolkit/use-fetch",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"dependencies": {
"@vue3-toolkit/shared": "workspace:*"
}
}
上面这个配置表达了两层意思:一是该包对外同时提供 ESM 和 CJS 两种格式,并且类型声明独立输出;二是它依赖了同仓库的 shared 包,但版本号交给 workspace 协议管理,不需要人工维护。这样做可以避免子包之间出现版本锁定冲突,也让本地开发时的热更新链路更直接。
二、共享 TypeScript 与 ESLint 配置
工具库通常不需要复杂的路径别名,但严格的类型检查、统一的 target 和 moduleResolution 是必须的。如果把 tsconfig 拆到每个子包里,修改一次 target 就要改七八份文件。更合理的方式是在根目录维护一份 tsconfig.base.json,所有子包通过 extends 继承,再补充各自的 outDir 和 include 范围。
根目录的配置可以这样写。它只保留公共编译选项,不指定具体入口文件,这样不会干扰子包自身的构建流程。为了适配 Vue 3 和 Vite 的生态,target 建议设为 ES2020 或更高,moduleResolution 使用 Bundler,这样既能识别 package.json 的 exports 字段,也能正确处理条件导出。
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": ["vite/client"]
}
}
子包只需要写一个很短的 tsconfig.json,把公共配置拉进来,再指定编译输出目录。例如 use-fetch 包的配置如下。这种继承方式让每个包的配置差异仍然可见,但公共部分完全收敛到根文件,改一处即可全面生效。
{
"extends": "../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"outDir": "dist"
}
}
ESLint 配置也是同样的思路,但更适合做成一个独立的包而不是文件。原因是 ESLint 的 flat config 可能包含插件、解析器和自定义规则,如果只是放一个可被 extends 的 JSON,扩展性会很差。更常见的做法是新建 @vue3-toolkit/eslint-config 包,里面导出数组格式的配置。其他子包在 eslint.config.js 中直接导入这个包即可。
import js from "@eslint/js";
import vue from "eslint-plugin-vue";
export default [
js.configs.recommended,
...vue.configs["flat/recommended"],
{
files: ["**/*.{ts,vue}"],
rules: {
"vue/multi-word-component-names": "off"
}
}
];
由于 Vue 3 工具库可能同时包含 .ts 和 .vue 文件,共享 ESLint 包需要同时覆盖 TypeScript 和 Vue 规则。上面的配置只是一个简化示例,实际项目中还可以追加 vue-eslint-parser、@typescript-eslint 规则集和自定义规则。关键点是,子包不再维护自己的 .eslintrc 或 eslint.config.js 的核心规则,而是统一继承共享包,只在必要时覆盖一两条局部规则。
三、构建产物与 Vue 3 依赖边界
工具库的构建目标不是把所有依赖都打进一个文件,而是输出干净、可被外部项目按需加载的代码。对于 Vue 3 组合式函数来说,vue 本身必须作为 peerDependency 声明,并在构建阶段外部化处理,不能把 Vue 核心打包进产物。否则使用方会同时存在两份 Vue 实例,导致响应式系统出现不可预知的问题。
为了简单起见,可以用 unbuild 或 tsup 来构建每个子包。它们默认就会把 peerDependencies 和 dependencies 里的包当作外部依赖,产出的 ESM 和 CJS 文件体积小,类型声明也能自动生成。以 use-fetch 包为例,构建配置可以这样写。
import { defineBuildConfig } from "unbuild";
export default defineBuildConfig({
entries: ["src/index"],
declaration: true,
clean: true,
rollup: {
emitCJS: true
}
});
package.json 中需要把 vue 放进 peerDependencies,并在 devDependencies 中安装它作为开发依赖。这样既能保证开发时类型推导和运行测试,又不会把 Vue 声明为硬依赖。对于工具包里的 shared 子包,如果只提供纯函数,可能完全不需要依赖 Vue,此时就保持纯净,不要为了统一而强行引入 Vue 依赖。
另一个容易忽略的点是 Vue 3 的内部包边界。组合式函数通常只需要依赖 vue 顶层导出的 API,比如 ref、computed、watch,不需要直接依赖 @vue/runtime-core。如果某些高级场景确实要引用内部包,建议把这些引用隔离在单独的文件中,并写清楚原因,避免后续升级 Vue 小版本时出现内部 API 变更导致构建失败。
{
"peerDependencies": {
"vue": "^3.4.0"
},
"devDependencies": {
"vue": "^3.4.0"
}
}
四、版本管理与发布流程
一个 Monorepo 里有多个可发布包时,最大的挑战不是代码,而是版本一致性。比如 use-fetch 修复了一个 bug,shared 包可能同时改了类型定义,如果手动分别更新版本号,很容易漏掉依赖关系。changesets 就是用来解决这个问题的工具,它把每次变更记录成带 semver 级别的小文件,发布时自动汇总并生成 CHANGELOG。
初始化 changesets 后,每次提交功能或修复时执行 pnpm changeset,选择受影响的包并指定版本升级类型。工具会根据 workspace 依赖关系自动调整版本号。发布前本地运行的命令可以放在根 package.json 的 scripts 中,例如先跑一次全量构建和测试,再进入 changeset 的版本发布流程。
{
"scripts": {
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"release": "pnpm build && pnpm test && changeset publish"
}
}
如果同时发布了 shared 包和 use-fetch 包,changesets 会确保 use-fetch 依赖的 shared 版本号同步更新,而不是停留在旧的 workspace 协议上。发布成功后,根目录里的 changeset 文件会被自动清理,保证下次发布时只处理新增变更。这样整个多包发布流程就从一项容易出错的手工活变成了可重复执行的命令。
工具库 Monorepo 的最终目标,是让每个子包既能独立发布,又能共享同一套工程约束。开发者日常只需要在对应的 packages 目录下写代码,类型检查、lint 规则、构建产物和版本记录全部由仓库级配置驱动。Vue 3 生态中的组合式函数库、指令库甚至小型组件库,都可以用这套模式降低长期维护成本。