Skip to content

monorepo 工程管理

项目结构

github 仓库 monorepo-template 仅供参考

monorepo-demo
├─ 📁.cspell                      # 自定义字典,用于检查拼写错误
│  └─ 📄custom-dictionary.txt     # 一些自定义的字典词语放里面
├─ 📁.husky                       # 管理githooks
├─ 📁apps                         # 业务代码
│  ├─ 📁backend                   # 后端业务代码
│  └─ 📁frontend                  # 前端业务代码
├─ 📁packages                     # 项目中维护的的公共包
│  ├─ 📁components                # 前端公共组件
│  └─ 📁utils                     # 前后路段公用的公共方法
├─ 📁scripts                      # 打包packages的脚本
│  ├─ 📄build.js                  # 打包脚本
│  ├─ 📄buildBase.js              # 打包的公共代码
│  └─ 📄dev.js                    # 开发时候的公共代码
├─ 📄.gitignore                   # git忽略文件
├─ 📄.lintstagedrc.js             # lint-straged配置文件
├─ 📄.npmrc                       # npm配置文件
├─ 📄.prettierignore              # prettier忽略文件
├─ 📄commitlint.config.js         # commitlint配置文件
├─ 📄cspell.json                  # 拼写检查配置文件
├─ 📄eslint.config.js             # eslint配置文件
├─ 📄package.json                 # 包管理文件
├─ 📄pnpm-lock.yaml               # 锁文件
├─ 📄pnpm-workspace.yaml          # pnpm工作空间配置文件
├─ 📄prettier.config.js           # prettier配置文件
├─ 📄README.md                    # 项目说明文件
├─ 📄tsconfig.json                # 全局公共ts配置文件
└─ 📄vitest.config.ts             # 包单元测试配置文件

初始化 pnpm monorepo

bash
# 创建工程目录
mkdir monorepo-demo && cd monorepo-demo

# 创建 pnpm-workspace.yaml
touch pnpm-workspace.yaml

# pnpm init
pnpm -w init

# 创建子工程目录
mkdir packages
mkdir apps

环境版本锁定

json
// package.json
"engines": {
  "node": ">=20.0.0",
  "pnpm": ">=10.0.0"
}

若要为严格模式(若版本不符合 engines 则在运营 pnpm 命令的时候会报错)

bash
# .npmrc
engine-strict=true

还可以为项目统一使用 pnpm 安装依赖

json
// package.json
{
  "scripts": {
    "preinstasll": "npx only-allow pnpm"
  }
}

TypeScript

  • 安装
bash
pnpm -Dw add typescript @types/node
  • 根目录公共配置
json
{
  "compilerOptions": {
    "baseUrl": ".",
    "module": "esnext",
    "target": "esnext",
    "types": [],
    "lib": ["esnext"],
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "strict": true,
    "verbatimModuleSyntax": false,
    "moduleResolution": "bundler",
    "isolatedModules": true,
    "noUncheckedSideEffectImports": true,
    "moduleDetection": "force",
    "skipLibCheck": true
  },
  "exclude": ["node_modules", "dist"]
}
  • 后端 nodejs tsconfig.json 配置
json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "types": ["node"],
    "lib": ["esnext"]
  },
  "include": ["src"]
}
  • 前端 tsconfig.json 配置
json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "types": ["node"],
    "lib": ["esnext", "DOM"]
  },
  "include": ["src"]
}

代码风格与质量检查

prettier

  • 安装
bash
pnpm -Dw add prettier
  • 配置

创建prettier.config.js

js
// prettier.config.js
/**
 * @type {import('prettier').Config}
 * @see https://www.prettier.cn/docs/options.html
 */
