如何使用Monorepo实现跨项目共享组件和模块

lxf2023-03-11 08:14:01

我正在参加「AdminJS·启航计划」
因为之前做过一个技术栈为 vue3+vite+elementPlus 的后台管理系统项目,当时为了方便创建类似项目还搭建了项目模板(github:vue3-vite-elementPlus-admin)。
随着公司业务发展类似项目越来越多,一个仓库一个项目的管理形式弊端越来越突出:

  1. 每个项目都要搭建 eslint+prettier+commitLint+styleLint 等代码规范校验
  2. 每个项目都要搭建许多类似的常用组件和创建公共 utils 函数
  3. 各个项目的主要依赖库(vue,elementPlus)版本可能不一致,且存在重复安装
  4. 有时候组件改动会涉及到多个项目都要手动更改

考虑到我们的不同项目在业务和组件交互上存在很多相似性,可以考虑在不同项目中使用公共组件库和公共依赖库。目前为了实现模块复用,采用 monorepo 风格管理项目就比较合适了。 monorepo 可以让多个模块共享同一个仓库,因此他们可以共享同一套构建流程、代码规范也可以做到统一,特别是如果存在模块间有共享公共组件的情况,查看代码、修改 bug、调试等会更加方便。

Monorepo 和 Multi-Repo 优缺点比较

MonorepoMulti-Repo
开发只需在一个仓库中开发,编码会相当方便,新成员入门简单,代码复用高,方便进行代码重构。仓库体积小,模块划分清晰。需要多仓库来回切换。无法实现跨项目代码复用
工程配置所有项目统一配置相同的工程配置各个项目可能各自有一套标准,新建一个仓库又得重新配置一遍
依赖管理共同依赖可以提取至 root,版本控制更加容易,依赖管理会变的方便依赖重复安装,多个依赖可能在多个仓库中存在不同的版本,npm link 时不同项目的依赖可能会存在冲突问题。
代码管理代码全在一个仓库,项目太大用 Git 管理会存在问题,无法隔离项目代码权限各个团队可以控制代码权限,也几乎不会有项目太大的问题
部署有 lerna 工具支持如果多项目存在依赖关系,开发者就需要在不同的仓库按照依赖先后顺序去修改版本及进行部署。

市场上搭建 monorepo 项目常用的有三种方案

  1. lerna
  2. yarn+workspace
  3. pnpm

考虑到 pnpm 内置了对 monorepo 的支持,且搭建简单快捷,门槛较低,使用方便的原因,我们这里采用 pnpm 方案。具体使用 pnpm 的好处可以参考为什么推荐使用 pnpm

初始化项目

新建 monorepoVueApps 文件夹,在该文件夹中新建 pnpm-workspace.yaml 文件,这个文件定义了工作空间的根目录,并能够使您从工作空间中包含 / 排除目录

packages:
  # 所有在 packages/  子目录下的模块
  - "packages/**"
  # 不包括在 test 文件夹下的模块
  - "!**/test/**"

在 monorepoVueApps 文件夹下创建以下目录

|-- packages
|   |-- apps           // 项目仓库文件夹 包含多个vue项目
|   |-- components     // 公共组件库
|   |   |-- basic      // 基础组件
|   |   |-- layout     // 布局组件
|   |-- hooks          // 公共hooks库
|   `-- utils          // 公共工具函数库
`-- pnpm-workspace.yaml

安装依赖

全局安装 pnpm

npm i pnpm -g

生成 package.json

分别在 components,hooks,utils 文件夹下执行

pnpm init -y

包名一般都通用为命名空间+项目名,这里命名空间名称为@vueapps

安装公共依赖

这里将相关开发工具和公共库安装至项目根目录,方便管理的各个项目直接使用。在根目录下执行

pnpm add vue -w
pnpm add typescript vite @vitejs/plugin-vue vue-tsc  -wD

pnpm 提供了 -w, --workspace-root 参数,可以将依赖包安装到工程的根目录下,作为所有 package 的公共依赖。

创建 vue3 项目 app1

cd packages/apps
pnpm create vite app1 -- --template vue-ts

生成 app1 项目后修改 package.json。 将 name 改为"@vueapps/app1",将默认的依赖全部去掉,因为上面我们已经在根目录安装过

{
  "name": "@vueapps/app1",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview"
  },
  "dependencies": {},
  "devDependencies": {}
}

在根目录 package.json 文件中添加 scripts

scripts:{
  "app1:dev": "pnpm -F @vueapps/app1 run dev",
  "app1:build": "pnpm -F @vueapps/app1 run build"
}

pnpm 提供了 -F ,--filter 参数,可以用来对特定的 package 进行某些操作。

