手把手带你玩转 Monorepo,拥抱现代前端开发新范式

grtsinry43
7/21/2025(更新于 7/21/2025
5 views
预计阅读时长 55 分钟

AI Summary

Powered By DeepSeek-R1
|

你是否曾被这些问题困扰?

  • 管理多个相互关联的 Git 仓库(或是复杂的 git submodules),心力交瘁。
  • 想在项目 A 中复用项目 B 的组件或工具函数,只能复制粘贴或者发布成 npm 包,流程繁琐。
  • 多个项目依赖同一个库,版本不一致导致“依赖地狱”。
  • 每次进行一个跨项目的需求,需要在多个仓库中提交代码、创建 PR,联调测试苦不堪言。

如果你对以上场景感同身受,无论是组件库,框架,或是日常的大型项目,不妨试试 Monorepo。

到底什么是 Monorepo?

想必大家听过这个概念无数次了

Monorepo(Monolithic Repository),直译为“单体仓库”,是一种将多个独立的项目、包(package)或应用(app)存放在同一个代码仓库中进行管理的代码组织策略。

与之相对的是 Polyrepo(Multiple Repositories),也就是我们传统的多仓库管理模式,每个项目都有自己独立的 Git 仓库。

对比项Monorepo(单体仓库)Polyrepo(多仓库)
代码组织所有项目在一个仓库中每个项目一个独立仓库
依赖管理根目录统一管理,易于保持版本一致各项目独立管理,易产生版本冲突
代码复用极为方便,通过工作区(workspace)直接引用需发布 npm 包或 Git Submodule,流程复杂
原子提交一次提交可跨越多个项目,保证原子性无法实现跨项目的原子提交
构建与部署工具链复杂,但可实现统一构建和按需部署各项目独立,简单直接
协作团队成员可见所有代码,便于协作和代码审查边界清晰,便于权限管理

一个常见的误解:Monorepo ≠ 单体应用(Monolith)

  • Monorepo 是一种 代码组织方式。仓库里可以包含多个独立的、可独立部署的应用。
  • 单体应用 是一种 软件架构模式。它指的是将所有功能模块打包成一个单一的、不可分割的部署单元。

例如在 Monorepo 中,我们可以同时管理一个 React 主应用、一个 Vue 管理后台、一个共享的 UI 组件库和一个通用的工具函数库。它们虽然在同一个仓库,但架构上是解耦的,可以独立开发、测试和部署。

为什么选择 Monorepo?

优势

  1. 极致的代码复用与共享:这是 Monorepo 最核心的优势。UI 组件库、工具函数、TS 类型定义等可以作为本地包,被仓库内的任何应用直接引用,无需发布到 npm。修改后立即生效,开发体验如丝般顺滑。
  2. 简化的依赖管理:所有项目共享同一个 node_modules(或其变体),借助 pnpm 等工具可以有效解决依赖版本冲突问题,保证环境一致性。
  3. 原子化的提交(Atomic Commits):当一个功能需要同时修改前端应用和其依赖的组件库时,可以在一次提交中完成所有更改。这让代码历史追溯和回滚变得异常清晰。
  4. 统一的工具链与标准化:可以在仓库根目录配置一次 ESLint, Prettier, TypeScript, Jest 等,所有子项目共同遵守,确保了代码风格和质量的统一。
  5. 提升团队协作:代码透明度高,便于团队成员进行跨项目的 Code Review 和知识共享。

挑战

  1. 工具链复杂度:需要引入 Lerna, Nx, Turborepo 等专门的工具来管理工作区、任务调度和构建缓存,有一定的学习成本。
  2. 性能问题:当仓库变得非常巨大时,git clone, git status 等命令可能会变慢。不过现代工具正在努力解决这个问题。
  3. 权限控制:默认情况下,所有人都拥有所有代码的访问权限。对于需要精细化权限控制的团队,需要借助如 GitLab/GitHub CODEOWNERS 等功能。

总的来说,对于需要高度协作、代码共享频繁的前端团队,Monorepo 带来的收益远大于其挑战。

选择合适的 Monorepo 工具

工欲善其事,必先利其器。现代 Monorepo 生态已经非常成熟,以下是几个主流工具:

  • 包管理器(必须)
  • pnpm: 目前 Monorepo 的首选。它通过符号链接(symlinks)和内容寻址存储来高效管理 node_modules,天生支持 workspace(工作区)协议,完美契合 Monorepo 场景。
  • npm (v7+) / yarn (v2+):也都支持 workspace,但 pnpm 在性能和磁盘空间占用上更具优势。
  • 任务编排与构建系统(强烈推荐)
  • Turborepo: 由 Vercel(Next.js 的母公司)出品,主打“高性能构建系统”。它通过智能任务调度和远程缓存,可以极大地提升 CI/CD 和本地开发的速度。简单、快速、易于上手,是目前的热门选择。
  • Nx: 功能极其强大且全面的 Monorepo 工具集。除了 Turborepo 的功能外,还提供了代码生成、依赖图可视化、插件生态等企业级功能,但配置也相对复杂。

这篇文章将教你从基础 pnpm workspaces,到引入 truborepo 加速构建,再使用自建缓存代替 Vercel Remote Cache。

熟悉使用

下面是 pnpm 在 Monorepo 中常用的基本操作:

初始化 Monorepo

首先,你需要一个项目根目录。在根目录下创建 pnpm-workspace.yaml 文件,这是 pnpm 识别 Monorepo 的关键。

bash
1# 在项目根目录
2mkdir my-monorepo
3cd my-monorepo
4
5# 创建 pnpm-workspace.yaml
6touch pnpm-workspace.yaml

pnpm-workspace.yaml 文件定义了你的工作区(workspace)包含哪些子包。

pnpm-workspace.yaml 示例:

yaml
1packages:
2  # 匹配 packages/ 目录下的所有子文件夹作为包
3  - 'packages/*'
4  # 匹配 apps/ 目录下的所有子文件夹作为包
5  - 'apps/*'
6  # 如果你的包在根目录下,也可以直接指定
7  # - 'foo'

创建子包(Packages)

pnpm-workspace.yaml 中定义的路径下创建你的子包。例如,如果你设置了 packages/*,那么可以在 packages 目录下创建 package-apackage-b

bash
1mkdir packages
2mkdir packages/package-a
3mkdir packages/package-b
4
5# 在每个子包中初始化 package.json
6cd packages/package-a
7pnpm init -y
8cd ../package-b
9pnpm init -y
10cd ../.. # 回到 Monorepo 根目录

安装依赖

在 Monorepo 根目录运行 pnpm install 会安装所有子包的依赖,并且 pnpm 会自动识别并符号链接(symlink)工作区内的互相依赖。

bash
1# 在 Monorepo 根目录
2pnpm install

添加/移除依赖

添加通用依赖(安装到所有子包)

如果你想在所有子包中添加相同的依赖,可以使用 -w--workspace-root 参数在根目录操作,但通常这不常用。更常见的是给特定子包添加依赖。

bash
1# 在 Monorepo 根目录安装依赖到根 package.json (通常用于工具,如eslint, prettier等)
2pnpm add <dependency-name> -w

添加特定子包依赖

进入子包目录,像普通项目一样添加依赖。pnpm 会智能地处理依赖关系。

bash
1# 例如,给 package-a 添加 react 依赖
2cd packages/package-a
3pnpm add react
4
5# 给 package-b 添加 lodash 依赖
6cd ../package-b
7pnpm add lodash

添加工作区内部依赖

当一个子包需要依赖 Monorepo 内的另一个子包时,可以直接使用子包的名称(即 package.json 中的 name 字段)作为依赖。

假设 package-aname@my-monorepo/package-apackage-bname@my-monorepo/package-b

bash
1# 在 package-b 中添加对 package-a 的依赖
2cd packages/package-b
3pnpm add @my-monorepo/package-a
4
5# 这会在 package-b 的 package.json 中添加 `"@my-monorepo/package-a": "workspace:^1.0.0"` 这样的依赖
6# "workspace:" 协议告诉 pnpm 这是一个工作区内部的依赖

提示: 使用 workspace:*workspace:^ 可以更好地管理内部依赖的版本。pnpm 默认会使用 workspace:^

移除依赖

与添加依赖类似,进入子包目录或在根目录使用 -w

bash
1# 在 package-a 中移除 react
2cd packages/package-a
3pnpm remove react

运行脚本

在 Monorepo 中,你可以从根目录运行特定子包的脚本,也可以运行所有子包的通用脚本。

运行特定子包的脚本

使用 -F--filter 参数指定要运行脚本的子包。

bash
1# 运行 package-a 的 build 脚本
2pnpm --filter package-a build
3
4# 运行多个子包的 build 脚本
5pnpm --filter package-a --filter package-b build
6
7# 使用通配符运行符合条件的包的脚本
8pnpm --filter 'packages/*' build

运行所有子包的脚本

pnpm -rpnpm recursive 命令可以在所有工作区包中运行指定的脚本。

bash
1# 运行所有子包的 test 脚本
2pnpm -r test

发布子包

发布子包时,你需要进入相应的子包目录进行操作。

bash
1# 进入要发布的子包目录
2cd packages/package-a
3
4# 发布(请确保在发布前登录 npm)
5pnpm publish

7. 一些有用的 pnpm 命令

  • pnpm ls -r:列出所有工作区包及其依赖。
  • pnpm outdated -r:检查所有工作区包的过时依赖。
  • pnpm up -r:更新所有工作区包的依赖。
  • pnpm store prune:清理本地 pnpm 存储,删除未引用的包。

实战:从零搭建一个前端 Monorepo

好吧,光说不做没有任何作用,讲这些东西没啥意思,csdn 分分钟给我抄走,ai 几秒钟就能生成,咱们实践才能出真知,Let's get our hands dirty

我们的目标是建一个 Vue3 组件库,包含 storybook 文档站 单测 cypress 端测。

核心技术栈选择:

  • Vue 3: 利用 Composition API 和更好的性能。
  • TypeScript: 为组件库提供类型安全和更好的开发体验。
  • Vite: 用于组件库的构建和 Storybook 的开发服务器,速度快。
  • pnpm / yarn / npm (with workspaces): 推荐 pnpm 或 yarn workspaces 来管理 monorepo。这里以 pnpm 为例,因为它对 monorepo 支持良好且高效。
  • Storybook: 用于组件的交互式开发、文档和展示。
  • Vitest: 用于单元/组件测试,与 Vite 集成良好。
  • Vue Test Utils: Vue 官方的组件测试库。
  • Cypress: 用于端到端 (E2E) 测试。
  • ESLint & Prettier: 代码规范和格式化。
  • Husky & lint-staged: Git 钩子,在提交前自动检查和格式化代码。
  • Turborepo: 一个优秀的高性能构建系统,用于 JavaScript/TypeScript monorepos。
bash
1❯ tree -I node_modules -L 3 .
2.
3├── apps
4│   ├── docs
5│   │   ├── api-examples.md
6│   │   ├── components
7│   │   ├── guide
8│   │   ├── index.md
9│   │   ├── markdown-examples.md
10│   │   ├── package.json

环境准备与项目初始化

我们这里就用我自己刚刚开始写的项目 amore-ui 为例。

确保你已经安装了 Node.js

(我建议还是最新 lts v22 吧)。然后全局安装 pnpm:

bash
1npm install -g pnpm

现在,创建我们的项目:

初始化项目和 Monorepo (使用 pnpm):

bash
1mkdir amore-ui
2cd amore-ui
3pnpm init # 创建根 package.json
4touch pnpm-workspace.yaml

编辑 pnpm-workspace.yaml:

yaml
1packages:
2  - 'packages/*'
3  - 'apps/*' # 单独的应用,如 Storybook 或文档站
4  - 'cypress' # 把 cypress 也看作一个包,方便测试

在根目录安装通用开发依赖:

bash
1pnpm add -Dw typescript eslint prettier eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin husky lint-staged vue # -Dw 表示安装到根目录的 devDependencies

创建主组件库

先从我们的组件库开始!

创建组件库包 (packages/components):

bash
1mkdir -p packages/components/src/components
2cd packages/components
3pnpm init

安装组件库特定依赖:

bash
1# -F 或 --filter 指定在哪个包内执行命令
2pnpm -F components add vue
3pnpm -F components add -D vite @vitejs/plugin-vue vite-plugin-dts typescript sass # sass 是可选的

配置 packages/components/vite.config.ts (用于库构建):

typescript
1import { defineConfig } from 'vite';
2import vue from '@vitejs/plugin-vue';
3import dts from 'vite-plugin-dts';
4import path from 'path';
5import Components from 'unplugin-vue-components/vite';
6
7export default defineConfig({
8  plugins: [
9    vue({
10      template: {

配置 packages/components/package.json:

json
1{
2  "name": "amore-ui",
3  "version": "0.0.5",
4  "keywords": [
5    "vue",
6    "vue3",
7    "components",
8    "ui library",
9    "typescript",
10    "vite"

创建 packages/components/src/index.ts:

typescript
1// 例如:导出 Button 组件
2export { default as MyButton } from './components/Button/Button.vue';
3// 如果 Button.vue 有自己的 index.ts (推荐)
4// export * from './components/Button';
5
6// 如果你有全局样式,可以在这里导入,并在 vite.config.ts 中配置提取
7// import './styles/main.scss';

创建示例组件 packages/components/src/components/Button/Button.vue:

vue
1<template>
2  <button class="my-button" :type="type" @click="$emit('click', $event)">
3	<slot></slot>
4  </button>
5</template>
6
7<script setup lang="ts">
8defineProps({
9  type: {
10	type: String as () => 'button' | 'submit' | 'reset',

创建 Storybook 应用

Storybook 是组件库开发使用的利器!

作为应用,我们将其放置在 app/ 目录,仅需在你建好的文件夹中执行

pnpm create storybook@latest

之后修改 .storybook/main.js

javascript
1import path from 'path';
2import { fileURLToPath } from 'url';
3import { mergeConfig } from 'vite';
4import { createRequire } from 'module';
5
6const require = createRequire(import.meta.url);
7
8// 获取当前 main.js 文件的目录路径
9const __filename = fileURLToPath(import.meta.url);
10const __dirname = path.dirname(__filename); // 这是 .storybook 目录

创建组件的 Story (packages/components/src/components/Button/Button.stories.ts):

typescript
1    import type { Meta, StoryObj } from '@storybook/vue3';
2    import MyButton from './Button.vue'; // 直接引用组件
3    // 或者 import { MyButton } from 'amore-ui'; // 如果配置了 alias
4
5    const meta: Meta<typeof MyButton> = {
6      title: 'Components/MyButton', // Storybook 中的路径
7      component: MyButton,
8      tags: ['autodocs'], // 开启自动文档
9      argTypes: {
10        // onClick: { action: 'clicked' }, // 如果需要手动配置事件监听

修改根 package.json 的 scripts:

json
1    {
2      // ...
3      "scripts": {
4        "dev:storybook": "storybook dev -p 6006",
5        "build:storybook": "storybook build",
6        "build:components": "pnpm --filter amore-ui build", // 根据你的包名调整
7        // ...其他脚本
8      }
9    }
Info

上文的注释提到,这里详细解释一下,-f,即 filter,意为过滤器,也就是在对应的仓库中执行,-f 之后跟随的仓库名就是你 package.json 中为每个模块配置的名字,利用这种功能,我们可以为主仓库添加很多 模块:命令 的快捷命令

现在可以运行 pnpm dev:storybook 来启动 Storybook。

设置 Vitest (单元/组件测试):

好的项目通常都有高的单元测试覆盖率

packages/components 包中安装 Vitest 和 Vue Test Utils:

bash
1    pnpm -F amore-ui add -D vitest @vue/test-utils happy-dom # happy-dom 或 jsdom 用于模拟 DOM

配置 packages/components/vite.config.ts (添加 test 配置):
(在现有 defineConfig 内添加 test 字段)

typescript
1    // ... (imports 和其他配置)
2    export default defineConfig({
3      // ... plugins, build, resolve ...
4      test: { // Vitest 配置
5        globals: true,
6        environment: 'happy-dom', // 或 'jsdom'
7        setupFiles: ['./vitest.setup.ts'], // 可选的 setup 文件
8      },
9    });

创建 packages/components/vitest.setup.ts (可选):

typescript
1    // import { config } from '@vue/test-utils';
2    // config.global.plugins = [/* ... */]; // 全局插件或配置

