先说个场景,你大概率遇过:公司项目从单仓库拆成多个独立仓库之后,公共代码只能靠复制粘贴,每次改个基础库的bug,得跑遍所有下游项目手动同步;反过来,如果死守单仓库,团队一多、权限一拆,又寸步难行。git子模块和package.json工作区这两个东西,分别解决的是“子仓库版本追踪”和“本地多包依赖管理”的问题,但单独用哪一个都不够顺手。把它们配合起来做多项目开发,是我在实际项目中验证过好几轮的做法,这篇文章把完整思路、操作流程和踩坑记录都写出来。
我是从一次真实的项目拆分开始接触这套组合的:当时系统里有三个前台应用、两个后台服务、一个公共SDK,六个仓库之间依赖关系复杂到爆炸。用git submodule管源码版本,用npm workspaces管本地依赖,拆完之后的体验完全不输monorepo,而且保留了多仓库的权限边界。如果你也在纠结多项目怎么组织,或者已经被子模块坑到怀疑人生,这篇文章应该能给你一套能直接落地的方案。
1. 拆仓库和聚仓库的博弈:为什么单靠一种方式都不够
1.1 git子模块的定位与局限
git submodule的本职工作,是在一个主仓库里以“提交引用”的方式挂载另一个独立仓库。它解决的核心痛点是跨仓库的版本锚定:公共SDK发布了v1.2.3,主项目能精确锁定到那个commit,而不是靠“记得上次拷贝的版本”这种原始方式。
但子模块天生有门槛和性格问题。首先是理解成本,刚接触的人会被git submodule update --init --recursive这套命令绕晕,clone不带--recursive就会得到一堆空目录。其次,它管理的是“源码引用”,不是“依赖产物”——如果公共SDK需要先构建才能被主项目使用,子模块拉下来的原始代码并不能直接被引用,你得自己去跑构建、处理产物路径。最头疼的是子模块的commit指针漂移,主仓库记录的是“子模块当时指向哪个commit”,但子模块本身是独立的git仓库,稍不注意就在主仓库里留下修改痕迹,提交时出现意想不到的dirty状态。
我之前见过一个团队用子模块管理公共组件库,结果各种“明明改了却不生效”“拉下来是旧的”“子模块更新把同事的提交冲掉”的现场。不是工具不能用,而是他们只用了子模块去干“依赖管理”的活,这超出了它擅长的范围。
1.2 package.json工作区解决的问题
npm/yarn/pnpm的workspaces(工作区)功能,解决的是另一个维度的问题:本地多个包之间的链接与依赖统一管理。
在workspaces出现之前,如果想在本地同时开发SDK和主项目,最常见的方式是npm link。它好用但脆,经常出现链接错乱、版本对不上的问题,多项目之间还得反复执行。workspaces通过根目录的package.json统一声明下面有哪些子包,安装依赖时自动把它们链接到node_modules里。开发公共SDK时,改完代码,主项目里立刻能用(需要watch或rebuild),不再需要手动link。
但workspaces的前提是“这些项目在同一个根目录下,且被同一套package.json体系管理”。这正好和git submodule形成互补:子模块解决“多仓库如何聚合到主仓库”的问题,workspaces解决“聚合之后,多个包如何共享依赖、互相引用”的问题。两个工具各有边界,组合起来的覆盖范围才完整。
1.3 组合使用的适用场景
这种组合不是银弹,但在以下场景里非常合适:
- 有多个互相依赖但需要独立发版的仓库。比如核心库、业务组件库、应用层,核心库发版节奏慢、业务组件库次之、应用层最快。
- 团队需要不同仓库拥有独立权限。比如第三方外包团队只允许访问某个子模块仓库,不能看主仓库其他业务代码。
- 本地开发时希望跨仓库实时联调。不用每次改公共库都先发布,本地就能验证。
- CI/CD仍希望按仓库分别构建。主仓库可以拉取子模块的最新代码统一打包,也可以让子模块单独构建发布。
如果你所有项目都长在一个仓库里、权限上也不敏感,那直接monorepo更省事,不需要这套组合。反过来,如果各仓库之间完全独立、没有源码级别的依赖关系,那也不需要子模块,普通npm包引入就够了。这套组合的适用区,恰恰是在“既要有仓库边界,又要有源码协同”的中间地带。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前的选型准备:环境、工作区管理器与仓库规划
2.1 环境检查
做这套方案前,先确认本机环境。git版本最好在2.20以上,早期的submodule命令行为和体验有不少差异,新版对--init、克隆策略都有优化。Node.js版本取决于你用哪个工作区管理器:
- npm:Node自带,但npm 7才开始比较稳定地支持workspaces,建议Node 16以上。
- yarn:yarn 1.x需要额外安装,支持workspaces但性能一般;yarn 2+(berry)的workspaces体验更好,但注意和Node版本的兼容。
- pnpm:对workspaces的支持最完整,但需要单独安装,且它的node_modules结构和npm不同,团队需要统一。
我在生产环境里用的是**npm 8 + git 2.30+**的组合,原因很简单:npm是新装Node自带的,不需要额外统一团队工具链。如果你团队统一装了pnpm或yarn,那继续用就行,核心逻辑一致。
2.2 工作区管理器的取舍对比
很多团队卡在“到底用npm、yarn还是pnpm”这一步,我先说结论,再展开对比。
| 管理器 | workspaces支持 | 依赖安装速度 | 磁盘占用 | node_modules结构 | 坑点 |
|---|---|---|---|---|---|
| npm 7+ | 稳定 | 中等 | 正常 | 扁平+连接 | 版本冲突处理较笨 |
| yarn 1.x | 可用 | 一般 | 正常 | 扁平 | 已停止维护,不推荐 |
| yarn berry | 完善 | 快 | 正常 | 依赖虚拟存储 | 插件机制需学习,部分工具兼容问题 |
| pnpm | 最完善 | 极快 | 严格去重 | 符号链接式 | 与某些只认扁平结构的旧工具冲突 |
安装速度其实在中小规模项目里感知差别不大,真正常影响的是依赖冲突的解决体验。npm在workspaces里没有完全做到隔离,两个子项目依赖了同一个包的不同版本,它会尝试扁平化,有时候会“帮倒忙”引起奇怪的类型冲突。pnpm用硬链接+符号链接的结构,每个包只存一份,文件级别严格隔离,能从根本上避免这类冲突。
不过有一点要提前警告:pnpm的严格node_modules结构会让很多老工具报错,比如某些旧版storybook、electron工具链、依赖“直接require一个未声明依赖的包”的项目。如果团队负责的项目都比较现代,用pnpm很爽;如果维护的是老项目,从npm迁移到pnpm会遇到不少适配工作。
2.3 仓库结构规划
在动手使用脚本前,先把仓库结构设计好,这是整个方案里最容易返工的部分。我强烈建议从一开始就按“主仓-子仓”关系建立目录规划。
我们当时的产品结构是仓库主仓(一个总的应用,包含管理后台和对外接口)、一个公共SDK仓、一个内部组件库仓,外加几个应用插件仓。规划后主仓库内部目录长这样:
code复制my-main-app/
├── .git/
├── .gitmodules
├── package.json
├── packages/
│ ├── core-sdk/ # git submodule,指向 core-sdk 仓库
│ ├── ui-components/ # git submodule,指向 ui-components 仓库
│ └── plugins/
│ ├── plugin-a/ # git submodule,独立的插件仓库
│ └── plugin-b/ # git submodule
├── apps/
│ ├── admin/ # 业务代码,主仓直接维护
│ └── server/ # 业务代码
└── node_modules/
设计原则有几条:
- 子模块统一收在一个目录下(如
packages/),不要东一个西一个,否则日后清理麻烦。 - 子模块的包名要使用npm scope,比如
@company/core-sdk、@company/ui-components,全局唯一,后面wor
