深入版:速查版讲「怎么用」,这一篇讲「为什么能这么用、出错时怎么救」。@delimao/db 到底是怎么被找到的、exports 为什么会拦你的路、循环依赖怎么自己冒出来、tRPC 的类型联动到底怎么做到的——全部讲透。
你全盘唯一一个 monorepo:C:\Users\Administrator\WorkBuddy\delimao-system
德莉玛淋浴房产供销一体化系统的「路线B 生产级重构」,用 npm workspaces(不是 pnpm、不是 turbo、不是 lerna),全项目 30 个 ts/tsx 源文件(含 dist 共 40 个)。
| 包 | 作用 | 技术栈(真实版本) |
|---|---|---|
@delimao/server | 后端服务(apps/server) | Express 4(^4.21.2)+ tRPC 11 + JWT(jsonwebtoken ^9.0.2)+ zod |
@delimao/web | 前端(apps/web) | React 19 + @trpc/client 11 + Tailwind 4 + Vite 6 |
@delimao/db | 数据层(packages/db) | drizzle-orm 0.38 + @libsql/client + drizzle-kit + migrate/seed |
@delimao/engine | 纯业务引擎(packages/engine) | 纯 TypeScript,dependencies 为空:报价 / BOM / 玻璃图 / 主数据 |
这一篇的所有代码和命令都来自这个仓库的真实文件,没有编造。
一句话:它在你 node_modules 里放了一堆「快捷方式」,让所有包互相都能直接引用。这些快捷方式叫软链(symlink,符号链接)。
delimao-system/ ├─ package.json ← 声明 "workspaces": ["packages/*", "apps/*"] ├─ node_modules/ │ ├─ @delimao/← 🔑 关键目录 │ │ ├─ db-> ../packages/db ← 软链,不是真文件 │ │ ├─ engine -> ../packages/engine │ │ ├─ server -> ../apps/server │ │ └─ web -> ../apps/web │ ├─ express/ ← 第三方包是真实文件 │ ├─ drizzle-orm/ │ └─ ... ├─ packages/ │ ├─ db/ │ └─ engine/ └─ apps/ ├─ server/ └─ web/
那 4 条 -> 就是软链。@delimao/engine 不是一个真的文件夹,它是「packages/engine 的另一个名字」。
ls -la node_modules/@delimao/,会看到带 -> 的行。ls node_modules/@delimao/engine/package.json —— 能读到,说明软链通了。| 软链(symlink) | 复制(copy) | |
|---|---|---|
| 本质 | 一个指针,记住「目标在哪」 | 把内容真的拷一份过去 |
改了packages/engine/src/quote.ts | 立刻生效,因为指针指的就是原文件;apps/server 立刻看到最新代码 | 不生效,副本还是老的,必须重新 install 或手动同步 |
| 磁盘占用 | 几KB(就是一个指针) | 整个包的大小 |
| 删掉原目录会怎样 | 软链变悬空,全盘引用报错 | 副本还活着,能跑一阵子(然后你就有了两份不一致的代码) |
npm install 会报 EPERM: operation not permitted, symlink,或者悄悄退化成复制。遇到时的处理:开开发者模式,或者用管理员身份跑一次 npm install。delimao-system/package.json){
"name": "delimao-system", // 仓库自己的名字
"version": "0.1.0",
"private": true, // ⛔ 私有包:永远不会被误发布到 npm 上去
"description": "德莉玛淋浴房产供销一体化系统(路线B 生产级重构)",
"workspaces": [// 🔑 整篇的地基:告诉 npm「下面这些目录里的包都算我的」
"packages/*", // 递归匹配 packages 下所有一级子目录
"apps/*" // 同理,apps 下所有一级子目录
],
"scripts": {
"build": "npm run build -w @delimao/engine && npm run build -w @delimao/db && npm run build -w @delimao/server",
// ↑ -w 就是「在 engine 里跑 build」,&& 串行
// 顺序不可换:engine 和 db 都是 server 的依赖,必须先出 dist
"dev:server": "npm run dev -w @delimao/server", // → tsx watch src/index.ts
"dev:web": "npm run dev -w @delimao/web", // → vite
"engine:test":"npm run test -w @delimao/engine", // → tsx test/run.ts
"db:generate":"npm run db:generate -w @delimao/db",// → drizzle-kit generate
"db:migrate": "npm run db:migrate -w @delimao/db", // → tsx src/migrate.ts
"db:seed": "npm run db:seed -w @delimao/db", // → tsx src/seed.ts
"typecheck": "npm run typecheck --workspaces --if-present"
// ↑ --workspaces = 对所有包各跑一次
// --if-present = 没有这个脚本的包直接跳过而不是报错
// (web 用 tsc -b,也有 typecheck,所以四个包都会跑)
},
"devDependencies": {
"typescript": "^5.6.3" // 只在根装一份 TS,各包共用
}
}
build 里没有 web?因为 web 的构建产物是静态文件(tsc -b && vite build),它的构建链跟另外三个 tsc 包不是一回事。根build 负责「后端三件套」,前端的构建单独敲 npm run build -w @delimao/web。不是漏了,是刻意分开。packages/engine/package.json(最值得看的一个){
"name": "@delimao/engine",// 🔑 包名 ≠ 目录名。代码里 import '@delimao/engine' 认的是这一行
"version": "0.1.0",
"private": true, // 内部包,不发 npm
"type": "module",// ⭐ 声明「本包用 ESM 语法」
"main": "./dist/index.js",// ⭐ 入口:给运行时用的 JS
"types": "./dist/index.d.ts", // ⭐ 入口:给TypeScript 看的类型声明
"exports": {// ⭐ 白名单:只开了根这一个入口
".": {// "." 代表「从包的根进」
"types": "./dist/index.d.ts", // TypeScript 拿类型走这里
"import": "./dist/index.js" // 运行时 import 走这里
}
},
"scripts": {
"build": "tsc -p tsconfig.json", // 编译出 dist/
"typecheck": "tsc -p tsconfig.json --noEmit", // 只检查不产出,CI 用
"test": "tsx test/run.ts"// ⭐ 不依赖数据库、不依赖服务器
},
"devDependencies": { // ⚠️ 注意:没有 dependencies
"tsx": "^4.19.2", // 只有开发期的工具
"typescript": "^5.6.3"
}
}
import 了 express 或 drizzle,npm install 会直接报缺包,因为 package.json 里没声明。这就用工具把「engine 必须保持纯净」这条架构约定硬性 enforce 了,而不是靠口头纪律。main 和 exports 同时存在不是冗余。没有 exports 时,main 是唯一入口;一旦写了 exports,exports 就是唯一权威,main 基本只作老工具的兼容。两个都留着是最稳的做法。apps/web/package.json(前端怎么引后端类型){
"name": "@delimao/web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build", // 先类型检查,再打包
"preview": "vite preview", // 本地预览生产产物
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@delimao/engine": "*",// ⭐ 自家包:写 "*",走本地软链
"@trpc/client": "^11.0.0",// 第三方:写 ^版本,走网络
"@trpc/react-query": "^11.0.0",
"@tanstack/react-query": "^5.62.11",
"react": "^19.0.0",// ⭐ React 19
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.6.0",
"lucide-react": "^0.468.0"
},
"devDependencies": {
"@delimao/server": "*",// ⭐ 前端居然依赖后端包!
"@trpc/server": "^11.0.0", // 只为了拿 AppRouter 这个类型
"@tailwindcss/vite": "^4.0.0",// ⭐ Tailwind 4:Vite 插件形态
"tailwindcss": "^4.0.0",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.5",
"@types/react": "^19.0.2",
"typescript": "^5.6.3"
}
}
@delimao/server,很多人会以为架构乱了。其实它只 import type 一个类型(AppRouter),运行时代码里根本不需要 server。这是为了拿「接口清单」而依赖,不是为了调用后端。放 devDependencies 是精确的表达:编译期需要,运行期不需要。packages/db/package.json(数据层){
"name": "@delimao/db",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
"scripts": {
"db:generate": "drizzle-kit generate", // 只生成 SQL 迁移文件,不碰数据库
"db:push": "drizzle-kit push",// 直接把结构推到库(开发期省事)
"db:migrate": "tsx src/migrate.ts", // 执行已生成的迁移
"db:seed": "tsx src/seed.ts" // 灌主数据 / 种子数据
},
"dependencies": {
"@libsql/client": "^0.14.0",// ⭐ libSQL 客户端
"drizzle-orm": "^0.38.4", // ⭐ drizzle 0.38
"bcryptjs": "^2.4.3" // 密码哈希(seed 里建用户用)
},
"devDependencies": {
"drizzle-kit": "^0.30.2",// ⭐ generate/push 用的命令行工具
"tsx": "^4.19.2",
"typescript": "^5.6.3"
}
}
generate 和 push 的区别(容易搞混):generate = 根据 schema.ts 生成一个 SQL 文件到磁盘,人可以看、可以审、可以提交进git。生产环境该用这个。push = 跳过文件,直接改数据库结构。快,但没有记录,别人不知道你改了什么。只适合本地开发。exports 字段的坑与解法大白话:exports 是包自己画的「对外开放的目录清单」。清单上没写的路径,import 直接报错——哪怕那个文件真实存在。
你 engine 的 exports 只开了 "." 一个入口,所以这行能跑通:
import { workshopBOM } from '@delimao/engine'; // ✅ 走"." 入口
而这行会直接失败,尽管 packages/engine/src/quote.ts 明明存在:
import { quoteModel } from '@delimao/engine/quote'; // ❌ 没在 exports 里声明
报错文案长这样(你以后见到就知道是 exports 的锅):
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]:
Package subpath './quote' is not defined by "exports"
in file .../packages/engine/package.json
Subpath 说明:这里的 subpath 就是你写的那个「/quote」部分。
| 你写的路径 | exports 只开 "." 时能不能 import | 说明 |
|---|---|---|
@delimao/engine | ✅ 能 | 根入口被声明了 |
@delimao/engine/quote | ❌ 不能 | 没声明这个子路径 |
@delimao/engine/dist/index.js | ❌ 不能 | 更不该这么写:一旦绕过 exports 走深层路径,你就绑死了它现在的文件布局。以后 engine 内部重构(换目录、换文件名)会直接打断所有引用方 |
re-export 出去。你 engine 的 src/index.ts 就是这么干的:// packages/engine/src/index.ts —— 把所有内部模块统一从根导出 export * from './types.js'; export * from './parseModel.js'; export * from './masterData.js'; export * from './skuMaster.js'; export * from './workshopBOM.js'; export * from './validateBOM.js'; export * from './glassDwg.js'; export * from './quote.js'; export * from './mergeConfig.js';
所以不管 quote.ts 在 src/ 里还是在 src/lib/ 下,对外永远只有 @delimao/engine 这一个口。内部随便重构,外部零感知。
exports 里显式加,并写成「双入口」——types + import(或再加 require)。"exports": {
".": {// 根入口:类型 + 运行时
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./quote: {// ⭐ 新开的子路径
"types": "./dist/quote.d.ts", // 给 TS 看
"import": "./dist/quote.js" // 给运行时 import 用
},
"./package.json": "./package.json"// 常开的一个特例:有些工具要读包元信息
}
| 条件键 | 谁用 | 不写会怎样 |
|---|---|---|
types | TypeScript 编译器 | TS 找不到类型,报「该模块没有类型声明」,你在前端就没有自动补全和类型检查 |
import | ESM 的 import 语句(你全项目是 type: module,走这条) | 运行时找不到入口,ERR_MODULE_NOT_FOUND |
require | CommonJS 的 require | 如果哪天有工具用 require 加载你的包就会失败。你的项目是纯 ESM,可以只写 import;但如果要给外部(非 ESM)项目用,必须补 require |
"types" 要写在 "import" 前面。写反了 TS 可能匹配到 import 就停下,类型签名丢失(表现为类型变成 any 或一堆隐式 any 报错)。types 和 main 要各写一份路径?因为 main 指向 .js(运行时),types 指向 .d.ts(编译期)。编译产物里 .js 和 .d.ts 是两个不同文件,所以得指两次。忘了写 types,那 TS 就完全看不见类型——你前端就享受不到 tRPC 的类型联动。循环依赖 = A 要 B 的东西,同时 B 也要 A 的东西。大白话:A 找 B 要东西的时候,B 正在初始化、A 又回头找 A要东西——这时候谁都没准备好,你拿到的就是 undefined。
engine 里为了拿一个工具函数 import 了 server,而 server 又必须 import engine。你项目里 server → engine 是对的,engine → server 绝对不行——engine 必须保持零框架依赖。undefined,于是报 Cannot read properties of undefined,但报错位置在业务代码里,离真因十万八千里。# 找出循环依赖(不用装成依赖,直接 npx 跑) npx madge --circular apps/server/src npx madge --circular packages/db/src npx madge --circular apps/web/src # 输出示例(有问题时): ✖ Found 3 circular dependencies! 1) router.ts → engineService.ts → router.ts 2) schema.ts → client.ts → schema.ts
# 顺带看依赖图(想搞清谁依赖谁时用) npx madge --image graph apps/server/src # 生成 svg 图 npx madge --depends quote.ts packages/engine/src # 反查:谁依赖了 quote.ts
import type。类型在编译期就没了,运行时根本不会形成环——这也是你 web 那个 @delimao/server 依赖能安全的原理(它只 import type)。packages/engine 出现 @delimao/server 或 @delimao/db 的 import,立刻停下来。因为这会同时击穿三件事:engine 跑不了单测、server 和 engine 成环、engine 彻底不能复用。engine 的价值全在「纯净」两个字上。大白话:后端把「我有哪些接口、每个接口要什么参数、返回什么」写成一份 TypeScript 类型,导出给前端。前端拿到这份类型,就等于拿到了接口清单——后端一改,前端立刻编译报错。这就是「类型即契约」。
AppRouter// apps/server/src/routers/index.ts —— 汇总所有 router
import { router } from '../trpc.js';
import { healthRouter } from './health.js';
import { authRouter } from './auth.js';
import { productRouter } from './product.js';
import { engineRouter } from './engine.js';
export const appRouter = router({
health: healthRouter,
auth: authRouter,
product: productRouter,
engine: engineRouter,
});
export type AppRouter = typeof appRouter; // ⭐⭐⭐ 就这一行是全部魔法所在
typeof appRouter 的意思是「把这个 router 的类型原样抠出来」。于是 AppRouter 这个类型里,精确记录了每一个接口的名字、参数校验规则、返回类型。
// apps/web/src/trpc.ts —— 客户端,类型是从后端 import 过来的
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@delimao/server';// ⭐ 只引类型,不引实现
export const trpc = createTRPCReact(); // ⭐ 把类型灌进去
const TOKEN_KEY = 'delimao_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string | null) {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/trpc',// ⭐ 所有 tRPC 请求都走这一个地址
headers() {// ⭐ 身份凭证:JWT 走 Authorization 头
const t = getToken();
return t? { authorization: `Bearer ${t}` } : {};
},
}),
],
});
因为 createTRPCReact<AppRouter>() 拿到了 AppRouter,TypeScript 就能推导出 trpc.engine.xxx、trpc.product.xxx 每一个方法的名字和参数类型。你在页面里写 trpc.engine.quote.useQuery(...) 时,编辑器能自动补全、参数写错立刻飘红。
| 你在后端做的改动 | 前端会发生什么 | 省掉了什么 |
|---|---|---|
把 quote 接口的参数从 model: string 改成 model: string, series?: string | 前端所有调用这个接口的地方立刻飘红 | 不用等运行时报「参数缺失」 |
把某个 router 从 appRouter 里删掉 | 前端所有调trpc.那个router 的地方立刻飘红,连拼错的名字都会立刻暴露 | 不用等到线上 404才发现调了个不存在的接口 |
| 改返回结构(少返回一个字段) | 前端读那个字段的地方立刻飘红 | 不用等页面白屏才查 |
AppRouter 这个类型定义在 apps/server 里,前端从 apps/web 里 import 它——两个不同的包能共享同一个类型,编译器才知道它们是「同一份契约」。拆成两个仓库的话,前端拿不到后端的类型(也不想为了拿类型装一个 npm 包),这个联动就断了。你写 "@delimao/server": "*" 那一行,值就在这里。/trpc 地址——它是个统一网关,具体调哪个方法在请求体里说。先给结论:你的项目里,四个包全部 private: true,一个都不需要发版。所以上面那些「发版流程」对你来说不适用——这是 monorepo 内部包最省事的地方。
| 包类型 | 要不要发版 | 为什么 |
|---|---|---|
| 内部包(你的四个:engine / db / server / web) | 不发 | private: true 明确禁止发布;而且它们只在本地被别人软链引用,版本号对引用方毫无意义 |
| 第三方依赖(express / react / drizzle-orm) | 要发,但不由你发 | npm 上那些包由各自作者发布,你的 ^4.21.2 只是声明你兼容这个范围 |
按「对外可见性」分三种情况:
版本号可以永远停在 0.1.0 不动。因为没有任何外部消费者按版本号来找它。你改成什么逻辑,apps/server 软链过去立刻就是新的。改版本号反而是负担——每次改都要想「要不要升 minor」,纯浪费。
这时才需要动版本号。npm workspaces 的机制是「包还在这个仓库里,但别的包声明了 "@your/pkg": "^1.2.0"」时,npm 仍然按本地软链处理,版本号不会被真正解析——所以实践中你写 "*" 就够了,根本不用维护版本号的一致性。这也是你项目全用 "*" 的合理之处。
才需要正经的版本策略:修 bug 升 patch(1.2.0→1.2.1)、加功能升 minor(1.2.1→1.3.0)、破坏兼容升 major(1.3.0→2.0.0)。这不是本文重点,你目前不需要。
git commit 一次就完了。因为四个包在同一份提交里,server 引用的 engine 就是提交那一刻的 engine。一致性由 git 保证,不由发布流程保证——这就是 monorepo「改一次就一起变」的真实含义。build 是 engine && db && server,因为 server 依赖这两个的 dist。如果你以后加第五个包,记住它排在什么依赖层级上——没按顺序构建,就会报「找不到 ./dist/index.js」。npm workspaces 必须在根目录装。你在 apps/web/ 里单独 npm install,会在子目录里生成一个独立的 node_modules,软链结构就断了,之后 workspace:* 式的引用和 -w 全都不对。规矩:永远在根目录 install,永远用根脚本跑。出问题时第一反应就是「是不是有人在子目录里装过东西」。
常见做法是在 tsconfig.json 里写 "paths": { "@delimao/*": ["packages/*/src"] }。这在单体项目里有用,在 monorepo 里是雷。因为它让 TypeScript 去读源码目录,而 npm 软链指向的是包的根(Node 运行时只认 main/exports,指向 dist)。于是 TypeScript 以为你import 的是 src 源码,Node 实际加载的是 dist 编译产物——两套东西,可能出现「类型对得上但运行不对」的诡异 bug。
正确做法:不要在 monorepo 里用 paths 指跨包路径,老老实实靠包名 + exports 解析。
exports 是一道白名单,它可能挡住你以为是合法路径的 import,典型报错是 ERR_PACKAGE_PATH_NOT_EXPORTED。对策:永远从 @delimao/xxx 根入口 import,不要图省事写深层路径。需要在 exports 里显式加子路径时,记得 types 写在 import 前面。
*,本地对不上如果你给自家包写 "@delimao/engine": "^0.1.0",而你本地 engine 已经是 0.2.0,npm 可能真的去网上找一个 0.1.x 装下来——于是你代码里import 的不是你正在改的那份。这是最难查的一类 bug,因为「明明我改了文件怎么没效果」。自家包一律写 "*"。
node_modules/.bin 在 PATH 里你调drizzle-kit、vite、tsx 这些命令行工具,本质是执行 node_modules/.bin/ 里的脚本。npm run 会自动把这个目录加进 PATH,所以正常情况下不用管。但如果你直接敲 drizzle-kit generate(不经过 npm run),可能报 command not found。规矩:一律用 npm run xxx。
node_modules/@delimao/ 里那 4 条软链分别指向哪个目录exports 只开 "." 一个入口,且我只从根入口 importtypes 必须写在 import 前面,否则类型丢失ERR_PACKAGE_PATH_NOT_EXPORTED 这个报错是 exports 的白名单在拦我npx madge --circular apps/server/src 查循环依赖import type 能切断裂环(因为类型编译期就没了)export type AppRouter = typeof appRouter 这一行为什么能让前端拿到接口清单private: true,根本不发,一致性靠 git 提交保证我先说清机制,再说坑。npm workspaces 做的事很朴素:安装时把 packages/* 和 apps/* 下的包软链进根 node_modules,所以 node_modules/@delimao/ 里那四个是符号链接不是真实文件夹。因为是软链,改引擎源码立刻全局生效,不需要重新 install、不需要发布——这是 monorepo 全部价值的基础;软链的代价是 Windows 上要管理员权限或开发者模式,否则会 EPERM 退化成复制。
坑一:exports 字段的白名单。我engine 只在 exports 里开了根入口 ".",所以 @delimao/engine/quote 会直接报 ERR_PACKAGE_PATH_NOT_EXPORTED,哪怕那个文件真实存在。对策是所有对外只走根入口,把要暴露的东西在 index.ts 里 export * 出去,内部重构外部零感知。真要开子路径就写成 types + import 双入口,而且 types 必须写在前面,顺序反了类型会丢。
坑二:循环依赖。表现是运行时拿到 undefined,而报错位置离真因很远。发现用 npx madge --circular apps/server/src。我的红线是engine 里绝对不能 import server 或 db——一旦 engine 依赖了框架,它就既没法单测又会成环,而 engine 的全部价值就是「纯 TypeScript、dependencies 为空」。这个约定我用工具守:package.json 里没声明依赖,有人乱 import 会直接 install 报错,而不是等运行才发现。解法优先级是:抽第三包 > import type(类型编译期就没了,天然切环)> 调定义顺序。
坑三:依赖写死版本号。自家包如果写 "@delimao/engine": "^0.1.0",而本地已经是 0.2.0,npm 可能真的从网上装一个 0.1.x 下来,你改的文件和你 import 的文件不是同一个——这种 bug 最难查,因为现象是「明明改了没效果」。所以自家包一律写 "*",第三方包才写 ^版本号。
坑四:子目录里 npm install。workspaces 必须在根安装,在子包里装会生成独立的 node_modules,软链结构就断了。所以团队规矩是永远在根目录 install,永远用根脚本跑。
坑五:tsconfig 的 paths 和 workspaces 混用。用 paths 把 @delimao/* 指到 packages/*/src,TypeScript 就去读源码,而 Node 运行时按 main/exports 加载 dist——两套东西并存,会出现「类型对得上但运行不对」。monorepo 里不要用 paths 指跨包路径,老实靠包名解析。
最后说说我怎么把红利吃满:monorepo 最值得的不是省了发布,而是「类型即契约」。后端 export type AppRouter = typeof appRouter,前端 createTRPCReact<AppRouter>() 直接推导,后端改接口签名,前端立刻编译报错。这个红利只有 monorepo 才有——拆成两个仓库,前端根本拿不到后端的类型。「类型即契约」比任何文档都可靠,因为它由编译器强制执行,不会过期。
想看一句话版本、命令速查表和「为什么非要用 monorepo」,回速查版 15 · 读懂 monorepo。