从0到1搭一个monorepo项目(二)

引言

大家好啊,我是前端拿破轮。😁

在上一篇文章《从0到1搭一个monorepo项目(一)》中,我们谈到了,如果采用传统的multirepo方式来开发一个项目,会遇到诸多问题。并且找到了解决这些问题的方法,就是monorepo。那么这篇文章,我们就来具体从0到1搭建一个monorepo的项目。

搭建过程

准备工作

安装node等前端的基本工程化环境不在本系列文章的讨论范围之内,大家可自行搜索相关文章。

首先需要我们安装pnpm,可以使用npm或者brew安装,windows用户可以使用scoop安装。

bash 复制代码
# 安装pnpm
scoop install pnpm

然后我们创建我们的工作文件夹,这里我取名叫做study-island,即学习岛,是一个无人自习室管理系统项目。

bash 复制代码
# 创建文件夹
mkdir study-island

# 进入文件夹
cd study-island

# 在vscode中打开此文件夹
code .

初始化

然后我们先利用pnpm进行项目初始化。

bash 复制代码
pnpm init

我们可以发现在项目的根目录自动生成了一个package.json文件,并写入了一些基本的配置内容。

接下来我们就要就要进行monorepo管理了,市面上有非常多的工具可以进行选择,这里我们就使用pnpm本身提供的轻量级的monorepo管理能力。

首先创建一个pnpm-workspace.yaml文件,用来配置pnpm的工作区。

bash 复制代码
touch pnpm-workspace.yaml

我们在其中写入以下内容:

yaml 复制代码
packages:
  # 匹配apps目录下的直接子目录
  - 'apps/*'
  # 匹配packages目录下的直接子目录
  - 'packages/*'

这个配置文件就是告诉pnpm,在appspackages目录下的直接子文件夹都是属于子包 ,方便pnpm进行monorepo的管理。

有了这个配置文件后,pnpm就能够识别我们的项目中,哪些目录是属于子包的。

然后我们就需要创建这些子包的目录。

bash 复制代码
mkdir apps packages

利用脚手架搭建业务子包

其中apps存放的是我们的业务代码,packages存放的是我们的公共模块代码,比如utils公用工具库,还有UI组件库等。

接着我们进入apps目录,并创建我们的业务子包。这里我们使用vite来进行创建。

pc端项目

PC端我们就创建最普通的react项目即可。

bash 复制代码
# 进入apps目录
cd apps

# 利用vite脚手架创建项目
pnpm create vite@latest

这里我们利用vite提供的交互式命令行进行项目创建,项目名我们就直接输入pc。在选择变体的时候选择TypeScript + SWC,因为我们后面要使用的UI组件库是基于React 18的,所以变体不能选择TypeScript + React Compiler ,因为这是React 19才引入的优化机制。

实验性质的rolldown-vite我们出于稳定性考虑也先不选用。

然后不要现在安装和运行,因为在monorepo项目中,我们一般不会进入子包目录运行命令,而是直接在根目录运行

mobile端项目

mobile端项目我们使用React Native来开发。

方便起见,我们使用对RN进行封装的Expo工具链来获得开箱即用的体验。

bash 复制代码
pnpm create create-expo-app

关于下载Android Studio和配置模拟器的相关步骤不在本文讨论范围,大家可以自行搜索,本文还是主要聚焦在如何搭建一个monorepo的项目。

后端项目

后端我们采用Nest.js框架来搭建成熟的服务器。

首先我们需要全局安装@nestjs/cli工具包。

bash 复制代码
# 全局安装cli工具
pnpm add -g @nestjs/cli

# 创建一个新项目,命名为backend
nest new backend

到目前为止,我们的三个模块已经全部创建完毕,整个项目目录如下图所示:

调整git仓库

由于我们在创建pc项目时,使用了脚手架vite,创建mobile项目时,使用了脚手架expo,创建backend项目时,使用了脚手架@nestjs/cli,这些脚手架工具有一些会在创建项目的时候自动初始化git仓库,这显然不是我们想要的。我们已经采用了monorepo整个项目只有根目录有一个git仓库即可,子包中不能有git仓库。所以我们需要将子包中的git仓库删除,并将.gitignore文件统一整合到根目录下。

整合后如下图所示:

开发依赖统一安装到根目录

package.json中主要有两种依赖类型dependenciesdevDependencies,前者是运行时依赖,后者是开发依赖。也就是说,后者仅仅在开发阶段用到,运行时是不需要的,所以在monorepo项目中,我们可以把子包的所有开发依赖都统一配置到根目录的package.json中,方便统一管理,也能避免版本冲突

