FDE·技术基础
基础目录
速查版 · 一屏看懂
①请求旅程 ②读懂monorepo ③数据库存东西 ④并发锁事务 ⑤前端为什么能跑 ⑥读懂AI代码
深入版 · 原理到能排障
①请求旅程 ②monorepo ③数据库 ④并发事务 ⑤前端渲染 ⑥读懂AI代码

基础 15 · 读懂 monorepo

速查版:一屏看懂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、不依赖数据库、不依赖任何框架。这个设计决定了你能不能给它写单测、能不能把它搬到别的系统里复用。

01

一句话:monorepo 是什么

一个仓库里放多个互相依赖的包,一次提交,一起改,一起发版。

「mono」是「单一」,「repo」是「仓库」。所以 monorepo = 单体仓库:所有代码住在一个仓库里,而不是拆成四个仓库。

方式怎么组织代价
单仓库一个项目一个仓库,别的项目来「复制粘贴」代码复制出去的代码和你手里的原版再无关系,改了一边另一边不跟着改,半年后两份代码不一样
多仓库每个包一个独立仓库,靠 npm 互相发布引用改一个接口 → 改 4 处+ 发 4 次版+ 等 4 个仓库的 CI。中间任何一步没跟上,线上就是版本错乱
monorepo一个仓库,N 个包,包之间直接引用包之间的依赖要靠自己纪律约束(这就是本文要讲的重点)
核心差别:多仓库靠「发布」连接,monorepo 靠「同一份提交」连接。所以 monorepo 里的「接口变了」,是一次 commit 里同时改完的,不可能出现「前端用了新版、后端还是旧版」。
02

为什么你项目非要用 monorepo

不是为了赶时髦,是因为你的业务要求这四块必须同时改:

改动场景 1
改一个报价规则
要动 engine(算价逻辑)→ db(价格表结构/主数据)→ server(暴露 tRPC 接口)→ web(页面调用与显示)。四个包一起改,一次提交。
改动场景 2
玻璃图 / BOM 加一个字段
engine 的类型定义要加 → db 的 schema 要加 → server 的 router 要加 → web 的表单和表格要加。拆成四个仓库就是四次发版,中间任何一版没跟上,线上直接报错。
如果拆成4 个独立仓库会怎样:改一次接口 =改 4 处代码 + 发 4 次版 + 等 4 条 CI 全绿。你只有一个人,光等 CI 就要花掉大半小时,而且中间任何一个包忘了发版,前端就调不到新接口。
monorepo 给你省下的:改完 git commit 一次,四个包的代码同时变成新版本。没有「等发布」这一步。
03

packages vs apps 怎么分(很多人搞混)

你根目录的 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)
为什么 web 放 apps 而不是 packages?因为 web 是个可运行的程序(vite 一敲就出一个网页),它不是被别人 import 的库。反过来,engine放 apps 就不对了——它没法独立启动,谁也「运行」不了它。
红线:可运行的程序不要放进 packages/。放错的后果不是报错,而是后面所有人都困惑:这个包怎么启动?为什么它没有 start 脚本?它到底是库还是服务?
04

你的四包数据流:一次请求穿过哪些包

