前端Monorepo工程化实战
随着前端项目的规模不断扩大,如何高效管理多个相关项目成为了一个棘手的问题。特别是当你的团队同时维护着多个共享相似技术栈的应用时,可能会遇到这些困扰:重复的依赖安装、繁琐的包发布流程、不一致的工具配置等。
本文将详细介绍如何使用 Monorepo 来解决这些问题,我们会以实际项目为例,使用 pnpm 和 Turborepo 搭建一个高效的前端工程化方案。读完本文,你将了解:
1. Monorepo 是什么,以及它如何解决传统多仓库的痛点
2. 如何使用 pnpm 管理项目依赖和工作空间
3. 如何使用 Turborepo 提升构建效率
4. 实际项目中的最佳实践和注意事项什么是Monorepo
在软件开发中,Monorepo("mono"意为"单一","repo"是"存储库"的缩写)是一种策略,它将多个项目集中在一个代码仓库中进行管理。这些项目之间通常具有一定的联系,可以共享代码或依赖,以提高开发效率。
对比传统的多仓库
下图展示了 Multirepo 和 Monorepo 的在代码仓库上区别
左边为传统的Multirepo
右边为Monorepo
在传统的多仓库结构中(Multirepo),每个项目独立维护,可能会面临以下问题:
代码共享困难 为了共享代码,需要额外创建一个共享仓库并发布为包,增加了维护成本。
代码重复 各项目可能会重复实现相同功能,导致冗余代码和后期维护困难。
工具不一致 不同仓库可能使用不同的工具链(构建、部署、代码规范),增加了协作成本。
构建效率低 每个项目独立安装依赖,可能会多次重复构建相同的内容。
举个例子: 我们之前的前端仓库下,有运维平台和租户平台,技术栈相差不多,相同的依赖会重复安装到磁盘上,分别维护各自的工具配置和公共组件,不仅导致代码重复,还让统一管理和协作变得复杂。
Monorepo 的优势
现代 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 = trueWorkspace 设置
通俗的说,Workspace 就像一个大文件夹,里面分门别类放着多个小项目(应用)或共享的工具包(模块)。这些小项目和工具包之间可以相互联系,也可以独立运作。
在 JavaScript 中,主流依赖管理工具均支持 Workspace,pnpm 使用 pnpm-workspace.yaml 配置。npm 和 Yarn,使用 package.json 中的 workspaces 字段配置。
我们选择 pnpm 作为我们依赖管理工具,pnpm-workspace.yaml 的内容如下:
pnpm-workspace.yaml
- 根目录新建 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 工具不支持
monorepo 命令
- 新建
touch pnpm-workspace.yaml内部代码
# pnpm-workspace.yaml
packages:
- "packages/*"
- "apps/*"- 执行工程级命令
pnpm --workspace-root [...]或
pnpm -w [...]- 执行子包命令
进入子目录中执行或
pnpm --filter app dev:h5初始化apps项目
- 在对应的apps/admin分别执行
- 在对应的apps/tenant分别执行
npm create vite@latest初始化packages/utils
初始化packages 添加 utils文件夹
新建 utils/src/exportdate.js
/**
* 导出格式化日期的函数
* @param {Date|string} date - 需要格式化的日期,可以是Date对象或日期字符串
* @returns {string} 返回格式化后的日期字符串,格式为YYYY-MM-DD
*/
export const exportDate = (date) => {
// 将输入的日期转换为Date对象
const dateObj = new Date(date);
// 获取年份
const year = dateObj.getFullYear();
// 获取月份(注意:getMonth()返回0-11,所以需要加1)
const month = dateObj.getMonth() + 1;
// 获取日期
const day = dateObj.getDate();
// 格式化日期,确保月份和日期都是两位数
const formattedDate = `${year}-${month < 10 ? "0" + month : month}-${day < 10 ? "0" + day : day}`;
// 返回
return formattedDate;
};- utils/index.js
import { exportDate } from "./src/exportdate";
export default {
exportDate,
};- 初始化
pnpm init -y- utils/package.json 导出这个文件
{
"name": "@mycomponent/utils",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"exports": {
".": "./index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.15.1"
}添加工作区包
注意
- 当一个工作空间包被打包为归档 ( 无论是通过 pnpm pack 还是一个发布命令如 pnpm publish) 时,我们动态地 替换任何 "workspace:` 依赖为:
目标工作空间中的对应版本(如果使用 workspace:*、workspace:~ 或 workspace:^)
相关的语义化版本范围(对于任何其他范围类型)
添加包依赖
在apps/admin,在apps/tenant中的package.json中添加依赖
package.json
{
"name": "tenant",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24",
"@mycomponent/utils": "workspace:*"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}- 然后在在apps/admin,在apps/tenant中执行
pnpm install演示执行
<template>
<div>
{{ dataDate }}
</div>
</template>
<script setup>
import utils from "@mycomponent/utils";
const dataDate = utils.exportDate(new Date());
</script>
<style lang="scss" scoped></style>运行项目
npm run dev更新根目录下面 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([
{
files: ["**/*.{js,mjs,cjs,vue}"], //匹配文件
plugins: { js },
extends: ["js/recommended"], //js规范
languageOptions: { globals: { ...globals.browser, ...globals.node } }, //全局变量 window
ignores, //忽略文件
// 添加你自己定义的规则
rules: {
"no-console": "off", //禁止console
semi: "never", //强制分号 {为了演示与prettier冲突}
"vue/multi-word-component-names": "off", // 关闭命名
},
...eslintConfigPrettier,
},
pluginVue.configs["flat/essential"], //vue规范
]);