export default {
  // 指定最大换行长度
  printWidth: 120,
  // 缩进制表符宽度 | 空格数
  tabWidth: 2,
  // 使用制表符而不是空格缩进进行(true: 制表符,false: 空格)
  useTabs: false,
  // 结尾不用分号(true: 有,false: 没有)
  semi: true,
  // 使用单引号(true: 单引号,false: 双引号)
  singleQuote: false,
  // 在对象字面量中决定是否将属性名用引号括起来 可选值 "<as-needed|consistent|preserve>"
  quoteProps: "as-needed",
  // 在 JSX 中使用单引号而不是双引号(true: 单引号,false: 双引号)
  jsxSingleQuote: false,
  // 多行时尽可能打印尾随逗号 可选值 "<none|es5|all>"
  trailingComma: "none",
  // 在对象,数组括号与文字之间加空格 "{ foo: bar }"(true: 有,false: 没有)
  bracketSpacing: true,
  // 将 > 多行元素放在最后一行的末尾,而不是单独放在下一行(true: 放末尾,false: 单独一行)
  bracketSameLine: false,
  // (x) => {} 箭头函数参数只有一个时是否要有小括号(avoid: 省略括号,always: 不省略括号)
  arrowParens: "avoid",
  // 指定要使用的解析器,不需要写文件开头的 @prettier
  requirePragma: false,
  // 可以在文件顶部插入一个特殊标记,指定该文件已使用Prettier 格式化
  insertPragma: false,
  // 用于控制文本是否应该被换行以及如何进行换行
  proseWrap: "preserve",
  // 在 html 中空格是否是敏感的 "css" - 遵守 CSS 显示属性的默认值,"strict" - 空格被认为是敏感的 ,"ignore" - 空格被认为是不敏感的
  htmlWhitespaceSensitivity: "css",
  // 控制在 Vue 单文件组件中 <script> 和 <style> 标签内的代码缩进方式
  vueIndentScriptAndStyle: false,
  // 换行符使用 lf 结尾是 可选值 "<auto|lf|crlf|cr>"
  endOfLine: "auto",
  // 这两个选项可用于格式化以给定字符偏移量 (分别包括和不包括) 开始和结束的代码(rangeStart: 开始,rangeEnd: 结束)
  rangeStart: 0,
  rangeEnd: Infinity
};
  • 创建.prettierignore
bash
# .prettierignore
dist
public
.local
node_modules
pnpm-lock.yaml
  • prettier脚本命令
json
{
  "scripts": {
    //…… 其他省略
    "lint:prettier": "prettier --write \"**/*.{js,ts,mjs,cjs,json,tsx,css,less,scss,vue,html,md}\""
  }
}

执行命令:

bash
pnpm run lint:prettier
# 或者
pnpm lint:prettier

ESLint

  • 安装依赖
bash
pnpm -Dw add eslint@latest @eslint/js globals typescript-eslint eslint-plugin-prettier eslint-config-prettier eslint-plugin-vue
类别库名
核心引擎eslint
官方规则集@eslint/js
全局变量支持globals
TypeScript 支持typescript-eslint
类型定义(辅助)@types/node
Prettier 集成eslint-plugin-prettier, eslint-config-prettier
Vue.js 支持eslint-plugin-vue
  • 配置eslint.config.js
js
// eslint.config.js
import { defineConfig } from "eslint/config";
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginPrettier from "eslint-plugin-prettier";
import eslintPluginVue from "eslint-plugin-vue";
import globals from "globals";
import eslintConfigPrettier from "eslint-config-prettier/flat";
const ignores = ["**/dist/**", "**/node_modules/**", ".*", "scripts/**", "**/*.d.ts"];
export default defineConfig(
  // 通用配置
  {
    ignores, // 忽略项
    extends: [
      // 继承规则
      eslint.configs.recommended,
      ...tseslint.configs.recommended,
      eslintConfigPrettier
    ],
    plugins: {
      prettier: eslintPluginPrettier
    },
    languageOptions: {
      ecmaVersion: "latest", // ecma语法支持版本
      sourceType: "module", // 模块化类型
      parser: tseslint.parser // 解析器
    },
    rules: {
      // 自定义
    }
  },
  // 前端配置
  {
    ignores,
    files: ["apps/frontend/**/*.{ts,js,tsx,jsx,vue}", "packages/components/**/*.{ts,js,tsx,jsx,vue}"],
    extends: [...eslintPluginVue.configs["flat/recommended"], eslintConfigPrettier],
    languageOptions: {
      globals: {
        ...globals.browser
      }
    }
  },
  // 后端配置
  {
    ignores,
    files: ["apps/backend/**/*.{ts,js}"],
    languageOptions: {
      globals: {
        ...globals.node
      }
    }
  }
);
  • 命令