在根目录下执行pnpm run app1:dev,即可看到 app1 项目正常运行

我们用同样的方式创建 app2 项目,然后实现这两个项目共用 utils 工具函数

添加公共函数库 utils

在 utils 文件夹下新建 getAge.ts

export function getAge() {
  return "26";
}

新建 index.ts

export { getAge } from "./getAge";

修改该项目的 package.json 文件中的 main,并添加 exports 字段暴露出 getAge 方法

{
  "main": "index.ts",
  "exports": {
    "./getAge": "./getAge.ts"
  }
}

至此已经在 utils 工具函数库中增加 getAge 方法,其他项目中调用时需要先安装

局部安装项目依赖

utils 工具函数库中的方法并不是所有项目都会用到,所以我们不全局安装@vueapps/utils,只是在具体用到的项目中安装 这里局部安装有两种方式:

  • 进入到具体项目目录中去安装
pnpm add @vueapps/utils -D
  • 在根目录通过 -F 安装
pnpm add @vueapps/utils -F @vueapps/app1  # app1项目安装utils工具函数库

这里我们采用-F 安装,此时我们查看 app 的 package.json,可以看到 dependencies 字段中多了对 @vueapps/utils 的引用,以 workspace: 开头,后面跟着具体的版本号

{
  "name": "@vueapps/app1",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@vueapps/utils": "workspace:^1.0.0"
  }
}

在不同项目中共享 utils 工具函数

在 app1 项目中 app.vue 文件中使用 getAge 函数

import { getAge } from "@vueapps/utils/getAge";
const age = getAge();
console.log(age); //26

可以在浏览器 console 面板中看到正常打印 26,说明 utils 公共函数库代码已经生效,同样在 app2 项目中也可以这样使用。从而达到公共函数库代码夸项目使用的目的

在开发环境下我们将 getAge 函数结果改为 25,app1,app2 两个项目都会更新生效。 在生产环境下我们更新 getAge 函数后需要每个项目都重新打包发布才能生效

添加公共布局组件 components 并跨项目共享

添加 Button 组件用于夸项目共享,在根目录下执行

cd packages/components/basic && mkdir Button && cd Button && touch Button.vue
<template>
  <ElButton type="primary">
    <slot></slot>
  </ElButton>
</template>

<script setup lang="ts">
import { ElButton } from "element-plus";
</script>

修改 components 文件夹下的 package.json 文件,添加 exports 暴露出 Button 组件

{
  "exports": {
    ".": "./index.ts",
    "./Button": "./basic/Button/Button.vue"
  }
}

为 app1 项目局部安装 Button 组件,在根目录下执行

pnpm add @vueapps/components -F @vueapps/app1

在 app1 项目中引用 Button

<script setup lang="ts">
import Button from "@vueapps/components/Button";
</script>
<template>
  <Button>这是共享的按钮组件</Button>
</template>

此时 Button 组件已经被使用,但是缺少样式文件 修改 app1 项目的 vite.config.ts 通过安装 unplugin-element-plus 插件实现对 ElementPlus 样式文件的支持

import ElementPlus from "unplugin-element-plus/vite";

export default defineConfig({
  plugins: [vue(), ElementPlus()],
});

至此 Button 组件在 app1 项目中完美使用 如何使用Monorepo实现跨项目共享组件和模块 在 app2 项目中可以同样安装使用

添加 eslint+prettier+commitLint+styleLint 统一代码规则校验

安装 eslint

pnpm add eslint@7.32.0 eslint-plugin-vue@7.15.1 @typescript-eslint/parser@5.30.7  @typescript-eslint/eslint-plugin@5.30.7 eslint-plugin-simple-import-sort@7.0.0 -wD

eslint-plugin-vue:Vue.js 的官方 ESLint 插件
@typescript-eslint/parser:ESLint 的解析器,用于解析 typescript,从而检查和规范 Typescript 代码
@typescript-eslint/eslint-plugin:ESLint 插件,包含了各类定义好的检测 Typescript 代码的规范
eslint-plugin-simple-import-sort:自动排序 import 的插件

在项目根目录下新建.eslintrc.js

