🧱 从“为什么要用 Monorepo”到“亲手跑通一个 Demo”:前端 Monorepo 完全入门指南

4 阅读7分钟

🧱 从“为什么要用 Monorepo”到“亲手跑通一个 Demo”:前端 Monorepo 完全入门指南

作者:一名曾对 Monorepo 充满疑惑的普通前端开发者


前言:我曾经也觉得 Monorepo 是“过度设计”

几个月前,我在 GitHub 上看到 Vue 3、Babel、Turborepo 这些明星项目都用 Monorepo,心里直犯嘀咕:

“Vue 不就是一个框架吗?干嘛非要把代码拆成十几个包,还塞进一个仓库?
我直接 import 相对路径不就行了?npm/pnpm 不是已经能管理依赖了吗?”

直到我尝试开发一个包含 两个应用 + 共享组件库 的小项目,才真正体会到:Monorepo 不是炫技,而是解决真实痛点的工程方案。

今天,我就用一篇实战文,带你从“完全不懂”到“亲手跑通”,彻底搞懂 Monorepo。


一、先说结论:Monorepo 到底解决了什么问题?

❌ 传统多仓库(Multirepo)的痛:

  • 改一行共享组件代码 → 要发版 → 升级依赖 → 测试 → 循环 5 分钟
  • 多个项目配置重复(ESLint、TS、构建脚本)
  • 跨项目调试困难,版本容易冲突

✅ Monorepo 的解法:

  • 所有相关代码放在一个仓库
  • 共享模块源码直连,修改立即生效
  • 统一工具链,一次配置,处处生效
  • 支持独立发布每个子包(可选)

💡 Monorepo 的核心价值:让“紧密耦合的模块”高效协同开发。


二、动手实验:5 分钟亲手复现“没有 Monorepo 的痛”

🎯 项目背景
假设你正在开发一个公司内部的前端系统,包含:

  • 商品管理页(web-a)
  • 用户中心页(web-b)
  • 两者都用到了同一个 共享按钮组件(SharedButton)

你希望这个按钮统一风格,所以把它抽成一个独立模块。
但问题来了:怎么让两个页面都能用它,而且改一次就能同时生效?

我们先试试“不用 Monorepo”的方式——也就是把共享组件当成一个普通 npm 包来用。

🔧 第一步:模拟“独立发布”的流程

# 1. 创建一个文件夹放共享组件
mkdir shared-button
cd shared-button

# 2. 初始化为一个 npm 包(就像你准备发布到 npm)
npm init -y

# 3. 写一个按钮组件(简化版)
echo 'export const SharedButton = () => "【旧版按钮】";' > index.js

# 4. 打包成 .tgz 文件(相当于“本地发布”)
npm pack
# 生成 shared-button-1.0.0.tgz

💡 这一步模拟的是:你把 SharedButton 当成一个正式 npm 包,先“发布”出来。


🔧 第二步:在商品页(web-a)中使用它

# 回到上一级目录
cd ..

# 创建商品页项目
mkdir web-a
cd web-a
npm init -y

# 安装刚才打包的“共享按钮”
npm install ../shared-button/shared-button-1.0.0.tgz

# 写一个简单的页面脚本
echo 'const { SharedButton } = require("shared-button"); console.log(SharedButton());' > index.js

现在运行:

node index.js
# 输出:【旧版按钮】

✅ 看起来一切正常!


🔧 第三步:修改按钮,看会发生什么?

产品说:“按钮文字要改成‘新版按钮’!”

你回到 shared-button 目录修改:

cd ../shared-button
echo 'export const SharedButton = () => "【新版按钮】";' > index.js

# 重新打包
npm pack
# 生成新的 shared-button-1.0.0.tgz

然后回到 web-a,再次运行:

cd ../web-a
node index.js
# 你猜输出什么?
# → 依然是:【旧版按钮】!

😱 为什么?
因为 node_modules/shared-button 里还是旧代码
虽然你改了源文件,但 web-a 并不知道要更新。


🔧 第四步:手动“升级依赖”(痛苦开始了)

你必须手动:

# 先卸载旧版本
npm uninstall shared-button

# 再安装新打包的版本
npm install ../shared-button/shared-button-1.0.0.tgz

# 现在再运行
node index.js
# 输出:【新版按钮】✅

每次改一行代码,都要重复这 3 步!
如果还有 web-bmobile-appadmin-panel……
你得在每个项目里都做一遍!

这就是 Multirepo 开发的真实日常 —— 效率极低,极易出错。


三、Monorepo 如何解决?理解 workspace 的真正含义

现在,我们用 Monorepo + pnpm workspaces 来解决这个问题。

🔍 什么是 workspace

你可以把 workspace 理解为:

“这个仓库里,哪些文件夹是‘合法的内部包’?”

比如你告诉 pnpm:

packages/ 下的所有文件夹,都是我们自己写的包,别去 npm 找,直接用本地源码!”

pnpm 就会:

  1. 在安装依赖时,自动扫描这些目录
  2. 如果发现某个依赖(如 @my/ui)正好在 packages/ui
  3. 就在 node_modules 里创建一个快捷方式(符号链接) ,指向 packages/ui

→ 结果:你的代码直接引用源码,修改保存,立即生效!


🔧 实操:用 workspace 实现“源码直连”