json
{
  "scripts": {
    "lint:eslint": "eslint --write \"**/*.{js,ts,mjs,cjs,json,tsx,css,less,scss,vue,html,md}\""
  }
}

拼写检查

推荐安装vscode插件: Code Spell Checker

  • 安装
bash
pnpm -Dw add cspell @cspell/dict-lorem-ipsum

cspell: 核心拼写库 @cspell/dict-lorem-ipsum:记录了一些拉丁文检查的字典

  • 配置

创建cspell.json

json
{
  "import": ["@cspell/dict-lorem-ipsum/cspell-ext.json"],
  "caseSensitive": false,
  "dictionaries": ["custom-dictionary"],
  "dictionaryDefinitions": [
    {
      "name": "custom-dictionary",
      "path": "./.cspell/custom-dictionary.txt",
      "addWords": true
    }
  ],
  "ignorePaths": [
    "**/node_modules/**",
    "**/dist/**",
    "**/build/**",
    "**/lib/**",
    "**/docs/**",
    "**/vendor/**",
    "**/public/**",
    "**/static/**",
    "**/out/**",
    "**/tmp/**",
    "**/*.d.ts",
    "**/package.json",
    "**/*.md",
    "**/stats.html",
    "eslint.config.mjs",
    ".gitignore",
    ".prettierignore",
    "cspell.json",
    "commitlint.config.js",
    ".cspell"
  ]
}
  • 自定义字典

创建文件 .cspell/custom-dictionary.txt

  • 检查脚本
json
{
  "scripts": {
    "lint:spellcheck": "cspell lint \"(packages|apps)/**/*.{js,ts,mjs,cjs,json,css,less,scss,vue,html,md}\""
  }
}

git 提交规范

  • 初始化
bash
git inti
  • 创建.gitignore
bash
dist/
.DS_Store
node_modules/
coverage
temp
explorations
TODOs.md
*.log
.idea
.eslintcache
dts-build/packages
*.tsbuildinfo
*.tgz
.pnpm-debug.log*

commitizen

  1. 通过命令行交互式构建commit-msg,保证工程提交信息格式统一,提高提交的可追溯性
  2. 对手动写的提交信息(commit-msg)进行标准格式校验(commitlint),或自定义校验
  • 安装命令:
bash
pnpm -Dw add @commitlint/cli @commitlint/config-conventional commitizen cz-git

各包说明:

  • @commitlint/cli:commitlint 工具的核心,提供了一个git-cz的命令来调用commitizen。

  • @commitlint/config-conventional:基于 conventional commits 规范的配置文件。

  • commitizen:提供交互式撰写 commit 信息的插件。

  • cz-git:国人开发的工具,工程性、自定义程度、交互性更强更好。

  • 配置代码提交命令

json
// package.json
{
  "scripts": {
    // 其他省略
    "co": "git add . && git-cz"
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-git"
    }
  }
}

注意:这里不推荐将脚本命令配置为commit, 因为在执行pnpm commit的时候,会首先触发pnpm precommit命令,会导致precommit这个命令执行两次

  • 配置 cz-git
  1. 创建 commitlint.config.js
