全栈项目如何用 pnpm Workspaces 构建 monorepo:从多仓库到单仓库的工程化实践
先交代一个背景。Wipi 这个项目最早是我自己维护的一个全栈作品前端是 Vue 3 Vite后端是 Node.js 写的服务最初两个仓库分开管理。前半年还好东西不多前后端各改各的发布的时候手动对齐一下接口就行。后来功能越加越多问题开始集中爆发改一个接口字段后端改完发版前端还在用老类型明明两个人都在改同一个项目代码却散落在两套仓库里CI 要分别拉代码、分别构建、分别部署。那段时间我每天都在干同一件事——处理前后端对不上的问题。后来我把整个项目塞进一个单体仓库monorepo用 pnpm Workspaces 管理拆成 4 个包两个应用两个共享包。改了大概一周整个开发和发布流程顺畅了很多。这篇文章把这次改造的思路、架构分割、依赖设计、构建编排以及我在实际操作里踩到的坑完整记录下来。1. 为什么把 Wipi 从多仓库改造成 monorepo不是追潮流是真扛不住了1.1 多仓库阶段最折磨人的三个日常先说三个我最常遇到的场景如果你也在维护前后端分离的项目应该不会陌生。第一接口类型不同步。后端的GET /api/article/:id返回的字段从{ title: string, content: string }改成了{ title: string, body: string, tags: string[] }后端代码里改了前端 TypeScript 定义还在用旧的。前端调用article.content编译能过运行时报undefined然后花半天排查是不是请求写错了最后发现是后端悄悄改了接口。第二工程化配置漂移。两个仓库各自维护一套 ESLint 规则、一套 TS 配置、一套 prettier 配置。今天给前端加了no-unused-vars报错规则后端完全不知道。代码风格在仓库边界上直接断层看代码像在看两个团队写的。第三联调成本高。本地开发要同时启动前端 dev server 和后端服务前端配 proxy 转发 API。听起来问题不大但当你有 pr 分支、测试环境、本地环境三个环境需要切换时每个环境都要维护一套环境变量和代理配置改起来头大。这三个问题本质上都是协同契约问题。代码量不是关键关键是前后端之间的接口约定、代码规范、构建流程必须在同一个上下文里维护。1.2 monorepo 解决的核心问题把跨仓库变成跨目录多仓库的问题在于物理隔离把逻辑上属于同一个项目的东西拆开了。Wipi 本质是一个完整的应用前后端的接口契约、数据模型、错误码定义本来就应该是一份东西却因为仓库边界被强迫维护成两份。monorepo 并没有改变你写代码的方式它改变的是这些代码在磁盘上的组织形式和依赖解析关系。前后端代码放在同一个仓库里改动一起提交、一起审查、一起发布。接口类型定义变了前端代码在同一个 MR 里同步修改CI 会同时检查前后端不匹配就直接挂掉根本走不到部署那一步。1.3 monorepo 也不是银弹Wipi 适合不代表所有项目都适合我必须先泼一盆冷水。monorepo 适合的是代码之间有明确共享需求、开发节奏快、包的数量可控的项目。如果你维护的是十几个互相没有业务关系的微服务各自独立部署、独立发布、由不同团队维护硬塞进一个仓库反而会让 CI 变慢、权限难以控制、发布互相阻塞。Wipi 适合 monorepo 的原因有三个前后端是同一个业务闭环接口自产自销共享代码的收益非常高整个项目只有 4 个包构建时间可控单个仓库的复杂度不会爆炸维护者少主要就我一人不需要复杂的权限隔离和发布协调。判断一个项目适不适合 monorepo就看一点把代码放在一起是让协同变简单了还是让仓库变臃肿了。Wipi 明显是前者。2. pnpm Workspaces 的位置它凭什么当这个底座2.1 workspace 协议本地包如何变成依赖monorepo 的核心是让一个包可以依赖另一个本地包并且在开发和构建时都能正确解析。pnpm 用pnpm-workspace.yaml定义工作区配合 package.json 里的workspace:*协议实现这一点。我的pnpm-workspace.yaml长这样packages: - apps/* - packages/*然后在apps/web的 package.json 里{ dependencies: { wipi/shared: workspace:*, wipi/config: workspace:* } }workspace:*的意思是这个依赖不从 registry 拉取而是从当前工作区里找wipi/shared这个包。发布时 pnpm 会把它替换成实际版本号。这比file:协议好使file:协议的包在 node_modules 里是一个源码目录的引用而workspace:协议会被解析成正常的依赖版本行为更符合直觉。2.2 硬链接 全局存储pnpm 区别于 npm/yarn 的根本pnpm 最核心的设计是全局模块存储content-addressable store。所有依赖包第一次安装时会解压到全局 store 里后续任何项目安装相同版本的依赖都通过硬链接引用而不是重新复制。对 Wipi 这种 4 包架构收益有两个一是节省磁盘。vue、vite、typescript这些重依赖在 web 和 server 里可能都要装pnpm 只在磁盘上存一份剩下全是硬链接。二是安装速度快。第二次 install 基本秒级完成因为文件已经在 store 里了。CI 里跑pnpm install --frozen-lockfile的速度比之前 npm 快很多因为大部分包都不用重新下载。2.3 严格 node_modules把幽灵依赖按死在摇篮里这里必须展开讲一下 pnpm 和 npm/yarn 最本质的区别这也决定了它适合 monorepo。npm 和 yarn 会把依赖结构做成一个扁平的 node_modules所有依赖的依赖都会被提升到顶层。这带来的问题是幽灵依赖——你的代码可以import一个你根本没有在 package.json 里声明的包。比如你的项目只声明了vue但vue内部依赖了vue/reactivity在 npm 的结构里vue/reactivity被提升到了 node_modules 根目录你的业务代码于是可以直接 import 它编辑器不报错、构建也能过但某天vue升级后不再依赖vue/reactivity你的代码瞬间崩掉而且报错还定位不到原因。pnpm 的 node_modules 结构是符号链接symlink驱动的node_modules/ wipi/shared - ../../packages/shared vue - .pnpm/vue3.4.21/node_modules/vue .pnpm/ vue3.4.21/ node_modules/ vue/ vue/reactivity/项目只能直接访问自己 package.json 里声明的依赖间接依赖被隔离在.pnpm目录里除非显式声明否则业务代码根本访问不到。这在 4 包架构里尤其重要apps/web只能依赖它声明的东西wipi/config里的依赖即使被提升web 端也访问不到。严格边界意味着你每写一行import都要想清楚它属于哪个包这在多包协同中是必要的纪律。2.4 为什么不用 npm/yarn 的 workspaces一个假成功的教训其实 Wipi 最早用的是 npm workspaces当时跑通了一套表面看起来也行。但后来遇到一个问题让我坚决换成了 pnpm。有一次apps/server里用到fastify而apps/web里因为某些依赖的间接关联node_modules 顶层恰好也有一份fastifynpm 提升到顶层了。我在 web 端代码里无意中引用了 fastify构建居然过了。说实话那会儿我是崩溃的——这个代码逻辑上毫无意义但 npm 的扁平结构就是不报错。它让我意识到npm workspaces 的依赖提升机制在 monorepo 的场景下会持续制造这种假成功你根本不知道哪段代码是侥幸跑起来的。pnpm 的严格结构一次就治好了这个病。依赖边界清晰以后4 个包的职责才能真正立住。3. 4 个包的分工边界Web、Server、Shared、Config 各自负责什么3.1 目录总览apps 与 packages 的分治Wipi 的目录结构是这样的wipi/ ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json ├── apps/ │ ├── web/ │ │ ├── src/ │ │ ├── index.html │ │ ├── vite.config.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── server/ │ ├── src/ │ ├── package.json │ └── tsconfig.json └── packages/ ├── shared/ │ ├── src/ │ ├── package.json │ └── tsconfig.json └── config/ ├── eslint/ ├── tsconfig/ ├── package.json └── index.js规则很简单apps/放最终会被打包部署的应用packages/放被应用依赖的共享库和工程化配置。应用可以依赖共享包共享包不能反过来依赖应用。依赖方向是单向的。3.2 apps/webVue 3 Vite 的前端应用apps/web是面向用户的界面应用。技术栈是 Vue 3 TypeScript ViteUI 组件库选的是 Element Plus。整个包只负责一件事把 API 返回的数据渲染成可交互的页面。Vite 作为 dev server 的核心优势是对 workspace 包原生友好。在 dev 模式下Vite 会把wipi/shared这种 workspace 依赖直接当作源码来编译前提是配置好optimizeDeps不需要手动执行tsc或rollup。开发体验基本等同于在单仓库里改代码。web 端的职责边界是不直接访问数据库、不处理业务校验逻辑、不感知服务端内部实现。它只管拿到数据、渲染数据、提交用户操作。3.3 apps/server处理业务逻辑与数据持久化apps/server是后端服务负责 API 提供、业务逻辑编排、数据库访问和鉴权。技术栈是 Node.js Fastify TypeScript Drizzle ORM数据库用 PostgreSQL。选 Fastify 而不是 Express 的原因是它的插件体系和性能更好TypeScript 支持也完善Drizzle 则是类型安全的 ORM和 shared 包里的类型定义配合起来很顺畅。server 端的核心是把业务逻辑和 HTTP 层分开。控制层route handler只做参数解析、调用业务函数、返回响应业务层放在services/目录里处理校验、操作数据库、抛业务错误。这样做的好处是如果以后要给 Wipi 加一个定时任务或者命令行工具可以直接复用 services 层而不需要走 HTTP。3.4 packages/shared前后端共同持有的真理之源这是 4 包架构里最关键的一包。wipi/shared里放的是前后端都需要用到的东西API 数据类型定义比如Article、User、Comment这些实体的 TypeScript 类型前后端引用的是同一份定义请求/响应结构ApiResponseT这种通用结构错误码与错误信息映射后端抛错误码前端映射错误消息不会出现 code 对不上的情况运行时校验逻辑用的 zod 写 schema前端表单校验和后端接口入参校验共用一套规则。这里我想强调一个理念只要前后端能用一份代码表达的东西就不应该写成两份。类型定义是最典型的后端 DTO 和前端 interface 如果分别维护改动时几乎一定会出现不同步。shared 包让类型和校验规则变成了单一事实源single source of truth前后端只依赖它。3.5 packages/config工程化配置的统一出口wipi/config存放的是不涉及运行时业务逻辑的工程化配置。它导出的东西包括ESLint 配置一个统一的.js配置导出包含typescript-eslint、vue、import插件的规则集合TypeScript 配置tsconfig.base.json和针对不同场景的扩展配置Prettier 配置统一代码格式构建工具辅助函数比如统一读取环境变量的方法。关于这个包最重要的是理解为什么要抽出来。当你有多个包都要 lint 时如果每个包各自维护一份.eslintrc改一次规则要改 4 个文件。抽成wipi/config后每个包的配置变成一行// apps/web/.eslintrc.js module.exports { root: true, extends: [wipi/config/eslint], };这样改规则只改一处所有包同时生效。wipi/config本身是纯编译期依赖不应该出现在最终产物的依赖树上。3.6 为什么是 4 包而不是 2 包抽取 Shared 和 Config 的依据可能有人问项目不大Web 和 Server 都在一个仓库里了为什么还要拆出 Shared 和 Config 两个包直接 apps/web 和 apps/server 互相 import 不就行了吗不行。核心原因有两个一是依赖方向必须单向。如果 web 直接 import server 里的类型定义那么 web 在构建时就要能解析 server 的整个代码包括它的依赖这会让前端构建依赖后端的技术栈耦合度直接拉满。shared 包是把公共部分抽出来的缓冲层web 和 server 都只依赖 shared彼此不直接触碰。二是可测试性和未来扩展。如果以后想加一个apps/admin管理后台或者apps/cli命令行工具它们都只需要依赖 shared 和 config不需要碰 web 和 server 的业务代码。保持包的职责单一是 monorepo 长期可维护的基础。4. 协同机制一条用户请求从前端到数据库的类型全链路4.1 一次典型的 CRUD 请求类型是怎么流转的我拿 Wipi 里的创建文章接口举例完整走一遍 4 包协同的过程。第一步在 shared 包里定义数据模型和请求响应类型// packages/shared/src/models/article.ts import { z } from zod; export const CreateArticleInput z.object({ title: z.string().min(1).max(100), content: z.string().min(1), tags: z.array(z.string()).max(10).optional(), }); export type CreateArticleInput z.infertypeof CreateArticleInput; export interface Article { id: string; title: string; content: string; tags: string[]; createdAt: string; updatedAt: string; } export interface ApiResponseT { code: number; message: string; data: T; }第二步server 端引入这个 schema 做入参校验和在数据库层使用类型// apps/server/src/routes/article.ts import { CreateArticleInput, Article } from wipi/shared; import { FastifyInstance } from fastify; export async function articleRoutes(app: FastifyInstance) { app.post(/api/articles, async (request, reply) { const input CreateArticleInput.parse(request.body); // 校验并得到类型 const article await createArticle(input); reply.send({ code: 0, message: ok, data: article } satisfies ApiResponseArticle); }); }第三步web 端从 shared 包里拿到类型创建 API 请求函数时直接用// apps/web/src/api/article.ts import { CreateArticleInput, ApiResponse, Article } from wipi/shared; import axios from axios; export async function createArticle(input: CreateArticleInput): PromiseArticle { const { data } await axios.postApiResponseArticle(/api/articles, input); return data.data; }在这个过程中前端写代码时能获得完整的类型提示。如果CreateArticleInput增加了字段前端不传就会在编译期报错如果后端改错 schema服务端校验也会在运行时兜底。类型是这套架构里最牢靠的契约。4.2 运行时校验为什么前后端要用同一套 zod schema类型系统只在编译期有效运行时的数据是不可信的。假设 web 端对用户输入做了完整的表单校验后端接口仍然必须再次校验——因为前端校验只是优化体验真正的安全边界在后端。如果前后端各写一套校验规则很可能出现前端说 title 不能超过 100 字后端说 50 字用户提交 80 字的标题前端拦住了客户端不会发请求。但如果有人绕过前端直接调 API后端 50 字的规则就会生效。两层校验不一致最终行为看运气。shared 包里的 zod schema 解决了这个问题前端用同一个 schema 做表单校验配合zodResolver后端用同一个 schema 做接口入参校验规则只维护一份保证了无论请求从哪里发来行为完全一致。4.3 依赖方向设计为什么 web 不直接依赖 server这是我在做架构设计时考虑最多的一点。如果 web 直接依赖wipi/server里的类型那么 web 构建时 TypeScript 会尝试解析 server 的代码而 server 的代码依赖 fastify、drizzle、pg 等后端库。这些库大多数不能直接在浏览器环境运行Vite 打包时会试图把后端代码打进前端 bundle然后各种报错。正确的做法是web 只依赖 shared 包里已定义好、且不含有 Node.js 专属 API 的类型和纯函数。shared 包在组件划分上就约定好不允许出现import fs或import path这类 Node 内置模块不允许出现浏览器专属 API。这样它才能同时被两端安全使用。4.4 本地开发一条命令跑起全栈本地开发的体验直接决定这个架构好不好用。Wipi 在根目录 package.json 里配了几个脚本{ scripts: { dev: pnpm run dev --parallel --filter wipi/server --filter wipi/web, dev:web: pnpm run dev --filter wipi/web, dev:server: pnpm run dev --filter wipi/server, build: pnpm -r run build } }pnpm run dev会同时启动前后端的 dev server。web 端 Vite 配置里做了代理// apps/web/vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, }, }, }, });这样前端页面里请求/api/articles会自动转发到后端的 3000 端口本地联调零感知。开发时改 shared 包的代码Vite dev server 会自动热更新因为 workspace 源码本身就是 Vite 的依赖图的一部分。4.5 构建编排与增量缓存生产构建时依赖关系要求 shared 先构建server 和 web 后构建。在 pnpm 脚本里我直接用pnpm -r run build它会按照拓扑排序执行wipi/config-wipi/shared-wipi/server/wipi/web。不需要手动指定顺序因为 package.json 里的workspace:*依赖已经声明了依赖关系。如果项目规模再大一些可以考虑引入 Turborepo 做增量构建缓存但目前 4 个包构建一次总共就十几秒没必要加额外复杂度。5. 工程化配套脚本编排、CI 构建与镜像发布5.1 根目录脚本下发pnpm -r与--filter的实际用法在 monorepo 里命令执行的粒度控制很关键。我平时用的最多的命令pnpm -r run build按依赖拓扑顺序执行所有包的 build 脚本pnpm --filter wipi/shared run build只构建 shared 一个包pnpm --filter wipi/web add axios只给 web 包安装依赖pnpm -r --filter wipi/server --filter wipi/web run lint只对两个应用跑 lint。这里有一个小技巧--filter支持通配符。比如pnpm --filter ./apps/* run lint会 lint 所有 apps 下的包。如果你需要经常同时对 server 和 web 做同一操作可以用这个方式省略重复写包名。5.2 lint-staged commitlint守住提交这最后一道线monorepo 比单仓库更需要统一的提交规范因为一个提交可能涉及多个包。我用lint-staged只对git add的文件跑 eslint 和 prettier避免全量 lint 太慢commitlint校验 commit message 的格式遵循 conventional commits 规范husky在 pre-commit 和 commit-msg 钩子里挂上上述工具。.husky/pre-commit内容#!/usr/bin/env sh pnpm lint-staged根目录package.json里的 lint-staged 配置{ lint-staged: { *.{ts,vue,tsx}: [eslint --fix, prettier --write], *.{json,md,yaml}: [prettier --write] } }这样一来跨包的类型改动在提交前就会被静态检查拦住一部分不至于带到 CI 里才暴露。5.3 CI 流程代码推送后发生了什么Wipi 的部署用的是 Jenkins Docker。CI 流程分为几个阶段安装依赖pnpm install --frozen-lockfile。这里的--frozen-lockfile很重要它要求必须存在且与package.json一致的 lockfile防止有人在本地悄悄升级了依赖但没提交 lockfileCI 上装出不同版本Lint 类型检查对全仓库跑pnpm -r run lint和pnpm -r run typecheck构建pnpm -r run build产出前后端的 dist 目录镜像构建分别构建wipi-web和wipi-server两个 Docker 镜像推送与部署将镜像推送到镜像仓库服务器拉取并容器化重启。web 端镜像是一个 Nginx 容器构建时把 dist 目录拷进去Nginx 里配置 SPA fallback 和/api反向代理。server 端镜像是一个 Node.js 运行容器。两个容器用 docker-compose 编排共享同一个网络。5.4 环境变量的管理方式多包项目里环境变量的管理容易乱。我的选择是每个包独立管理自己的 .env 文件互不包含。server 需要的DATABASE_URL、JWT_SECRET、PORT放在apps/server/.envweb 需要的VITE_API_BASE_URL放在apps/web/.env共享的业务配置比如 CORS 白名单也放在 server 的 env 里不放在 shared 包因为 shared 是编译期库不应该读取运行时变量。CI 里构建时用环境变量注入的方式覆盖默认配置不把正式环境的密钥提交进仓库。jenkins 构建参数里配置好这些变量镜像构建时传入可以有效避免密钥泄漏。6. 实测高频踩坑依赖、符号链接、TS 路径别名这些老问题6.1 经典幽灵依赖eslint 插件找不到换成 pnpm 之后最先出问题的就是 ESLint 插件。在 npm 时代eslint-plugin-vue就算不在某个包的 devDependencies 里也会因为依赖提升被放到顶层 node_moduleseslint 能正常加载。换成 pnpm 严格模式后eslint 会在当前包目录找不到插件然后一路向上找找不到就直接报错ESLint couldnt determine the plugin eslint-plugin-vue uniquely.解决办法很粗暴也很明确谁的代码要 lint谁就把插件声明到自己的 devDependencies 里。于是我在wipi/config的 devDependencies 里补齐了所有 lint 相关插件然后 apps 里的每个包都依赖wipi/config。这样 eslint 从wipi/config的解析路径里能找到插件。这个报错其实不该被看成麻烦它是 pnpm 在帮你纠正错误你的插件本来就该显式声明依赖。6.2 shared 包的构建方式和 tsconfig 配置wipi/shared的构建方式一开始走了弯路。最初我给 shared 配置了 TypeScript 的project references让 web 直接引用 shared 的源码目录不单独构建。这在 Vite 里可行因为 Vite 能直接编译 TS 源码但 server 端使用tsc构建时经常遇到.ts文件被引用的报错因为 Node 运行时最终还是需要 JS 文件。我最终改成shared 包独立构建成dist/目录package.json 的main指向dist/index.jstypes指向dist/index.d.ts{ name: wipi/shared, main: dist/index.js, types: dist/index.d.ts, scripts: { build: tsc -p tsconfig.build.json, dev: tsc -p tsconfig.build.json --watch } }这样 web 和 server 消费的都是构建后的产物行为一致不会有源码编译路径不同的诡异问题。开发模式下 shared 的 tsc watch 监听文件变化自动重新构建配合 Vite 的热更新体验完全可以接受。6.3 Vite 对 workspace 包的预构建处理Vite 默认会对依赖做预构建esbuild optimizeDeps。对 workspace 里的包如果它有 ESM 格式的源码Vite 通常能直接处理但有些场景需要手动排除。我遇到的一个典型情况是shared 包里的 zod 版本和 web 包里的 zod 版本不一致哪怕只是小版本差异Vite 预构建时可能出现同时存在两个 zod 实例的问题导致instanceof判断失效。我最终在vite.config.ts里加了配置export default defineConfig({ optimizeDeps: { include: [wipi/shared], }, });并且在 shared 包里把zod放进peerDependencies只允许一个副本存在。这样前端构建时的依赖解析就稳定了。这里我补充一句如果遇到引用 workspace 包后修改 shared 源码但前端不热更新的情况多半是 Vite 的依赖缓存问题。清掉node_modules/.vite再启动 dev server 就能解决。6.4 peerDependencies 警告与重复依赖在把zod、typescript、vue这类库放进 shared 包的 dependencies 后运行pnpm install时经常出现警告Unmet peer dependencies: zod^3.22.0原因很好解释web 包已经安装了 zodshared 包里如果也安装一份两边的类型可能因为版本差异变得不完全兼容。在 pnpm 的严格结构里这不是 bug但会浪费磁盘空间偶尔还会引发类型不匹配。正确做法是凡是宿主应用也需要直接使用的库进入 shared 包的 peerDependencies。比如 zodweb 的表单校验要用它server 的接口校验也要用它那么 shared 不直接安装而是声明peerDependencies要求宿主提供。这样整个项目中 zod 永远只有一个实体。{ peerDependencies: { zod: ^3.x } }6.5 升级依赖时的版本漂移monorepo 里最容易被忽略的问题是依赖版本漂移web 锁定 vue 3.4.1shared 里的类型用到 vue 的新特性明明根目录有 lockfile但各包对同一个库的版本要求不一样pnpm 会在.pnpm里放两个版本。这个问题目前没有“一劳永逸”的解法。我的经验是同一生态的库vue、vite、typescript、types/chrome 等尽量用相同的精确版本在根目录 package.json 里用pnpm.overrides字段强制统一或者升级时一起升级定期跑pnpm up --latest -r批量升级到最新版本然后统一跑一遍构建和测试降级成本比以后单独升级小得多。最后再说几句实际体会在我把 Wipi 拆成 pnpm Workspaces 的单体仓库之后最大的感受不是构建速度变快了虽然确实快了而是心态变了——前后端代码在同一个仓库里我不用担心改接口时忘记同步类型我可以放心地重构数据模型因为有类型系统和共同 schema 会帮我找出所有受影响的地方。如果你也打算把自己的前后端项目改造成 monorepo我的建议是不要一口气上太多工具。先把pnpm-workspace.yaml配好把 shared 包抽出来让前后端共享类型就已经能解决 80% 的协同痛苦。之后再逐步引入统一的 lint 配置、提交规范、CI 编排。工具是为了解决问题的不是为了展示技术。另外有一点想提醒如果项目里已经维护了很长时间、且前后端完全由不同团队独立开发运营建议谨慎推进 monorepo 改造。它适合的是开发节奏一致、共享需求强烈的项目。像 Wipi 这类一个人全栈维护、24 小时内迭代一个完整功能的小项目monorepo 的好处会被放大到极致。最后分享一个实际使用的小技巧在根目录放一个README.md把包管理命令、目录说明、常见问题全部写清楚。等你半年后回头看这个项目你就知道当初的架构决策和命令约定都记在那里不用靠回忆去猜当时的意图了。