从入门到实践:前端 Monorepo 工程化实战(4)

目录

[什么是 Monorepo](#什么是 Monorepo)

[Monorepo 的特点](#Monorepo 的特点)

对比传统的多仓库(Multirepo)

[Monorepo 的优势](#Monorepo 的优势)

[Monorepo 在 Web 开发领域中](#Monorepo 在 Web 开发领域中)

[Monorepo 的实践](#Monorepo 的实践)

环境版本锁定

[Workspace 设置](#Workspace 设置)

[pnpm monorepo](#pnpm monorepo)


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

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

  • Monorepo 是什么,以及它如何解决传统多仓库的痛点
  • 如何使用 pnpm 管理项目依赖和工作空间
  • 如何使用 Turborepo 提升构建效率
  • 实际项目中的最佳实践和注意事项

什么是 Monorepo

在软件开发中,Monorepo("mono"意为"单一","repo"是"存储库"的缩写)是一种策略,它将多个项目集中在一个代码仓库中进行管理。这些项目之间通常具有一定的联系,可以共享代码或依赖,以提高开发效率。

Monorepo 的特点

Monorepo 使用单一的代码仓库来管理多个项目:

  • 项目之间可以通过本地依赖实现无缝共享。
  • 提供统一的工具链,便于维护和协作。

例如谷歌管理着一个庞大的 Monorepo 库,包括大约 10 亿个文件,拥有约 3500 万次提交的历史,跨越了谷歌整个 18 年。[2016]

对比传统的多仓库(Multirepo)

下图展示了 MultirepoMonorepo 的在代码仓库上区别

在传统的多仓库结构中(Multirepo),每个项目独立维护,可能会面临以下问题:

  1. 代码共享困难 为了共享代码,需要额外创建一个共享仓库并发布为包,增加了维护成本。
  2. 代码重复 各项目可能会重复实现相同功能,导致冗余代码和后期维护困难。
  3. 工具不一致 不同仓库可能使用不同的工具链(构建、部署、代码规范),增加了协作成本。
  4. 构建效率低 每个项目独立安装依赖,可能会多次重复构建相同的内容。 举个例子: 我们之前的前端仓库下,有运维平台和租户平台,技术栈相差不多,相同的依赖会重复安装到磁盘上,分别维护各自的工具配置和公共组件,不仅导致代码重复,还让统一管理和协作变得复杂。

Monorepo 的优势

使用 Monorepo,以 JavaScript 生态为例,可以解决以上问题:

  1. 代码共享简单 在本地直接引用共享包,无需发布到 npm 即可使用。
  2. 统一的工具链 通过集中化管理代码规范(如 eslinttypescript 等)和工具链,减少配置成本,保证一致性。
  3. 高效的构建流程 减少重复安装依赖,利用缓存机制加速构建。

Monorepo 在 Web 开发领域中

现代 Web 开发中通常涉及前后端,随着使用 JavaScript 全栈开发的流行,前后端代码和服务往往在同一个项目中协作。例如,ReactVue 等前端框架和 Node.jsGraphQL 等后端技术之间的交互性非常强,前后端复用类型、公共常量等等,这使得在同一个仓库中维护前后端代码变得更加高效。

Monorepo 的实践

我们在一个新项目中实施了 Monorepo,该项目包含了运维前端(admin)和租户前端(tenant),我们将和后端交互用的 Api 抽象成一个独立的包。同时,我们两侧的技术栈基本一致,将相关工具的配置公共部分抽象成包,项目各自继承扩展,最后,我们将一些常用的常量、方法、UI 组件抽象出来,单独管理。

按照我们以往多仓库(Multirepo)方式,这些包分布在各个独立的仓库里,然后将包发布到公司内部或者公开的源上项目中安装导入。这种方式就会产生我前面提到的多仓库的一个缺陷,代码共享困难,在这种模式下,更新共享包(如 @org/api)的流程通常包括以下步骤:

  1. 修改共享包代码
  2. 发布新版本
  3. 更新依赖该包的项目中的版本号
  4. 重新安装依赖
  5. 调试和验证

这个过程不仅繁琐,还可能影响开发效率。虽然在开发模式下可以使用软链接(symlink)来实时查看效果,但这种方法仍需手动操作,且可能引入额外的复杂性。

Monorepo 工具和现代依赖管理工具(如 pnpmYarnnpm)提供的 Workspace 功能可以有效解决这些问题:

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

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

环境版本锁定

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 中,主流依赖管理工具均支持 Workspacepnpm 使用 pnpm-workspace.yaml 配置。npmYarn,使用 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):独立的项目(如前端、后端应用),通常相互隔离。在我们的项目中,admintenant 是两个基于 React 的前端应用,用 Vite 作为打包工具
  • 包(packages):共享的模块,我们项目中包括组件库(ui)、 前后端交互 Api 、代码 lint 工具配置(eslint-config)等 一个包可以是另外一个包的依赖,也可以是应用的依赖,比如 eslint-config 包,可以被 uiapi 依赖,也可以被 admintenant 依赖
  • 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"
    }
  }
])
相关推荐
菩提小狗7 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常7 小时前
我学习到的AG-UI的概念
前端
韩师傅7 小时前
前端开发消亡史:AI也无法掩盖没有设计创造力的真相
前端·人工智能·后端
XiaoYu20028 小时前
第12章 支付宝SDK
前端
双向338 小时前
RAG的下一站:检索增强生成如何重塑企业知识中枢?
前端
拖拉斯旋风8 小时前
从零开始:使用 Ollama 在本地部署开源大模型并集成到 React 应用
前端·javascript·ollama
asing8 小时前
🤯 为什么我的收银台在鸿蒙系统“第一次返回”死活拦不住?一次差点背锅的排查实录
前端·harmonyos
德育处主任8 小时前
『NAS』在群晖部署图片压缩工具-Squoosh
前端·javascript·docker
Hao_Harrision8 小时前
50天50个小项目 (React19 + Tailwindcss V4) ✨| ThreeDBackgroundBoxes(3D背景盒子组件)
前端·3d·typescript·react·tailwindcss·vite7