packages/components/package.json 添加测试脚本:

json
1    {
2      "scripts": {
3        // ...
4        "test": "vitest",
5        "test:ui": "vitest --ui", // 带 UI 的测试
6        "coverage": "vitest run --coverage"
7      }
8    }

写一个测试 (packages/components/src/components/Button/Button.test.ts):

typescript
1    import { describe, it, expect, vi } from 'vitest';
2    import { mount } from '@vue/test-utils';
3    import MyButton from './Button.vue';
4
5    describe('MyButton.vue', () => {
6      it('renders slot content', () => {
7        const wrapper = mount(MyButton, {
8          slots: {
9            default: 'Test Button',
10          },

运行 pnpm -F amore-ui test

设置 Cypress (E2E 测试):

趁热打铁,让我们继续!接下来是端到端测试

bash
1    # 在项目根目录
2    mkdir cypress
3    cd cypress
4    pnpm init # 创建 cypress/package.json
5    cd ..
6
7    # 安装 Cypress
8    pnpm -F cypress add -D cypress
9    # (这里把 cypress 目录看作一个独立的包)

配置 cypress/cypress.config.ts:

typescript
1    import { defineConfig } from 'cypress';
2
3    export default defineConfig({
4      e2e: {
5        baseUrl: 'http://localhost:6006', // Storybook 的地址
6        specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
7        supportFile: 'cypress/support/e2e.ts',
8        setupNodeEvents(on, config) {
9          // implement node event listeners here
10        },

在根 package.json 添加 Cypress 脚本:

json
1    {
2      "scripts": {
3        // ...
4        "cy:open": "cypress open",
5        "cy:run": "cypress run",
6        "test:e2e": "pnpm dev:storybook & pnpm cy:run --headed; pkill -f storybook", // 简单示例,实际CI中需要更健壮的启动和停止
7        "test:e2e:ci": "pnpm build:storybook && start-server-and-test dev:storybook-static http-get://localhost:6006 cy:run" // 需安装 start-server-and-test
8      }
9    }
10
  • start-server-and-test 是一个有用的 npm 包,可以帮你启动服务器,等待它响应,然后运行测试,最后关闭服务器。pnpm add -Dw start-server-and-test
  • dev:storybook-static 脚本可以是你构建 Storybook 后用 http-server 或类似工具启动静态文件的命令,例如: pnpm build:storybook && http-server storybook-static -p 6006

创建 E2E 测试 (cypress/e2e/button.cy.ts):

typescript
1    describe('MyButton in Storybook', () => {
2      beforeEach(() => {
3        // 访问 Button 在 Storybook 中的 Primary story
4        // URL 结构可能是 /iframe.html?id = components-mybutton--primary&viewMode = story
5        // 请根据你的 Storybook URL 调整
6        cy.visit('/iframe.html?id=components-mybutton--primary&viewMode=story');
7      });
8
9      it('should display the button with correct text', () => {
10        cy.get('.my-button').should('be.visible').and('contain.text', 'Click Me');

确保 Storybook 在 http://localhost:6006 运行,然后执行 pnpm cy:open

Okok,到这里大家可能都看累了或者是感觉无聊,我们来小小整理下我们有了什么命令呢:

json
1"build:components": "pnpm --filter amore-ui build",
2    "dev:storybook": "pnpm --filter storybook dev",
3    "build:storybook": "pnpm --filter storybook build",
4    "cy:open": "pnpm --filter cypress cy:open",
5    "cy:run": "pnpm --filter cypress cy:run",
6    "test:unit": "pnpm --filter amore-ui test",
7    "test:e2e": "start-server-and-test dev:storybook http://localhost:6006 cy:run",

可以休息下,下面我们继续

创建文档站

这部分很简单,我们依然靠 Vite 来实现

就像往常一样建好你的文档站,之后...

typescript
1import { defineConfig } from 'vitepress';
2import path from 'node:path';
3import { fileURLToPath } from 'node:url';
4import Components from 'unplugin-vue-components/vite';
5
6const __filename = fileURLToPath(import.meta.url);
7const __dirname = path.dirname(__filename); // 这是 .vitepress 目录
8
9export default defineConfig({
10  title: 'My Vue Component Library', // 站点标题

我这里使用了 unplugin-vue-components,当然你也可以从工作区直接导入打包之后的产物

到此为止,这个 monorepo 已经初具形态了。

ESLint, Prettier, Husky, lint-staged:

好的代码建立在规范之上

我的项目一直都是 eslint error 模式+有 eslint/单测/e2e 测试不过就禁止 commit,也就是受虐模式

  • Husky & lint-staged:
bash
1pnpm add -Dw husky lint-staged
2npx husky init # 会创建 .husky 目录

.husky/pre-commit 内容:

bash
1npx lint-staged
2# 如果想在提交前运行所有测试 (可能会很慢)
3# pnpm test: all

在根 package.json 添加 lint-staged 配置:

json
1        {
2          // ...
3          "lint-staged": {
4            "*.{js,jsx,ts,tsx,vue}": "eslint --fix",
5            "*.{json,md,html,css,scss}": "prettier --write"
6          }
7        }

TypeScript 配置 (tsconfig.json):

太多了,贴不过来了,影响正常阅读

具体看我的仓库 amore-ui

引入 Turborepo 提升效率

目前,我们需要手动进入每个目录去运行命令。当项目变多时,这会变得很麻烦。Turborepo 可以帮我们统一管理和加速这些任务。

1. 在根目录安装 Turborepo

bash
1# -w 表示 --workspace-root,安装到根工作区
2pnpm add turbo --save-dev -w

2. 配置 turbo.json

在项目根目录创建 turbo.json 文件:

json
1// turbo.json
2{
3  "$schema": "https://turbo.build/schema.json",
4  "pipeline": {
5"build": {
6  // "build" 任务依赖于其所有依赖包的 "build" 任务
7  "dependsOn": ["^build"],
8  // 构建产物在这些目录下,用于缓存
9  "outputs": ["dist/**", ".next/**"]
10},

3. 在根 package.json 中添加脚本

json
1// package.json (根目录)
2"scripts": {
3  "dev": "turbo run dev",
4  "build": "turbo run build",
5  "lint": "turbo run lint"
6}

现在,你可以从根目录统一运行命令了!

bash
1# 同时启动所有应用的 dev 服务
2pnpm dev
3
4# 构建所有应用和包
5pnpm build

Turborepo 自建缓存

我对 Vercel 公司不喜欢也不讨厌,但是我希望自建一个缓存。

参考这个项目就好啦:

Turborepo Remote Cache

Monorepo 最佳实践

  1. 统一配置:将 ESLint, Prettier, tsconfig.json 等配置文件放在 packages 目录下(如 packages/eslint-config-custom, packages/tsconfig),然后让各个应用和包去继承这些配置,保持一致性。
  2. 明确的目录结构apps 放应用,packages 放可复用包,是一种广泛采纳的约定。
  3. 版本管理:使用如 Changesets 这样的工具来管理包的版本发布和生成 CHANGELOG,它与 Monorepo 配合得非常好。
  4. 精简根目录:保持根目录 package.jsondependencies 干净,只存放对整个项目都至关重要的开发依赖(如 turbo, typescript, prettier)。

尾声

恭喜你!你已经成功搭建并体验了一个现代化的前端 Monorepo 项目。

感觉这个例子选的不是很好啊,有点太上难度了,直接把我之前研究好久的架子全搬上来了,一般只有组件库或者大框架会这样操作了,不过基础的入门操作还是可以提升效率的🥹

从现在开始拥抱现代前端开发吧!

相关推荐

COMMENT 7352941287160549376

发表评论

来这里畅所欲言吧!
支持 Markdown 语法 0 / 3000

网站运行时间

0
0
0
0

在风雨飘摇之中

感谢陪伴与支持

愿我们不负热爱,继续前行