删除子包中的devDependencies字段,将内容全部整合到根目录的package.json中,重复的依赖我们只保留一个版本即可。

锁定node和pnpm版本

package.json中配置engines字段如下:

json 复制代码
"engines": {
    "node": "22.20.0",
    "pnpm": "10.17.0"
}

这样当我们使用不匹配的node版本进行安装时,会有如下警告:

如果我们想要更严格的控制,当版本不符合时直接报错,我们可以在项目的根目录下创建.npmrc文件,并将engine-strict设置为true

.npmrc 复制代码
engine-strict=true

这样再在根目录运行pnpm i命令时就会报错如下图:

配置git提交规范

这里我们需要安装如下几个工具:

bash 复制代码
pnpm add -Dw @commitlint/cli @commitlint/config-conventional commitizen cz-git

这里的-Dw表示-D即安装开发依赖和--workspace-root,即告诉pnpm我确定就是要在项目的根目录进行安装。

假设没有-w命令,我们直接安装,会有如下提示:

意思是说,运行这个命令会在工作空间的根目录添加依赖,pnpm感觉这个不一定时我们的本意,如果我们确实想要在根目录添加依赖就加上-w或者--workspace-root表示确认。

  • @commitlint/clicommitlint工具的核心。
  • @commitlint/config-conventional是基于conventional commits规范的配置文件,用来约束git提交的格式。
  • commitizen提供了交互式 攥写commit信息的插件。
  • cz-git是国人开发的工程性更强,更加高度自定义的commitizen适配器和cli,相当于commitizen的UI和逻辑增强版。

commitizen库提供了一个git-cz的命令,可以进行交互式生成git的提交信息。

他们的关系大致如下图:

sql 复制代码
开发者输入命令:
    ↓
  npx cz / git cz
    ↓
【commitizen】提供交互界面
    ↓
  使用适配器(adapter)
    ├─ cz-conventional-changelog (官方)
    └─ cz-git (社区增强版)
    ↓
  生成 commit message
    ↓
【commitlint】校验是否符合规范
    ↓
  commit 成功 or 拒绝提交

所以我们在根目录的package.json中配置脚本和适配器:

json 复制代码
// package.json 
...,
"scripts": {
    "commit": "git-cz"
 }
...,
"config": {
    "commitizen": {
        "path": "node_modules/cz-git"
    }
}

创建commitlint的配置文件:

bash 复制代码
touch commitlint.config.js

写入如下配置:

js 复制代码
/** @type {import('cz-git').UserConfig} */
export default {
  // 继承 commitlint 的常见规范(Angular 风格)
  extends: ["@commitlint/config-conventional"],

  // commitlint 规则(影响 commit message 校验)
  rules: {
    // 提交主体前必须有空行(body 与 header 之间),严重级别 2(错误)
    "body-leading-blank": [2, "always"],

    // footer 前是否需要空行
    "footer-leading-blank": [1, "always"],

    // header 最大长度
    "header-max-length": [2, "always", 108],

    // 不允许空的 subject
    "subject-empty": [2, "never"],

    // 不允许空的 type
    "type-empty": [2, "never"],

    // 对 subject 的大小写不做强制
    "subject-case": [0],

    // type 的可选值
    "type-enum": [
      2,
      "always",
      [
        "feat",
        "fix",
        "docs",
        "style",
        "refactor",
        "perf",
        "test",
        "build",
        "ci",
        "chore",
        "revert",
        "wip",
        "workflow",
        "types",
        "release",
      ],
    ],
  },

  // 自定义错误提示信息(中文)
  plugins: [
    {
      rules: {
        "subject-empty": ({ subject }) => {
          const isValid = subject && subject.trim().length > 0;
          return [
            isValid,
            "❌ 提交描述不能为空!\n💡 请在冒号后添加描述,例如: feat: 添加用户登录功能",
          ];
        },
        "type-empty": ({ type }) => {
          const isValid = type && type.trim().length > 0;
          return [
            isValid,
            "❌ 提交类型不能为空!\n💡 格式: <类型>: <描述>\n📚 可用类型: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert, wip, workflow, types, release",
          ];
        },
        "type-enum": ({ type }) => {
          const validTypes = [
            "feat",
            "fix",
            "docs",
            "style",
            "refactor",
            "perf",
            "test",
            "build",
            "ci",
            "chore",
            "revert",
            "wip",
            "workflow",
            "types",
            "release",
          ];
          const isValid = validTypes.includes(type);
          return [
            isValid,
            `❌ 提交类型 "${type}" 不在允许的范围内!
💡 请使用以下类型之一:
   ✨ feat      - 新功能
   🐛 fix       - 修复bug
   📚 docs      - 文档变更
   🎨 style     - 代码格式
   📦 refactor  - 代码重构
   🚀 perf      - 性能优化
   🧪 test      - 测试相关
   🏗️  build     - 构建/依赖变更
   👷 ci        - 持续集成
   🔧 chore     - 其他修改
   ⏪ revert    - 回滚提交
   🚧 wip       - 进行中的工作
   🔁 workflow  - 工作流相关
   🔤 types     - 类型定义
   🏷️  release   - 版本发布`,
          ];
        },
        "header-max-length": ({ header }) => {
          const maxLength = 108;
          const isValid = header.length <= maxLength;
          return [
            isValid,
            `❌ 提交信息头部太长了!
📏 当前长度: ${header.length} 字符
📏 最大长度: ${maxLength} 字符
💡 请精简描述,保持简洁明了`,
          ];
        },
        "body-leading-blank": ({ body }) => {
          if (!body) return [true];
          const isValid = body.startsWith("\n");
          return [
            isValid,
            "❌ 提交正文前需要有空行!\n💡 在标题和正文之间添加一个空行",
          ];
        },
        "footer-leading-blank": ({ footer }) => {
          if (!footer) return [true];
          const isValid = footer.startsWith("\n");
          return [
            isValid,
            "⚠️  页脚前建议有空行!\n💡 在正文和页脚之间添加一个空行",
          ];
        },
        "subject-case": ({ subject }) => {
          // 这个规则已关闭,但保留提示以供参考
          return [true];
        },
      },
    },
  ],

  // 自定义帮助信息 URL 或提示文本
  helpUrl: `请使用 pnpm commit 命令进行交互式提交,不要直接使用 git commit 命令`,

  // cz-git 的 prompt 配置(交互式提交时的提示)
  prompt: {
    // types 与上面的 type-enum 必须一致
    types: [
      {
        value: "feat",
        name: "✨ feat:       新功能(feature)",
        emoji: "✨",
      },
      {
        value: "fix",
        name: "🐛 fix:        修复 bug",
        emoji: "🐛",
      },
      {
        value: "docs",
        name: "📚 docs:       文档变更",
        emoji: "📚",
      },
      {
        value: "style",
        name: "🎨 style:      代码格式(不影响逻辑)",
        emoji: "🎨",
      },
      {
        value: "refactor",
        name: "📦 refactor:   代码重构(无新增/修复)",
        emoji: "📦",
      },
      {
        value: "perf",
        name: "🚀 perf:       性能优化",
        emoji: "🚀",
      },
      {
        value: "test",
        name: "🧪 test:       测试相关",
        emoji: "🧪",
      },
      {
        value: "build",
        name: "🏗️  build:      构建系统/外部依赖变更",
        emoji: "🏗️",
      },
      {
        value: "ci",
        name: "👷 ci:         持续集成/流程相关",
        emoji: "👷",
      },
      {
        value: "chore",
        name: "🔧 chore:      构建过程或辅助工具变更",
        emoji: "🔧",
      },
      {
        value: "revert",
        name: "⏪ revert:     回滚到以前的提交",
        emoji: "⏪",
      },
      {
        value: "wip",
        name: "🚧 wip:        正在进行的工作(未完成)",
        emoji: "🚧",
      },
      {
        value: "workflow",
        name: "🔁 workflow:   工作流(如 GitHub Actions)",
        emoji: "🔁",
      },
      {
        value: "types",
        name: "🔤 types:      类型定义文件/TS 类型相关",
        emoji: "🔤",
      },
      {
        value: "release",
        name: "🏷️  release:    发布/版本相关",
        emoji: "🏷️",
      },
    ],

    // 影响范围,常见子项目/包名
    scopes: [
      { value: "root", name: "root:     项目根目录" },
      { value: "backend", name: "backend:  后端相关" },
      { value: "mobile", name: "mobile:   移动端相关" },
      { value: "pc", name: "pc:       PC端相关" },
    ],
    allowCustomScopes: true,
    allowEmptyScopes: true,

    // 跳过不必要的问题
    skipQuestions: ["body", "breaking", "footerPrefix", "footer"],

    // 提示文本(中文提示更友好)
    messages: {
      type: "📌 选择你要提交的类型:",
      scope: "🎯 选择一个影响范围 (可选,直接回车跳过):",
      customScope: "🎯 请输入自定义的影响范围:",
      subject: "📝 填写简短精炼的变更描述:\n   (例如: 添加用户登录功能)\n",
      body: '🔍 填写更详细的变更描述 (可选)。使用 "|" 换行:\n',
      breaking: "💥 列举非兼容性重大的变更 (可选):\n",
      footerPrefixesSelect: "🔗 选择关联issue前缀 (可选):",
      customFooterPrefix: "🔗 输入自定义issue前缀:",
      footer: "🔗 列举关联的 ISSUE (可选)。例如: #31, #34:\n",
      generatingByAI: "🤖 正在通过 AI 生成你的提交简短描述...",
      generatedSelectByAI: "💡 选择一个 AI 生成的简短描述:",
      confirmCommit: "✅ 是否确认以上commit信息提交?",
    },

    // 允许在 feat 和 fix 类型中添加 breaking changes
    allowBreakingChanges: ["feat", "fix"],

    // subject 长度限制
    subjectLimit: 100,

    // 默认 scope 枚举分隔符
    scopeEnumSeparator: ",",

    // 自定义选择框宽度
    maxHeaderLength: 108,
    maxSubjectLength: 100,
    minSubjectLength: 3,

    // 默认 body 和 footer 的最大长度
    defaultBody: "",
    defaultFooter: "",

    // 是否使用 emoji(建议开启,让提交历史更直观)
    useEmoji: true,
    emojiAlign: "center",

    // 主题前缀(可选,如果不想要可以设为空)
    themeColorCode: "",

    // 如果有 AI 生成功能可以配置(需要额外插件支持)
    // aiNumber: 3,
    // aiQuestionCB: ({ answers, aiQuestions }) => { }
  },
};

