目录
[什么是 Monorepo](#什么是 Monorepo)
[Monorepo 的特点](#Monorepo 的特点)
[Monorepo 的优势](#Monorepo 的优势)
[Monorepo 在 Web 开发领域中](#Monorepo 在 Web 开发领域中)
[Monorepo 的实践](#Monorepo 的实践)
[Workspace 设置](#Workspace 设置)
[pnpm monorepo](#pnpm monorepo)
随着前端项目的规模不断扩大,如何高效管理多个相关项目成为了一个棘手的问题。特别是当你的团队同时维护着多个共享相似技术栈的应用时,可能会遇到这些困扰:重复的依赖安装、繁琐的包发布流程、不一致的工具配置等。
本文将详细介绍如何使用 Monorepo 来解决这些问题,我们会以实际项目为例,使用 pnpm 和 Turborepo 搭建一个高效的前端工程化方案。读完本文,你将了解:
Monorepo是什么,以及它如何解决传统多仓库的痛点- 如何使用
pnpm管理项目依赖和工作空间- 如何使用
Turborepo提升构建效率- 实际项目中的最佳实践和注意事项
什么是 Monorepo
在软件开发中,Monorepo("mono"意为"单一","repo"是"存储库"的缩写)是一种策略,它将多个项目集中在一个代码仓库中进行管理。这些项目之间通常具有一定的联系,可以共享代码或依赖,以提高开发效率。

Monorepo 的特点
Monorepo 使用单一的代码仓库来管理多个项目:
- 项目之间可以通过本地依赖实现无缝共享。
- 提供统一的工具链,便于维护和协作。
例如谷歌管理着一个庞大的 Monorepo 库,包括大约 10 亿个文件,拥有约 3500 万次提交的历史,跨越了谷歌整个 18 年。[2016]
对比传统的多仓库(Multirepo)
下图展示了 Multirepo 和 Monorepo 的在代码仓库上区别
在传统的多仓库结构中(Multirepo),每个项目独立维护,可能会面临以下问题:
- 代码共享困难 为了共享代码,需要额外创建一个共享仓库并发布为包,增加了维护成本。
- 代码重复 各项目可能会重复实现相同功能,导致冗余代码和后期维护困难。
- 工具不一致 不同仓库可能使用不同的工具链(构建、部署、代码规范),增加了协作成本。
- 构建效率低 每个项目独立安装依赖,可能会多次重复构建相同的内容。 举个例子: 我们之前的前端仓库下,有运维平台和租户平台,技术栈相差不多,相同的依赖会重复安装到磁盘上,分别维护各自的工具配置和公共组件,不仅导致代码重复,还让统一管理和协作变得复杂。
Monorepo 的优势
使用 Monorepo,以 JavaScript 生态为例,可以解决以上问题:
- 代码共享简单 在本地直接引用共享包,无需发布到
npm即可使用。 - 统一的工具链 通过集中化管理代码规范(如
eslint、typescript等)和工具链,减少配置成本,保证一致性。 - 高效的构建流程 减少重复安装依赖,利用缓存机制加速构建。
Monorepo 在 Web 开发领域中
现代 Web 开发中通常涉及前后端,随着使用 JavaScript 全栈开发的流行,前后端代码和服务往往在同一个项目中协作。例如,React、Vue 等前端框架和 Node.js、GraphQL 等后端技术之间的交互性非常强,前后端复用类型、公共常量等等,这使得在同一个仓库中维护前后端代码变得更加高效。
Monorepo 的实践
我们在一个新项目中实施了 Monorepo,该项目包含了运维前端(admin)和租户前端(tenant),我们将和后端交互用的 Api 抽象成一个独立的包。同时,我们两侧的技术栈基本一致,将相关工具的配置公共部分抽象成包,项目各自继承扩展,最后,我们将一些常用的常量、方法、UI 组件抽象出来,单独管理。
按照我们以往多仓库(Multirepo)方式,这些包分布在各个独立的仓库里,然后将包发布到公司内部或者公开的源上项目中安装导入。这种方式就会产生我前面提到的多仓库的一个缺陷,代码共享困难,在这种模式下,更新共享包(如 @org/api)的流程通常包括以下步骤:
- 修改共享包代码
- 发布新版本
- 更新依赖该包的项目中的版本号
- 重新安装依赖
- 调试和验证
这个过程不仅繁琐,还可能影响开发效率。虽然在开发模式下可以使用软链接(symlink)来实时查看效果,但这种方法仍需手动操作,且可能引入额外的复杂性。
Monorepo 工具和现代依赖管理工具(如 pnpm、Yarn、npm)提供的 Workspace 功能可以有效解决这些问题:
- 简化依赖管理:本地包可以直接被引用,无需发布和版本更新
- 即时生效:对共享包的修改可以立即反映在依赖它的项目中
- 统一构建:确保所有项目使用相同版本的共享包
- 简化工作流:减少了发布、更新和重新安装的步骤

这种方法不仅提高了开发效率,还确保了项目间的一致性,是现代大型前端项目开发的推荐实践。
环境版本锁定
engines 字段用于指定项目运行所需的 Node.js 版本范围。它的主要作用是确保项目在指定的 Node.js 版本下能够正常运行,以避免因为运行环境不匹配而导致的不稳定或错误。
engines 字段的语法很简单,通常被定义在 package.json 文件的顶层,格式如下:
"engines": {
"node": ">=20.18.0",
"npm": ">=10.8.2",
"pnpm": ">=10.13.1"
},
在这个示例中,engines 字段指定了项目运行所需的 Node.js 版本范围为 >=20.18.0,当其他开发者尝试安装该项目时,npm 会检查当前 Node.js 版本是否符合要求,并在不符合要求时给出警告。
这么做npm并不管用,使用pnpm的时候才可以限制
原来 engines 只是建议,默认不开启严格版本校验,只会给出提示,需要手动开启严格模式。在根目录下 .npmrc 添加 engine-strict = true
engine-strict = true
Workspace 设置
通俗的说,Workspace 就像一个大文件夹,里面分门别类放着多个小项目(应用)或共享的工具包(模块)。这些小项目和工具包之间可以相互联系,也可以独立运作。
在 JavaScript 中,主流依赖管理工具均支持 Workspace,pnpm 使用 pnpm-workspace.yaml 配置。npm 和 Yarn,使用 package.json 中的 workspaces 字段配置。
我们选择 pnpm 作为我们依赖管理工具,pnpm-workspace.yaml 的内容如下:
packages:
- "apps/*"
- "packages/*"
根据上面的 Workspace 配置,我们把所用的应用和包,放置在一个仓库里,仓库结构如下:
.
├── apps
│ ├── admin
│ │ └── package.json
│ └── tenant
│ └── package.json
├── package.json
├── packages
│ ├── api
│ │ └── package.json
│ ├── eslint-config
│ │ └── package.json
│ ├── shared
│ │ └── package.json
│ ├── typescript-config
│ │ └── package.json
│ └── ui
│ └── package.json
└── pnpm-lock.yaml
- 应用(apps):独立的项目(如前端、后端应用),通常相互隔离。在我们的项目中,
admin和tenant是两个基于React的前端应用,用Vite作为打包工具 - 包(packages):共享的模块,我们项目中包括组件库(ui)、 前后端交互
Api、代码 lint 工具配置(eslint-config)等 一个包可以是另外一个包的依赖,也可以是应用的依赖,比如eslint-config包,可以被ui和api依赖,也可以被admin和tenant依赖 package.json文件:描述包的元数据,包括名称、版本号、依赖等,每个包或者应用必须包含- 不要嵌套包或者应用,后续介绍到
Monorepo工具不支持
下图展示了项目的依赖关系

pnpm monorepo
touch pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"
执行工程级命令
pnpm --workspace-root [...]
或
pnpm -w [...]
执行子包命令
进入子目录中执行
或
pnpm --filter app dev:h5
初始化项目
npx degit dcloudio/uni-preset-vue#vite app
npx degit dcloudio/uni-preset-vue#vite admin
初始化packages 添加utils
extendApi.js
/**
* @description 封装消息提示组件
* @param {*} title 提示的内容
* @param {*} icon 图标
* @param {*} duration 提示的延迟时间
* @param {*} mask 是否显示透明蒙层,防止触摸穿透
*/
// 如果用户传入对象作为参数,在形参位置通过解构赋值的方式获取用户传入的参数,同时设置默认值
export const toast = ({ title = "数据加载中", icon = "none", mask = true, duration = 5000 } = {}) => {
uni.showToast({
title,
icon,
mask,
duration
})
}
/**
* @description 封装 uni.showModal 方法
* @param {*} options 同 uni.showModal 配置项
*/
export const modal = (options = {}) => {
// 使用 Promise 处理 uni.showModal 的返回结果
return new Promise(resolve => {
// 默认的参数
const defaultOpt = {
title: "提示",
content: "您确定执行该操作吗?",
confirmColor: "#f3514f"
}
// 将传入的参数和默认的参数进行合并
const opts = Object.assign({}, defaultOpt, options)
uni.showModal({
// 将合并的参数赋值传递给 showModal 方法
...opts,
complete({ confirm, cancel }) {
// 如果用户点击了确定,通过 resolve 抛出 true
// 如果用户点击了取消,通过 resolve 抛出 false
confirm && resolve(true)
cancel && resolve(false)
}
})
})
}
pnpm init -y
{
"name": "monorepo-utils",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"exports": {
"./extendApi": "./extendApi.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": ""
}
添加工作区包
当一个工作空间包被打包为归档 ( 无论是通过
pnpm pack还是一个发布命令如pnpm publish) 时,我们动态地 替换任何 "workspace:` 依赖为:
- 目标工作空间中的对应版本(如果使用
workspace:*、workspace:~或workspace:^)- 相关的语义化版本范围(对于任何其他范围类型)
"monorepo-utils": "workspace:*",
运行项目
pnpm --filter app dev:h5
演示执行
<template>
<view class="content">
<image class="logo" @click="handleClick" src="/static/logo.png"></image>
<view class="text-area">
<text class="title">{{ title }}</text>
</view>
</view>
</template>
<script>
import { modal } from "monorepo-utils/extendApi"
export default {
data() {
return {
title: "Hello"
}
},
onLoad() {},
methods: {
handleClick() {
modal({
title: "提示",
content: "您确定执行该操作吗?"
})
}
}
}
</script>
<style>
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}
.text-area {
display: flex;
justify-content: center;
}
.title {
font-size: 36rpx;
color: #8f8f94;
}
</style>
更新eslint
import js from "@eslint/js" //js规范(标准的)
import globals from "globals" //环境
import pluginVue from "eslint-plugin-vue" //vue规范
import { defineConfig } from "eslint/config" //配置
import eslintConfigPrettier from "eslint-config-prettier" // prettier
const ignores = ["**/dist/**", "**/node_modules/**", ".*"]
export default defineConfig([
pluginVue.configs["flat/essential"], //vue规范
{
files: ["**/*.{js,mjs,cjs,vue}"], //匹配文件
plugins: { js },
extends: ["js/recommended"], //js规范
languageOptions: {
globals: {
...globals.browser,
...globals.node,
uni: true
}
}, //全局变量 window
ignores, //忽略文件
...eslintConfigPrettier,
rules: {
"vue/multi-word-component-names": "off"
}
}
])