js
/** @type {import('cz-git').UserConfig} */
export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    // @see: https://commitlint.js.org/#/reference-rules
    "body-leading-blank": [2, "always"],
    "footer-leading-blank": [1, "always"],
    "header-max-length": [2, "always", 108],
    "subject-empty": [2, "never"],
    "type-empty": [2, "never"],
    "subject-case": [0],
    "type-enum": [
      2,
      "always",
      [
        "feat",
        "fix",
        "docs",
        "refactor",
        "perf",
        "test",
        "build",
        "ci",
        "chore",
        "revert",
        "wip",
        "workflow",
        "types",
        "release"
      ]
    ]
  },
  prompt: {
    types: [
      { value: "feat", name: "✨ 新功能: 新增功能" },
      { value: "fix", name: "🐛 修复: 修复缺陷" },
      { value: "docs", name: "📚 文档: 更新文档" },
      {
        value: "refactor",
        name: "🔨 重构: 代码重构(不新增功能也不修复 bug)"
      },
      { value: "perf", name: "🚀 性能: 提升性能" },
      { value: "test", name: "🧪 测试: 添加测试" },
      { value: "chore", name: "🛠 工具: 更改构建流程或辅助工具" },
      { value: "revert", name: "↩️ 回滚: 代码回滚" },
      { value: "style", name: "🎨 样式: 格式调整(不影响代码运行)" }
    ],
    scopes: ["root", "backend", "frontend", "components", "utils"],
    allowCustomScopes: true,
    skipQuestions: ["body", "footerPrefix", "footer", "breaking"], // 跳过“详细描述”和“底部信息”
    messages: {
      type: "📌 请选择提交类型:",
      scope: "🎯 请选择影响范围 (可选):",
      subject: "✏️ 请简要描述更改:",
      body: "🔍 详细描述 (可选):",
      footer: "🔗 关联的 ISSUE 或 BREAKING CHANGE (可选):",
      confirmCommit: "✅ 确认提交?"
    }
  }
};
  • 配置commit-msg信息校验

这里需要husky管理gitHook(commit-msg),内容会在下文的huksy中提及

每次提交代码只需要执行pnpm co,即可跟着提示一步步够建出提交信息了,也可以手动执行git命令git add .git commit -m "feat: xxxx"

husky

  • 安装husky
bash
pnpm add -Dw husky
  • 初始化
bash
pnpx husky init
  • 配置 .husky/pre-commit

这里pre-commit配置为过度项,继续往下看

sh
#!/usr/bin/env sh
pnpm lint:prettier && pnpm lint:eslint && pnpm lint:spellcheck

到这一步执行git commit提交代码,会对整个工程代码执行上述命令(包括此次没有被更改的文件)不太友好,正常只需要对本次更改的文件进行lint即可,所以需要 lint-staged

  • 配置 .husky/commit-msg(补充上面的commit提交时候校验commit-msg信息)
sh
#!/usr/bin/env sh
commitlint --edit $1

若是 报 commitlint 单词存在拼写错误,可以将 commitlint 添加到 .cspell/custom-dictionary.txt

lint-staged

检查暂存区文件

  • 安装
bash
pnpm -Dw add lint-staged
  • 配置脚本命令