然后运行pnpm commit即可执行git-cz脚本。

然后我们就可以使用交互式命令行来提交啦。

但是这里还有一个问题,我们这是使用了pnpm commit来运行git-cz脚本进行交互式提交,如果有新人接手了我们的项目,他并不知道这个脚本,直接使用git commit,就会绕过我们的交互式工具,就可以任意上传commit信息了,还是无法实现规范效果。

这里我们可能会想,要是有一个工具可以在我每次git提交前都检查一下我的提交信息是否符合规范,不符合就无法成功提交就好了。

这就是我们接下来要说的工具husky

首先我们需要安装husky

bash 复制代码
# 安装husky
pnpm -Dw add husky

# 初始化
pnpm husky init

然后我们会发现项目目录下多了一个文件夹.huksy

bash 复制代码
# 配置husky的commit-msg钩子
# Add commit message linting to commit-msg hook mac和linux用户用这个命令
echo "pnpm dlx commitlint --edit \$1" > .husky/commit-msg
# Windows users should use ` to escape dollar signs windows用户用这个命令
echo "pnpm dlx commitlint --edit `$1" > .husky/commit-msg

我们这个时候如果再想绕过监管就难以做到了。

总结

本文我们从0-1搭建了一个monorepo的项目,主要进行了项目初始化,并利用vite搭建了pc端项目,利用expo搭建了移动端的RN项目,利用nest.js搭建了服务端项目。同时配置了git提交的强制规范。后续内容会在本专栏持续更新,欢迎大家订阅关注:Monorepo项目搭建

好了,这篇文章就到这里啦,如果对您有所帮助,欢迎点赞,收藏,分享👍👍👍。您的认可是我更新的最大动力。由于笔者水平有限,难免有疏漏不足之处,欢迎各位大佬评论区指正。

往期推荐✨✨✨

我是前端拿破轮,关注我,一起学习前端知识,下期见!

相关推荐
止观止3 小时前
XSS 攻击详解:原理、类型与防范策略
前端·xss
SuperherRo3 小时前
JS逆向-安全辅助项目&Yakit热加载&魔术方法&模版插件语法&JSRpc进阶调用&接口联动
javascript·安全·yakit·jsrpc·热加载
用户47949283569153 小时前
用|运算符写管道?Symbol.toPrimitive让JavaScript提前用上|>语法
前端·javascript
知识分享小能手4 小时前
uni-app 入门学习教程,从入门到精通,uni-app 基础知识详解 (2)
前端·javascript·windows·学习·微信小程序·小程序·uni-app
文心快码BaiduComate4 小时前
限时集福!Comate挂件/皮肤上线,符(福)气掉落中~
前端·后端·程序员
勇敢di牛牛4 小时前
vue3 + mars3D 三分钟画一个地球
前端·vue.js
ssshooter4 小时前
MCP 服务 Streamable HTTP 和 SSE 的区别
人工智能·面试·程序员
IT_陈寒5 小时前
Python+AI实战:用LangChain构建智能问答系统的5个核心技巧
前端·人工智能·后端
DIY机器人工房5 小时前
【嵌入式面试题】STM32F103C8T6 完整元器件解析 + 面试问题答案
stm32·单片机·面试·嵌入式·面试题·diy机器人工房