第一步:创建 Monorepo 根目录
mkdir my-monorepo
cd my-monorepo
pnpm init
第二步:告诉 pnpm 哪些是“内部包”

创建文件 pnpm-workspace.yaml(注意是 .yaml,不是 .json):

packages:
  - 'packages/*'   # 所有 packages/ 下的文件夹都是内部包
  - 'apps/*'       # apps/ 下的是应用(也可选)

✅ 这个文件就是 workspace 的“身份证” —— 没有它,pnpm 不知道你是 Monorepo!

第三步:创建共享组件包
mkdir -p packages/ui
cd packages/ui
pnpm init

编辑 package.json必须指定 name(带 scope 更规范):

{
  "name": "@my-monorepo/ui",
  "version": "1.0.0"
}

写组件:

// packages/ui/index.js
export const SharedButton = () => "【Monorepo 按钮】";
第四步:创建商品页(web-a)
cd ../..
mkdir -p apps/web-a
cd apps/web-a
pnpm init

关键一步:声明依赖时加上 workspace:*

pnpm add @my-monorepo/ui@workspace:*

这会在 package.json 中生成:

{
  "dependencies": {
    "@my-monorepo/ui": "workspace:*"
  }
}

🔑 workspace:* 的意思是:
“这个包来自本地 workspace,不要去 npm 拉,直接用源码!”

写页面脚本:

// apps/web-a/index.mjs
import { SharedButton } from '@my-monorepo/ui';
console.log(SharedButton());
第五步:在根目录安装依赖
cd ../..
pnpm install

💡 必须在根目录运行! pnpm 会读取 pnpm-workspace.yaml,建立所有链接。


🔧 第六步:见证奇迹

# 运行商品页
node apps/web-a/index.mjs
# 输出:【Monorepo 按钮】

# 修改共享组件
echo 'export const SharedButton = () => "【Monorepo 按钮 - 已更新!】";' > packages/ui/index.js

# 再次运行(无需任何操作!)
node apps/web-a/index.mjs
# 输出:【Monorepo 按钮 - 已更新!】✅

改完保存,直接生效!没有 uninstall,没有 install,没有等待!


🔍 验证:看看 pnpm 做了什么

apps/web-a 目录下运行:

ls -la node_modules/@my-monorepo/

你会看到:

ui -> ../../../../packages/ui

这就是一个 符号链接(symlink) ——
它让 node_modules/@my-monorepo/ui 指向 packages/ui
所以 Vite、Node.js 都能直接读到最新源码!


四、为什么不能直接用相对路径?

你可能会问:“我直接 import ... from '../../../packages/ui' 不行吗?”

技术上可以,但会带来隐藏成本

问题相对路径包名引用
文件移动后路径全崩导入语句不变
模块封装性侵入内部结构遵守公开接口
工具链支持需额外配置开箱即用
未来发布几乎不可能一行命令发布

@scope/name 不仅是名字,更是模块的“身份证” —— 它让工具链知道:“这是一个独立单元”。


五、Monorepo 中的包能单独发布到 npm 吗?

完全可以!而且这是 Monorepo 的一大优势。

比如你的 @my-monorepo/ui,只需:

# 1. 构建(如有需要)
pnpm run build --filter @my-monorepo/ui

# 2. 发布
cd packages/ui
npm publish --access public

之后,任何人可以:

npm install @my-monorepo/ui

🌟 对内:本地协同开发;对外:独立 npm 包。两者无缝切换。

Vue 3 的 @vue/reactivity、Babel 的 @babel/core 都是这样工作的。


六、推荐技术栈(2026 年轻量级方案)

工具作用
pnpm高效包管理器(硬链接 + 符号链接)
pnpm workspaces声明哪些目录是“内部包”
Turborepo智能任务调度(增量构建、缓存)
Vite应用构建(支持 Monorepo 开箱即用)

💡 不需要 Lerna、Rush 等重型工具,pnpm + Turborepo 足够覆盖 90% 场景


七、什么时候该用 Monorepo?

✅ 适合:

  • 多个应用共享组件/工具库
  • 框架/库的多模块开发(如 Vue、Babel)
  • 需要统一工程规范的团队项目

❌ 不适合:

  • 单一简单应用
  • 完全无关的项目(如电商 + 游戏后台)

📌 判断标准:这些项目是否需要频繁协同变更?


结语:Monorepo 不是银弹,但值得掌握

我曾经以为 Monorepo 是大厂专属,离我很远。
但当我亲手跑通第一个 Demo,看到“改一行代码,两个应用同时更新”的那一刻,才明白:

Monorepo 的本质,不是把代码塞进一个仓库,而是让协作回归简单。

如果你也在维护多个前端项目,不妨试试 Monorepo —— 它可能比你想象的更轻量、更实用。


📦 附:完整 Demo 代码结构

my-monorepo/
├── packages/
│   └── ui/                 # 共享组件库
├── apps/
│   ├── web-a/              # 应用 A
│   └── web-b/              # 应用 B
├── pnpm-workspace.yaml     # 声明工作区
├── turbo.json              # Turborepo 配置
└── package.json            # 根脚本

代码已开源,欢迎 Star & Fork:github.com/Jin82155140…


作者简介:一线前端工程师,热爱工程化与效率提升。欢迎关注我的掘金主页,获取更多实战干货!


💬 互动话题:你在项目中用过 Monorepo 吗?遇到了哪些坑?欢迎评论区交流!