bash
# package.json 中的scripts 中新增下面脚本
"precommit": "lint-staged"
  • 配置文件(.lintstagedrc.js
js
// .lintstagedrc.js
export default {
  "*.{js,ts,mjs,cjs,json,tsx,css,less,scss,vue,html,md}": ["pnpm lint:spellcheck"],
  "*.{js,ts,vue,md}": ["pnpm lint:prettier", "pnpm lint:eslint --fix"]
};
  • 重新配置 husky pre-commit 脚本
sh
#!/usr/bin/env sh
pnpm precommit

到这一步,git commit时候已经可以自动lint暂存区(本次被改动的文件)了

公共库打包

业务代码的打包都可以自己打包,因为自带打包功能

但是对于公共库(组件库,工具函数库)需要统一进行管理,有多少个公共库每次打包都一次打包全部库

  • 公共库的打包脚本统一放置到/scripts目录
  • 公共库的打包 选用 rollup,其他工具也可以
  • 安装
bash
pnpm add -Dw rollup @rollup/plugin-terser @rollup/plugin-node-resolve @rollup/plugin-commonjs rollup-plugin-typescript2 @vitejs/plugin-vue rollup-plugin-postcss
  • 新建 scripts/buildBase.js
js
import path from "node:path";
import URL from "node:url";
import fs from "node:fs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import typescript from "rollup-plugin-typescript2";
import vue from "@vitejs/plugin-vue";
import postcss from "rollup-plugin-postcss";

const __filename = URL.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const packages = ["utils", "components"];

function getPackageRoots() {
  return packages.map(pkg => path.resolve(__dirname, "../packages", pkg));
}
async function packageJson(root) {
  const jsonPath = path.resolve(root, "package.json");
  const content = await fs.promises.readFile(jsonPath, "utf-8");
  return JSON.parse(content);
}

async function getRollupConfig(root) {
  const config = await packageJson(root);
  const tsconfig = path.resolve(root, "tsconfig.json");
  const { name, formats } = config.buildOptions || {};
  const dist = path.resolve(root, "./dist");
  const entry = path.resolve(root, "./src/index.ts");
  const rollupOptions = {
    input: entry,
    sourcemap: true,
    external: ["vue"],
    plugins: [
      nodeResolve(),
      commonjs(),
      typescript({
        tsconfig,
        compilerOptions: {
          outDir: dist
        }
      }),
      vue({
        template: {
          compilerOptions: {
            // 自定义转换函数,在生成 AST 时移除特定属性
            nodeTransforms: [
              node => {
                if (node.type === 1 /* NodeTypes.ELEMENT */) {
                  // 过滤掉所有 data-testid属性(单元测试时候用于获取元素自定义ID),具体看单元测试部分
                  node.props = node.props.filter(prop => {
                    if (prop.type === 6 /* NodeTypes.ATTRIBUTE */) {
                      return prop.name !== "data-testid";
                    }
                    return true;
                  });
                }
              }
            ]
          }
        }
      }),
      postcss()
    ],
    dir: dist
  };
  const output = [];
  for (const format of formats) {
    const outputItem = {
      format,
      file: path.resolve(dist, `index.${format}.js`),
      sourcemap: true,
      globals: {
        vue: "Vue"
      }
    };
    if (format === "iife") {
      outputItem.name = name;
    }
    output.push(outputItem);
  }
  rollupOptions.output = output;
  // watch options
  rollupOptions.watch = {
    include: path.resolve(root, "src/**"),
    exclude: path.resolve(root, "node_modules/**"),
    clearScreen: false
  };

  return rollupOptions;
}

export async function getRollupConfigs() {
  const roots = getPackageRoots();
  const configs = await Promise.all(roots.map(getRollupConfig));
  const result = {};
  for (let i = 0; i < packages.length; i++) {
    result[packages[i]] = configs[i];
  }
  return result;
}

export function clearDist(name) {
  const dist = path.resolve(__dirname, "../packages", name, "dist");
  if (fs.existsSync(dist)) {
    fs.rmSync(dist, { recursive: true, force: true });
  }
}
  • 新建 scripts/build.js
js
import { getRollupConfigs, clearDist } from "./buildBase.js";
import { rollup } from "rollup";
import terser from "@rollup/plugin-terser";

async function build() {
  const configs = await getRollupConfigs();
  for (const name in configs) {
    clearDist(name);
    const config = configs[name];
    console.log(`📦 正在打包: ${name}`);
    const bundle = await rollup({
      input: config.input,
      plugins: [...config.plugins, terser()],
      external: config.external
    });
    const tasks = [];
    for (const output of config.output) {
      tasks.push(bundle.write(output));
    }
    await Promise.all(tasks);
    console.log(`✅ ${name} 打包完成`);
  }
}

build();
  • 新建 scripts/dev.js
js
import { getRollupConfigs } from "./buildBase.js";
import { watch } from "rollup";

async function dev() {
  const configs = await getRollupConfigs();
  for (const name in configs) {
    const config = configs[name];
    const watcher = watch(
      config.output.map(o => ({
        input: config.input,
        plugins: config.plugins,
        external: config.external,
        output: o,
        watch: config.watch
      }))
    );

    watcher.on("event", event => {
      if (event.code === "START") {
        console.log(`👁️ 开始监听: ${name}`);
      } else if (event.code === "ERROR") {
        console.error(`❌ ${name}打包失败:`, event.error);
      } else if (event.code === "BUNDLE_START") {
        console.log(`📦 正在打包: ${name}`);
      } else if (event.code === "BUNDLE_END") {
        console.log(`✅ ${name} 打包完成`);
      }
    });
  }
}

dev();
  • 在packages下各个包的 package.json 内新建 配置
json
"buildOptions": {
  "name": "MonorepoUtils", // iife的全局名字
  "formats": [ // 需要打包的格式(参考rollup)
    "esm",
    "cjs",
    "iife"
  ]
}

子包间依赖

工程中自爆之间的依赖只需参照pnpm官方的配置,通过 "workspace:^" 来使用版本即可

注意点:一个子包中import 进另一个子包,可能会报找不到,是因为node模块查策略的原因找不到,只需要在子包package.json 中加入esmodule的入口点即可

json
{
  "main": "./dist/index.cjs.js", // commonjs 模块查找入口
  "type": "module",
  "module": "./dist/index.esm.js", // esmodule 模块查找入口
  "types": "./dist/index.d.ts" // ts类型入口
}

单元测试

  • 安装依赖
bash
pnpm -Dw add vitest @vitest/browser vitest-browser-vue vue
  • vitest:核心测试框架
  • @vitest/browser:通过无头浏览器来测试ui的
  • vitest-browser-vue:测试vue的
  • 添加脚本命令
json
{
  "scripts": {
    "test": "vitest"
  }
}

所有测试代都写到__test__/目录下

  • 配置单元测试(vitest.config.ts)

    推荐安装vscode插件 vitest

ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  test: {
    projects: [
      // 针对utils工具的测试
      {
        test: {
          globals: true,
          name: "utils",
          include: ["packages/utils/__test__/**/*.{test,spec}.{ts,js,tsx,jsx}"],
          environment: "node"
        }
      },
      // 针对components库的测试(ui)
      {
        plugins: [vue()],
        test: {
          globals: true,
          name: "ui",
          include: ["packages/components/__test__/**/*.{test,spec}.{ts,js,tsx,jsx}"],
          browser: {
            enabled: true,
            instances: [{ browser: "chromium" }] // 这里进配置了一个浏览器内核,还可以配置更多
          }
        }
      }
    ]
  }
});
  • 编写测试代码(示例)

