Skip to content

前端Monorepo工程化实战

随着前端项目的规模不断扩大,如何高效管理多个相关项目成为了一个棘手的问题。特别是当你的团队同时维护着多个共享相似技术栈的应用时,可能会遇到这些困扰:重复的依赖安装、繁琐的包发布流程、不一致的工具配置等。

本文将详细介绍如何使用 Monorepo 来解决这些问题,我们会以实际项目为例,使用 pnpm 和 Turborepo 搭建一个高效的前端工程化方案。读完本文,你将了解:

bash

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 功能可以有效解决这些问题:

  1. 简化依赖管理:本地包可以直接被引用,无需发布和版本更新
  2. 即时生效:对共享包的修改可以立即反映在依赖它的项目中
  3. 统一构建:确保所有项目使用相同版本的共享包
  4. 简化工作流:减少了发布、更新和重新安装的步骤

这种方法不仅提高了开发效率,还确保了项目间的一致性,是现代大型前端项目开发的推荐实践。

环境版本锁定

engines 字段用于指定项目运行所需的 Node.js 版本范围。它的主要作用是确保项目在指定的 Node.js 版本下能够正常运行,以避免因为运行环境不匹配而导致的不稳定或错误。

engines 字段的语法很简单,通常被定义在 package.json 文件的顶层,格式如下:

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

bash
engine-strict = true

Workspace 设置

通俗的说,Workspace 就像一个大文件夹,里面分门别类放着多个小项目(应用)或共享的工具包(模块)。这些小项目和工具包之间可以相互联系,也可以独立运作。

在 JavaScript 中,主流依赖管理工具均支持 Workspace,pnpm 使用 pnpm-workspace.yaml 配置。npm 和 Yarn,使用 package.json 中的 workspaces 字段配置。

我们选择 pnpm 作为我们依赖管理工具,pnpm-workspace.yaml 的内容如下:

pnpm-workspace.yaml

  • 根目录新建 pnpm-workspace.yaml 文件
bash
packages:
  - "apps/*"
  - "packages/*"

目录结构

根据上面的 Workspace 配置,我们把所用的应用和包,放置在一个仓库里,仓库结构如下:

bash
.
├── 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 命令

  • 新建
bash
touch pnpm-workspace.yaml

内部代码

bash
# pnpm-workspace.yaml

packages:
  - "packages/*"
  - "apps/*"
  • 执行工程级命令
bash
pnpm --workspace-root [...]

bash
pnpm -w [...]
  • 执行子包命令
bash
进入子目录中执行

bash
 pnpm --filter app  dev:h5

初始化apps项目

  • 在对应的apps/admin分别执行
  • 在对应的apps/tenant分别执行
bash
npm create vite@latest

初始化packages/utils

  • 初始化packages 添加 utils文件夹

  • 新建 utils/src/exportdate.js

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
js
import { exportDate } from "./src/exportdate";

export default {
  exportDate,
};
  • 初始化
bash
pnpm init -y
  • utils/package.json 导出这个文件
bash
{
  "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"
}

添加工作区包

注意

  1. 当一个工作空间包被打包为归档 ( 无论是通过 pnpm pack 还是一个发布命令如 pnpm publish) 时,我们动态地 替换任何 "workspace:` 依赖为:
  • 目标工作空间中的对应版本(如果使用 workspace:*、workspace:~ 或 workspace:^)

  • 相关的语义化版本范围(对于任何其他范围类型)

添加包依赖

  • 在apps/admin,在apps/tenant中的package.json中添加依赖

  • package.json

bash
{
  "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中执行
bash
pnpm install

演示执行

vue
<template>
  <div>
    {{ dataDate }}
  </div>
</template>

<script setup>
import utils from "@mycomponent/utils";
const dataDate = utils.exportDate(new Date());
</script>

<style lang="scss" scoped></style>

运行项目

bash
npm run dev

更新根目录下面 eslint

js
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规范
]);