TypeScript 与 Rust 同仓:多端项目的 monorepo 组织实践

《AI 时代的微信小程序开发》里那个报名工具,当时还是 25 个页面加一个 Rust 后端。现在它长成了正经的小系统:小程序之外,SaaS 管理后台、运营后台、用户手册站陆续到位,算下来四个 TypeScript 前端、一个 Rust 后端、一份 proto 契约,外加一套集成测试。这些家伙放几个仓库?我的答案是全放一个,两个包管理器各管各的,边界划清楚就行。这篇文章把组织方式和踩过的坑摊开讲。

先看全貌

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── apps/
│ ├── saas-web/ # SaaS 管理后台(React 19 + antd 6 + Vite)
│ ├── console-web/ # 运营后台
│ ├── miniprogram/ # 微信小程序(无框架、无构建链)
│ └── user-guide/ # 用户手册站(rspress)
├── packages/shared/ # proto 生成码出口 + Connect client
├── contracts/ # proto / DDL / seeds / fixtures,唯一真相源
├── bins/backend/ # 后端 bin(axum + ConnectRPC)
├── crates/ # 共享 crate:core、build-support
├── repos/fusions/ # 基座框架(git submodule,独立 workspace)
├── tests/ # 集成测试(vitest)
├── pnpm-workspace.yaml # TS 侧 workspace
└── Cargo.toml # Rust 侧 workspace

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
2
3
4
5
6
[workspace]
resolver = "3"
members = ["bins/*", "crates/*"]
# fusions 为独立 Rust workspace(git submodule),MUST exclude——
# 否则其成员与依赖会被折进本 workspace 的解析,导致依赖错乱。
exclude = ["repos/fusions"]

不 exclude 的后果注释写得很直白:submodule 的成员和依赖被折进主 workspace 的解析,两套依赖图搅在一起。这种问题真出的时候,报错完全不指向根因,值得用一条注释钉死。

消费侧用带版本的 path 依赖:

1
2
[workspace.dependencies]
fusion-web = { version = "0.3.2", path = "repos/fusions/crates/fusion-web" }

仓内走 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
2
3
bash scripts/bump-version.sh 1.2.0             # 全量统一升版
bash scripts/bump-version.sh 1.2.1 --mp # 只升小程序域
bash scripts/bump-version.sh 1.2.1 --web --be # 域可组合

分域升版是真实需求:小程序发 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
2
3
hoistPattern:
- '*'
- '!lru-cache' # 排除提升,让别名落点唯一化

再把根 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 怎么生成三端代码。

分享到