module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  }, //定义eslint依赖的插件
  plugins: ["@typescript-eslint", "prettier", "simple-import-sort"], //定义文件继承的代码规范
  extends: [
    "plugin:vue/vue3-essential",
    "plugin:vue/vue3-recommended",
    "plugin:prettier/recommended",
  ],
  parserOptions: {
    //解析ts文件
    parser: "@typescript-eslint/parser",
    sourceType: "module",
    ecmaVersion: "latest",
    ecmaFeatures: {
      tsx: true, // 允许解析TSX
    },
  },
  rules: {
    "prettier/prettier": "error",
    "@typescript-eslint/explicit-module-boundary-types": "off",
    "@typescript-eslint/interface-name-prefix": "off",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-var-requires": "off",
    "@typescript-eslint/camelcase": "off",
    "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
    "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
    "vue/html-self-closing": [
      "error",
      {
        html: {
          component: "always",
          normal: "always",
          void: "any",
        },
        math: "always",
        svg: "always",
      },
    ],
    "vue/require-default-prop": "off",
    "vue/no-v-html": "off",
    "sort-imports": "off",
    "import/order": "off",
    "simple-import-sort/imports": "error",
    "simple-import-sort/exports": "error",
  },
  overrides: [
    {
      files: [
        "**/__tests__/*.{j,t}s?(x)",
        "**/tests/unit/**/*.spec.{j,t}s?(x)",
      ],
      env: {
        jest: true,
      },
    },
  ],
};

安装 prettier

pnpm add prettier@2.7.1 eslint-config-prettier@8.5.0 eslint-plugin-prettier@4.2.1 -wD

eslint-config-prettier :解决 ESLint 中的样式规范和 prettier 中样式规范的冲突,以 prettier 的样式规范为准,使 ESLint 中的样式规范自动失效
eslint-plugin-prettier :将 prettier 作为 ESLint 规范来使用

在根目录下新建.prettierrc.js

module.exports = {
  printWidth: 120,
  proseWrap: "preserve",
  tabWidth: 2,
  semi: true,
  singleQuote: false,
  trailingComma: "none",
  bracketSpacing: true,
  jsxBracketSameLine: false,
  arrowParens: "avoid",
  rangeStart: 0,
  endOfLine: "lf",
  insertPragma: false,
  requirePragma: false,
  useTabs: true,
};

安装 styleLint

pnpm add stylelint@13.13.1 stylelint-prettier@1.2.0 stylelint-config-prettier@8.0.2
stylelint-config-rational-order@0.1.2 -wD

在项目根目录下新建.stylelintrc.js

module.exports = {
  defaultSeverity: "error",
  plugins: ["stylelint-prettier"],
  extends: ["stylelint-prettier/recommended", "stylelint-config-recess-order"],
  rules: {},
};

安装 commitLint

pnpm add @commitlint/cli@17.0.3 @commitlint/config-conventional@17.0.3 -wD

在项目根目录下新建.commitlintrc.js

module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [
      2,
      "always",
      [
        "wip", // 开发中
        "feat", // 新功能
        "fix", // bug 修复
        "docs", //文档变更
        "style", //样式变更
        "refactor", //重构
        "perf", // 性能优化
        "test", //新增或修订单元测试
        "revert", // 回滚操作
        "chore", //构建过程或辅助工具变更
      ],
    ],
  },
};

安装 husky 和 lint-staged

pnpm add husky@8.0.0 lint-staged@13.0.3 -wD
# 设置husky
npx husky install
npm pkg set scripts.prepare="husky install"
npm pkg set scripts.preinstall="npx only-allow yarn" # 只允许用yarn
npx husky add .husky/commit-msg 'yarn commitlint --edit "$1"'
npx husky add .husky/pre-commit 'yarn lint-staged'
npx husky add .husky/post-merge 'yarn'

在 package.json 中添加 lint-staged 脚本

"lint-staged": {
    "*.{vue,ts,js}": [
      "prettier --write",
      "eslint --fix",
      "ls-lint"
    ],
    "*.{vue,less,scss}": [
      "prettier --write",
      "stylelint --quiet --fix",
      "ls-lint"
    ]
  },

安装 editorConfig 确保不同编辑器代码风格一致

# EditorConfig is awesome: https://EditorConfig.org

# top-most EditorConfig file
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

总结

看到这里我们应该能清楚的知道采用 monorepo 项目的好处以及它解决的实际问题,同时我们也发现采用 monorepo 后在组件跨模块共享上会带来新的问题,

  1. 不同项目用到不同版本的依赖库
  2. 公共组件库代码修改后,所有依赖这个库的项目都需要重新打包构建部署
  3. 公共组件库代码会参与本地构建,重复打包到项目代码中

解决方案

  1. 如果不同项目方便改成相同版本依赖库那最好,如果不能就只能各自局部安装不同版本依赖库
  2. 2 和 3 都可以考虑用模块联邦解决

源码仓库:monorepoVueApps

参考文章:

  1. 为什么推荐使用 pnpm
  2. 用 PNPM Workspaces 替换 Lerna + Yarn
  3. pnpm + workspace + changesets 构建你的 monorepo 工程
  4. 开源项目都在用 monorepo,但是你知道居然有那么多坑么?