注意测试代码文件需要参照配置文件

vue组件测试用例示例

ts
// packages/components/__test__/MyArea.test.ts
import { describe, it, expect, test } from "vitest";
import { render } from "vitest-browser-vue";
import MyArea from "../src/MyArea/MyArea.vue";

describe("MyArea.vue", () => {
  test("mounted", async () => {
    const screen = render(MyArea);
    const n1 = screen.getByTestId("n1"); // 来源于元素的自定义属性 data-testid="n1",打包配置的时候已经配置移除
    const n2 = screen.getByTestId("n2"); // 来源于元素的自定义属性 data-testid="n1",打包配置的时候已经配置移除
    const result = screen.getByTestId("result");
    expect((n1.element() as HTMLInputElement).value).toBe("1");
    expect((n2.element() as HTMLInputElement).value).toBe("2");
    expect(result.element().textContent).toBe("sum:3");
    await n1.fill("3");
    await n2.fill("4");
    expect(result.element().textContent).toBe("sum:7");
  });
});

utils工具函数测用例示例

ts
// packages/utils/__test__/math.test.ts
import * as math from "../src/math";
import { describe, it, expect } from "vitest";

describe("math utils", () => {
  it("add: 1 + 2 = 3", () => {
    expect(math.sum(1, 2)).toBe(3);
  });

  it("subtract: 5 - 2 = 3", () => {
    expect(math.subtract(5, 2)).toBe(3);
  });

  it("multiply: 2 * 3 = 6", () => {
    expect(math.multiply(2, 3)).toBe(6);
  });
});

后续

可能涉及到子包的发布

  • 子包加上相关描述 descriptions
  • 加上files字段
  • 使用工具自动管理版本号
  • 子包name使用命名空间(可选)

业务代码

  • apps里面的项目根据具体业务使用对应脚手架搭建工程

若业务代码使用的技术栈一致,可以使用 pnpm 的工作空间功能 Catalogs 提取公共的包,便于后续维护