《AI 时代的微信小程序开发》里那个报名工具,当时还是 25 个页面加一个 Rust 后端。现在它长成了正经的小系统:小程序之外,SaaS 管理后台、运营后台、用户手册站陆续到位,算下来四个 TypeScript 前端、一个 Rust 后端、一份 proto 契约,外加一套集成测试。这些家伙放几个仓库?我的答案是全放一个,两个包管理器各管各的,边界划清楚就行。这篇文章把组织方式和踩过的坑摊开讲。
先看全貌
1 | . |
pnpm 的成员是 apps/*、packages/*,加上 contracts 和 tests 两个管道包,一共 8 个 package.json;cargo 的成员是 bins/* 和 crates/*。两边目录不重叠,靠这一点井水不犯河水。
两个成员值得多说一句。tests 是集成测试包:拉起真实后端和容器数据库做全量契约测试,和被测系统同仓同版本线,测试不是二等公民。apps/miniprogram 是四个 app 里唯一没有构建链的,但照样是正式成员:package.json 承载 TypeScript 工具链和 miniprogram-ci 的 preview、release 脚本,版本号也归统一升版管,生成码直接躺在源码树里被 import。
唯一要显式划界的是 repos/fusions,下一节讲它。
基座框架挂在 submodule 里
fusions 是一套 Rust 基座,二十来个 crate:web、db、微信对接、存储、邮件、安全这些横切面各一个,这个报名工具消费其中十个。我的几个项目——《该你啦》、河图工具箱、这个报名工具——都长在它上面。多项目共用、又是私有代码,发布 crates.io 不现实,拷贝多份更是下策,所以它以 git submodule 的形式挂在 repos/fusions,独立维护、独立 workspace,各项目各自钉自己的指针。
主仓的 Cargo.toml 里有一条铁律:
1 | [workspace] |
不 exclude 的后果注释写得很直白:submodule 的成员和依赖被折进主 workspace 的解析,两套依赖图搅在一起。这种问题真出的时候,报错完全不指向根因,值得用一条注释钉死。
消费侧用带版本的 path 依赖:
1 | [workspace.dependencies] |
仓内走 path 直连,版本号同时把线钉住。升级基座就是前移 submodule 指针,diff 里一眼可见这次动了哪版。
端口家族约定
我的开发机上常年同时跑着四五个项目,端口撞车是迟早的事,所以有一套按项目分段的约定:前端 530xx,后端 59xxx,数据库 554xx,每个项目领一个十位段。报名工具领的是前端 53031~53035、后端 59141、数据库 55462,兄弟项目各领各的段。
分段的好处不只是不撞车。同项目端口连号(管理后台 53031、手册站 53033、运营后台 53034、e2e 档 53035),CORS 白名单、启动脚本、排错时看到一个端口号,段位一读就知道是哪个项目的哪条腿,有没有串台当场可见。
一条命令起全家
合仓最直接的红利在启动这里。bash scripts/dev.sh start 一条命令:数据库容器没起就自动拉起,cargo 构建后端,跑一遍 migrate,再把两个 Web 的 vite 拉起来。停下来也是一条 dev.sh stop。新机器 clone 完、依赖装完,到整套系统跑起来,中间没有任何「先去那个仓、再来这个仓」的操作。
拆仓方案里,这类环境编排脚本每个仓一份,互相不知道对方的存在,维护久了各自漂移,最后谁也不敢动。
脚本还有个 status 子命令,做五面只读检查:数据库、后端、前端、迁移链漂移、基线指纹。后端在跑的时候顺手把局域网 IP 打出来,真机联调该在微信开发者工具里填哪个地址,一目了然。
版本号只从一个门进
8 个 package.json、cargo workspace 的版本行、Cargo.lock,这三处的版本号靠手改必定漏。所以全仓有规矩:**改版本号只能走 scripts/bump-version.sh**,手改 manifest 的版本行算违规。
1 | bash scripts/bump-version.sh 1.2.0 # 全量统一升版 |
分域升版是真实需求:小程序发 hotfix 的时候,Web 端一行没动,不该跟着跳版本。域与域之间允许版本不一致。
两个实现细节值得一提。前端成员互引全走 workspace:* 协议,pnpm-lock.yaml 不记成员自身版本,升完不需要重新 install;Rust 侧用 cargo update --workspace 同步 Cargo.lock,只动成员版本行,外部依赖不重新解析。
文件枚举走 git ls-files,submodule 的文件不在主仓索引里,天然枚举不到:fusions 的「排除」不是一条规则,是结构上就碰不到。脚本收尾有一段「结果核对」,把全部 8 个 package.json 的版本打一遍,分域升版时未动的域原样列出,版本不一致当场可见,不靠人记。改写本身只认每个文件里第一个 "version": 行,它总在依赖段之前,正好是包自身的版本,依赖行里的 ^1.2.3 不会误伤。
脚本本身纯 bash,兼容 macOS 自带的 bash 3.2,一行 perl 都没有。这个仓禁用 perl,起因是 perl -i 在本机出过把源文件清零的事故;版本脚本这种「改错一行就全仓版本乱掉」的东西,少一个依赖是一个。
已知的不完美:小程序「我的」页页脚有个展示用的版本串,不在脚本改动面,升版后得手动同步。先记着,哪天烦了再治。
根上集中管的事
monorepo 的意义大半在这一节。能统一的都提到根上:
- tsconfig.base.json:strict 全开,target ES2025,四个 app 各自继承再加差异项。
- lint 与 format 一套:oxlint + oxfmt,根上一条命令递归全仓。不引 eslint 链,连带省掉一堆插件版本问题。
- Rust 的版本表与 lint 全在根 Cargo.toml:workspace.dependencies 集中管版本;clippy 的 allow 集中声明,每条写明理由,比如 result_large_err 是让上游错误类型的大尺寸不必波及业务签名。
- 工具链钉死:packageManager 字段把 pnpm 版本钉住,engines 卡 node 下限,不存在「我本地是另一个 pnpm」。
- 传递依赖也在根治理:pnpm-workspace.yaml 的 overrides 把全树 react 统一在 19.2.7(antd 6 按这条线验证过);某个传递依赖自带的 typescript 与仓内版本归一;miniprogram-ci 的传递依赖声明了 eslint 的 peer、而本仓根本没用 eslint,标记 optional 放行。依赖元数据与现实不符时,pnpm 给的是显式治理的口子,不是绕过校验。
- 门禁只在根:
pnpm check:commit一条命令串起 format 检查、lint、type-check、单测和一串静态护栏脚本(RLS 校验、部署泄密扫描这类)。
再一个小的:profile.dev 的 debug 设成 line-tables-only,调试信息体积近乎减半,backtrace 行号保得住,要看变量时用环境变量临时覆盖。这种配置放 workspace 根,一次生效全体 crate 受益。
顺手交代一条选型边界:没上 turborepo、nx 这类任务编排器。四个 app 的构建规模,pnpm -r build 的拓扑递归分钟级跑完,编排器换来的缓存收益抵不过它的配置面。真痛了再说。
AI 编程时的收益
前面各节的好处都以人为主体。但这个仓的日常开发,现在有相当比例是和 AI 编程智能体(Claude Code / ZCode 等,后文简称 AI)结对完成的,合仓对它还有一层收益,分量不轻。
上下文给得全。AI 的工作区就是一棵目录树,单仓意味着它打开工作区就看到全部:四个 app、一份契约、后端、测试同树。改一个 rpc,proto、三端生成码、Rust 实现和调用方测试都在它的检索面里,一次搜索从定义打到使用处。拆仓则是 AI 一次只能开一个仓,契约在 A 仓、消费方在 B 仓,它改 proto 时看不见使用方,漏改要等运行时才炸。这类「局部正确、全局漏改」恰好是 AI 最典型的错误形态,上下文给不全时无解。
规则可以分层装载。仓里现在有 7 份 AGENTS.md:根上一份管全仓纪律(版本号、门禁、端口约定),contracts、miniprogram、tests 这些目录各一份管各自的硬约束。AI 在哪个目录干活就加载哪层规则,规则贴着代码放,不必把全仓手册塞进一次对话。这是单仓独有的结构,拆仓后它会退化成 N 份各自漂移的手册。
门禁替它兜底。pnpm check:commit 一条命令分钟级扫全仓:format、lint、type-check、单测,加上 RLS 校验、部署泄密扫描、SQL lint 这一串静态护栏。AI 改完跑这一条,等于替它回答「有没有碰坏别处」。跨端改动连同再生成产物在同一个 commit 里过门,不存在「这个仓提了、那个仓忘了」的跨仓协调问题。
AGENTS.md 怎么分层、人和 AI 怎么照同一条路走,后面写 AI 协作的篇目专门展开。
踩坑:一个 lru-cache 引发的 preview 崩溃
这是最有教育意义的一个坑。现象:本地用 miniprogram-ci 跑 preview,崩在它内部,报 _lruCache is not a constructor。
因果链不短,结论现在留在 pnpm-workspace.yaml 的注释里:miniprogram-ci 的 summer-compiler 子进程通过 .pnpm 的别名路径加载 babel 链,不走 realpath 解析;这条路径上的裸名 lru-cache 落到了被 jsdom 依赖链抢占的 11.x,而 11.x 是 named export 形态,@babel/helper-compilation-targets 要的是 v5 那种 module.exports 构造器形态,于是崩。
解法两行:
1 | hoistPattern: |
再把根 devDependencies 里的 lru-cache 钉死 5.1.1。jsdom 链走 store 的 realpath,照常解析它的 11.x,互不影响。
顺带一个 pnpm 的阴人细节:pnpm-workspace.yaml 里的键名必须 camelCase(hoistPattern),写成 CLI 风格的 kebab-case(hoist-pattern)会被静默忽略,不报错。我怀疑这个坑每个人都得踩一次。
另外两道闸也开着:strictPeerDependencies 让 peer 缺失直接报错而不是静默猜;allowBuilds 是安装期脚本的显式白名单,谁想在 install 时执行代码都得登记。monorepo 依赖面大,这两道闸挡掉不少「装着装着多出一个东西」的意外。
小结
我的判断:这种规模的多端项目,合仓明显划算。跨端改动一个 commit 落地;门禁、版本号有单一入口;契约一改,三端的再生成被同一个动作带出来:不是「记得别漏」,是「想漏都难」。日常开发和 AI 结对之后,这几条的分量又重了一层:完整上下文和单一门禁,恰好是它最需要的两样。代价是根配置的纪律要人维持,两套 workspace 的边界要写进注释钉死。
唯一拆出去的是基座:submodule、独立 workspace、版本各自钉。多项目共用的东西和单项目的东西,归宿本来就不同。
仓怎么组织讲完了。下一篇讲仓的心脏 contracts:一份 proto 怎么生成三端代码。