第 1 站
web(React 19 页面)
用户在页面上点「生成 BOM」。页面里调 trpc.engine.quote.useQuery(...)。它只知道有个叫 engine.quote 的接口,不知道后端怎么实现。
第 2 站
tRPC client(在 web 进程里)
把这句调用变成一个 HTTP 请求发到 /trpc,请求头带上 authorization: Bearer <JWT>。关键:它怎么知道接口长什么样?—— 类型是从 server 直接 import 过来的。
第 3 站
server(Express 4 收请求)
Express 把 /trpc/* 交给 tRPC 11 的路由,JWT 校验通过后,调用对应的 router 函数。router 内部去调 engineService。
第 4 站
db(drizzle-orm 0.38)
如果需要读价格表 / SKU 主数据,走 @delimao/db,用 @libsql/client 连库。db 只负责「存取」,不含任何业务判断。
第 5 站
engine(纯 TypeScript 算价)
真正干活的:解析型号 → 出 BOM → 校验 → 出玻璃图 → 算报价。它不认 Express、不认数据库,只认传入的数据和类型。
回程
结果原路返回
engine 返回纯数据 → service 加工 → tRPC 序列化 JSON → web 的 React Query 拿到数据 → 页面渲染。整条链上没有任何一步是「后端渲染页面」,前后端彻底分开。
为什么 engine 绝对不能依赖 server:如果 engine 里 import 了 server 的东西(哪怕只是 import 一个类型或一个工具函数),立刻就变成循环依赖(engine → server → engine)。而 server 依赖 engine 是天经地义的。一旦成环,engine 就跑不起来了——tsx test/run.ts 会直接读到 undefined。
纯 engine 的直接好处:npm run engine:test 一条命令,不起数据库、不起服务器,6 个真实型号的解析 / BOM / 玻璃图 / 报价断言几秒钟跑完。这就是纯包的变现方式。
05

@delimao/xxx 这种写法怎么解析的

你代码里写 import { workshopBOM } from '@delimao/engine',Node 凭什么知道去磁盘上哪个文件?靠三样东西配合:

1

根 package.json 声明「我有哪些包」

"workspaces": ["packages/*", "apps/*"]。告诉 npm:这两个通配符下面的目录,都算我的一部分。没有这一行,npm 根本不去看它们。

2

每个包自己的 package.json 声明「我叫什么名字」

packages/engine/package.json 里 "name": "@delimao/engine"。这个 name 就是包在代码里的身份证。所以包名和目录名不一致完全没问题——目录叫 engine,名字叫 @delimao/engine,靠 name 字段对上。

3

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/*(自家包)→ 写 "*";第三方包 → 写 ^版本号。
06

-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。这个就是「包名和目录名不一致」最容易踩的地方。
07

速查表:常用命令逐条解释

这 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 typechecknpm run typecheck --workspaces --if-present,对所有有 typecheck 脚本的包各跑一次提交前必跑。--if-present 的意思是「没有这个脚本就跳过」,所以 web 那种用 tsc -b 的包也不会报错
一条龙口诀:改表结构 db:generate → db:migrate;改业务逻辑 engine:test 验证 → dev:server 起服务;要上线才 build。
08

常见坑 / 红线

坑 1 · 把可运行的程序放进 packages

packages/ 里的东西没有独立启动方式。把 server 放进去,等于宣布「这个库不能启动」——虽然它其实能。判断方法很简单:能不能敲一条命令把它跑起来对外服务?能→ apps,不能 → packages。

坑 2 · engine 里 import 了框架,导致没法单测

这是你项目刻意避开的雷。packages/engine/package.json 里一个 dependencies 都没有,只有 devDependencies(typescript、tsx)。这不是省事,是自检:只要 engine 里 import 了 express 或 drizzle,node:assert 之外的东西就会被拉进来,engine:test 立刻变慢甚至跑不起来。你的纯包优势是靠「不 import」守住的。

坑 3 · 循环依赖

A 依赖 B,B 又依赖 A。运行时表现为其中一个是 undefined,而且报错位置经常离真正的原因很远。你项目里 server → engine 是对的,engine → server 绝对不行。发现方法:npx madge --circular apps/server/src。

坑 4 · 包名和目录名不一致,以为引用失败

你这里 packages/engine 目录 ↔ @delimao/engine 包名,apps/web 目录 ↔ @delimao/web 包名。npm 认的是 package.json 里的 name,不是目录名。所以 -w 后面必须写包名(-w @delimao/engine),不能写目录名(-w packages/engine)。

坑 5 · 只在子包里npm install,根依赖没装

npm workspaces 要在根目录装。你在 apps/web/ 里敲 npm install,装出来的node_modules 结构和根目录装的不一样,子包里就可能报「Cannot find module 'vite'」这类莫名其妙的错。正确做法:永远回根目录装、永远用根脚本跑。

✓

自查清单

  • ✓我能说出 delimao-system 是 npm workspaces,workspaces: ["packages/*", "apps/*"]
  • ✓我能说出四个包的名字和职责(server / web / db / engine)
  • ✓我能用「有没有独立启动方式」区分 apps/ 和 packages/
  • ✓我能画出 web → tRPC client → server → db → engine 的数据流
  • ✓我知道 engine 不能依赖 server,也说过纯 TS 无框架依赖换来了单测能力
  • ✓我讲得出 workspaces + name + exports 三者怎么配合解析 @delimao/engine
  • ✓我知道自家包写 "*"、第三方包写 ^版本号,并知道为什么
  • ✓我知道 -w 后面跟的是包名不是目录名
  • ✓8 条命令我都能解释,尤其分清 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 定期查循环依赖。

🔗 想看原理细节

上面是速查版,只讲「是什么、怎么用」。软链机制、exports 的坑、循环依赖怎么发现、tRPC 类型联动的原理、发版策略都在深入版:

深入版 15 · monorepo 深入 →