速查版:一屏看懂monorepo 是什么、packages 和 apps 怎么分、你项目里一次请求穿过哪些包,以及根目录那些 -w 命令到底在干什么。
你全盘唯一一个 monorepo:C:\Users\Administrator\WorkBuddy\delimao-system
它是德莉玛淋浴房产供销一体化系统的「路线B 生产级重构」,用 npm workspaces(不是 pnpm、不是 turbo、不是 lerna),全项目 30 个 ts/tsx 源文件(含 dist 共 40 个)。
四个包:
| 包 | 作用 | 技术 |
|---|---|---|
@delimao/server | 后端服务 | Express 4 + tRPC 11 + JWT |
@delimao/web | 前端 | React 19 + tRPC client + Tailwind 4 |
@delimao/db | 数据层 | drizzle-orm 0.38 + @libsql/client + migrate/seed |
@delimao/engine | 纯业务引擎 | 纯 TypeScript,无框架依赖(报价 / BOM / 玻璃图 / 主数据) |
⚠️ 最值钱的一条是 packages/engine:它不依赖 Express、不依赖数据库、不依赖任何框架。这个设计决定了你能不能给它写单测、能不能把它搬到别的系统里复用。
一个仓库里放多个互相依赖的包,一次提交,一起改,一起发版。
「mono」是「单一」,「repo」是「仓库」。所以 monorepo = 单体仓库:所有代码住在一个仓库里,而不是拆成四个仓库。
| 方式 | 怎么组织 | 代价 |
|---|---|---|
| 单仓库 | 一个项目一个仓库,别的项目来「复制粘贴」代码 | 复制出去的代码和你手里的原版再无关系,改了一边另一边不跟着改,半年后两份代码不一样 |
| 多仓库 | 每个包一个独立仓库,靠 npm 互相发布引用 | 改一个接口 → 改 4 处+ 发 4 次版+ 等 4 个仓库的 CI。中间任何一步没跟上,线上就是版本错乱 |
| monorepo | 一个仓库,N 个包,包之间直接引用 | 包之间的依赖要靠自己纪律约束(这就是本文要讲的重点) |
不是为了赶时髦,是因为你的业务要求这四块必须同时改:
engine(算价逻辑)→ db(价格表结构/主数据)→ server(暴露 tRPC 接口)→ web(页面调用与显示)。四个包一起改,一次提交。engine 的类型定义要加 → db 的 schema 要加 → server 的 router 要加 → web 的表单和表格要加。拆成四个仓库就是四次发版,中间任何一版没跟上,线上直接报错。git commit 一次,四个包的代码同时变成新版本。没有「等发布」这一步。你根目录的 workspaces 声明了两个通配符:
"workspaces": ["packages/*", "apps/*"]
就一行,但含义很明确:所有子目录我都收,packages/* 归一类,apps/* 归另一类。分法不是随便定的,是按「有没有自己的启动方式」来分。
apps/* | packages/* | |
|---|---|---|
| 是什么 | 可运行的程序 | 被别人用的库 |
| 判断标准 | 有 dev / start 脚本,能独立跑起来对外提供服务 | 没有独立启动方式,只能被别的代码 import |
| 最终产物 | 是终点(部署到服务器的就是它) | 是材料(是给别人用的中间产物) |
| 你的实例 | apps/server(tsx watch src/index.ts 起服务)apps/web(vite 起前端) | packages/db(被 server import)packages/engine(被 server 和 web 都 import) |
vite 一敲就出一个网页),它不是被别人 import 的库。反过来,engine放 apps 就不对了——它没法独立启动,谁也「运行」不了它。packages/。放错的后果不是报错,而是后面所有人都困惑:这个包怎么启动?为什么它没有 start 脚本?它到底是库还是服务?trpc.engine.quote.useQuery(...)。它只知道有个叫 engine.quote 的接口,不知道后端怎么实现。/trpc,请求头带上 authorization: Bearer <JWT>。关键:它怎么知道接口长什么样?—— 类型是从 server 直接 import 过来的。/trpc/* 交给 tRPC 11 的路由,JWT 校验通过后,调用对应的 router 函数。router 内部去调 engineService。@delimao/db,用 @libsql/client 连库。db 只负责「存取」,不含任何业务判断。engine 里 import 了 server 的东西(哪怕只是 import 一个类型或一个工具函数),立刻就变成循环依赖(engine → server → engine)。而 server 依赖 engine 是天经地义的。一旦成环,engine 就跑不起来了——tsx test/run.ts 会直接读到 undefined。npm run engine:test 一条命令,不起数据库、不起服务器,6 个真实型号的解析 / BOM / 玻璃图 / 报价断言几秒钟跑完。这就是纯包的变现方式。@delimao/xxx 这种写法怎么解析的你代码里写 import { workshopBOM } from '@delimao/engine',Node 凭什么知道去磁盘上哪个文件?靠三样东西配合:
"workspaces": ["packages/*", "apps/*"]。告诉 npm:这两个通配符下面的目录,都算我的一部分。没有这一行,npm 根本不去看它们。
packages/engine/package.json 里 "name": "@delimao/engine"。这个 name 就是包在代码里的身份证。所以包名和目录名不一致完全没问题——目录叫 engine,名字叫 @delimao/engine,靠 name 字段对上。
exports 字段声明「从哪个入口进」engine 里写的是 main: ./dist/index.js + exports 只开了 "." 一个入口,types 指向 ./dist/index.d.ts。所以 @delimao/engine 只能从根进,不能写 @delimao/engine/quote 直接掏内部文件。
"*" 而不是具体版本号// apps/server/package.json
"dependencies": {
"@delimao/db": "*",← 用的是本地的 db 包
"@delimao/engine": "*", ← 用的是本地的 engine 包
"express": "^4.21.2", ← 这才是从网上装的
"@trpc/server": "^11.0.0"
}
"*" 的意思是「任意版本都行,我要的就是我本地这个」。因为这些包不会发布到 npm(private: true),npm 装依赖时看到 @delimao/db: "*",会直接在本仓库里找到这个包,做个软链(快捷方式)指过去——而不是去网上下载。
packages/db/src/schema.ts 保存,apps/server 里立刻就能看到最新代码,不用 npm install、不用重新发布、不用重启任何东西。而 express、@trpc/server 这些第三方包必须写具体版本范围(^4.21.2),因为它们要从网上装。@delimao/*(自家包)→ 写 "*";第三方包 → 写 ^版本号。-w 参数是什么-w @delimao/engine 就是「在 engine 这个包里执行命令」。它等价于你先 cd 进那个包目录,再执行,只是不用真的切目录。
"scripts": {
"dev:server": "npm run dev -w @delimao/server",
"engine:test": "npm run test -w @delimao/engine",
"db:migrate": "npm run db:migrate -w @delimao/db"
}
所以下面这两条命令做的事完全一样:
# 写法一:从根目录用 -w(推荐,你项目就是这么写的) npm run dev:server -w @delimao/server # 内部实际发生:去 apps/server/ 里执行它的 dev 脚本 = tsx watch src/index.ts # 写法二:手动切目录 cd apps/server && npm run dev
npm run dev:server 这一条,在根目录敲就行。命令名和实际包名解耦了。以后包改名、目录调整,根脚本改一行,你敲的命令不用变。-w 后面跟的是包的 name(@delimao/server),不是目录名。写错了会报 No workspaces found。这个就是「包名和目录名不一致」最容易踩的地方。这 8 条全部来自你项目真实的根 package.json,不是编的:
| 命令(在根目录敲) | 它实际做了什么 | 什么时候用 |
|---|---|---|
npm run build | 按顺序构建三个包:engine → db → server | 要部署到服务器时。顺序有意义:engine 是 server 的依赖,必须先出 dist;db 是 server 的依赖,同理 |
npm run dev:server | → apps/server 的 dev = tsx watch src/index.ts | 起后端。watch = 改文件自动重启,不用手动重启 |
npm run dev:web | → apps/web 的 dev = vite | 起前端开发服务器(热更新) |
npm run engine:test | → packages/engine 的 test = tsx test/run.ts | 改引擎后必跑。6 个真实型号跑一遍解析/BOM/玻璃图/报价断言,不依赖数据库和服务器,几秒钟出结果 |
npm run db:generate | → packages/db 的 db:generate = drizzle-kit generate | 改了 schema.ts 之后,根据表结构生成 SQL 迁移文件。这一步不碰数据库,只生成文件 |
npm run db:migrate | → packages/db 的 db:migrate = tsx src/migrate.ts | 把上一步生成的迁移真正应用到数据库。generate 和 migrate 是两步,别混 |
npm run db:seed | → packages/db 的 db:seed = tsx src/seed.ts | 灌初始数据:SKU 主数据、系列价格、真实型号。20+ 张表的主数据靠它。⚠️ 会覆盖已有数据,别对生产随手跑 |
npm run typecheck | npm run typecheck --workspaces --if-present,对所有有 typecheck 脚本的包各跑一次 | 提交前必跑。--if-present 的意思是「没有这个脚本就跳过」,所以 web 那种用 tsc -b 的包也不会报错 |
db:generate → db:migrate;改业务逻辑 engine:test 验证 → dev:server 起服务;要上线才 build。packages/ 里的东西没有独立启动方式。把 server 放进去,等于宣布「这个库不能启动」——虽然它其实能。判断方法很简单:能不能敲一条命令把它跑起来对外服务?能→ apps,不能 → packages。
这是你项目刻意避开的雷。packages/engine/package.json 里一个 dependencies 都没有,只有 devDependencies(typescript、tsx)。这不是省事,是自检:只要 engine 里 import 了 express 或 drizzle,node:assert 之外的东西就会被拉进来,engine:test 立刻变慢甚至跑不起来。你的纯包优势是靠「不 import」守住的。
A 依赖 B,B 又依赖 A。运行时表现为其中一个是 undefined,而且报错位置经常离真正的原因很远。你项目里 server → engine 是对的,engine → server 绝对不行。发现方法:npx madge --circular apps/server/src。
你这里 packages/engine 目录 ↔ @delimao/engine 包名,apps/web 目录 ↔ @delimao/web 包名。npm 认的是 package.json 里的 name,不是目录名。所以 -w 后面必须写包名(-w @delimao/engine),不能写目录名(-w packages/engine)。
npm workspaces 要在根目录装。你在 apps/web/ 里敲 npm install,装出来的node_modules 结构和根目录装的不一样,子包里就可能报「Cannot find module 'vite'」这类莫名其妙的错。正确做法:永远回根目录装、永远用根脚本跑。
workspaces: ["packages/*", "apps/*"]apps/ 和 packages/workspaces + name + exports 三者怎么配合解析 @delimao/engine"*"、第三方包写 ^版本号,并知道为什么-w 后面跟的是包名不是目录名db:generate(生成文件)和 db:migrate(写进库)我用的是 npm workspaces,不是 pnpm 也不是 turborepo——我判断我的规模(四五个包、一个人)不需要上更重的工具,npm 自带的 workspaces 就够了,多加一层反而增加学习成本。
根 package.json 一行 "workspaces": ["packages/*", "apps/*"],下面四个包:apps/server(Express 4 + tRPC 11 + JWT)、apps/web(React 19 + tRPC client + Tailwind 4)、packages/db(drizzle-orm 0.38 + libsql client,管 migrate 和 seed)、packages/engine(纯 TypeScript 的业务引擎:报价、BOM、玻璃图、主数据)。
apps 和 packages 的划分标准是「有没有独立启动方式」:server 和 web 能敲 dev 起来对外服务,所以是 apps;db 和 engine 没有独立启动方式、只能被 import,所以是 packages。这不是命名习惯,是判断标准。
最值钱的设计是 engine 做成纯 TypeScript、一个框架依赖都没有。代价是它什么都不能干;收益是它能单测——npm run engine:test 不起数据库、不起服务,6 个真实型号的解析、BOM、玻璃图、报价断言几秒跑完。改引擎敢立刻跑回归,全靠这一点。代价我也认:engine 绝不能 import server,否则成环,测试直接崩。
编排靠根脚本 + -w:命令全在根 package.json,-w @delimao/engine 就是「在这个包里执行」,等价于 cd 进去再执行。好处是命令名和包名解耦,我不用记包在哪个目录,敲 npm run db:migrate 就行。
「一起改不打架」靠三件事:①所有包在同一个提交里改完,不存在「前端用了新版、后端还是旧版」;②自家包依赖写 "*",走本地软链,不需要发布中间步骤,改完立刻生效;③类型即契约——后端 AppRouter 类型导出给前端,前端 createTRPCReact<AppRouter>() 直接推导,后端改接口参数,前端立刻编译报错,不用等到运行时才发现。
红线我也说清楚:npm install 永远在根目录跑,不在子包里装;-w 后面写包名不写目录名;用 madge --circular 定期查循环依赖。