Skip to content

MonoRepo工程化

传统架构

  • 独立项目结构:每个项目作为独立的单元开发、维护和部署
  • 技术栈独立:不同的项目可能使用不同的技术栈和工具链
  • 依赖管理:每个项目都有独立的 node_modules 和依赖配置
  • 部署策略:各自独立部署和上线,通常依赖 CI/CD 工具

MenoRepo 架构

  • 单一代码仓库:将多个相关项目(子包)集中在同一个代码仓库中进行管理
  • 统一管理依赖:通过工具(如 PNPM、Lerna、Turborepo 等)实现依赖的统一安装和版本管理
  • 共享代码:通过工作空间或内部包机制共享公共模块
  • 统一构建和发布:可以对多个子包进行一次性构建和发布

MonoRepo 项目搭建

项目仓库 GitHub

项目配置

在根目录下创建pnpm-workspace.yaml文件

yaml
packages:
  - "apps/*"
  - "packages/*"

通过 pnpm 初始化项目,指定参数 --workspace-root 表示其去找文件所在位置作为根目录

bash
pnpm --workspace-root init

锁定版本

在根目录下的package.json中锁定依赖版本

json
{
  "engines": {
    "node": ">=22.0.0",
    "npm": ">=10.0.0",
    "pnpm": ">=10.0.0"
  }
}

并且添加.npmrc文件,用于启用引擎版本严格检查,若版本不匹配:npm 会直接抛出错误并终止操作,而非仅显示警告

bash
engine-strict=true

此时当我降低node版本进行安装依赖进行测试,会直接报错

bash
PS D:\mygit\monorepo> pnpm i -Dw typescript
ERR_PNPM_BAD_PM_VERSION This project is configured to use v10.18.1 of pnpm. Your current pnpm is v9.1.4

If you want to bypass this version check, you can set the "package-manager-strict" configuration to "false" or set the "COREPACK_ENABLE_STRICT" environment variable to "0"

TypeScript 配置

安装TypeScript 及其类型定义,其中w是--workspace-root的缩写,用于指定在根目录下安装依赖

bash
pnpm -Dw add typescript @types/node

统一管理

在根目录下新建一个tsconfig.json文件,并添加以下内容:

json
{
  "compilerOptions": {
    "baseUrl": ".",
    "module": "esnext",
    "target": "esnext",
    "types": [],
    "lib": [
      "esnext"
    ],
    "sourceMap": true,
    "declaration": true,
    "declarationMap": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "strict": true,
    "verbatimModuleSyntax": true,
    "moduleResolution": "bundler",
    "isolatedModules": true,
    "noUncheckedSideEffectImports": true,
    "moduleDetection": "force",
    "skipLibCheck": true
  },
  "exclude": [
    "node_modules",
    "dist"
  ]
}

具体管理

对于具体的子包,在其子包的根目录下创建tsconfig.json文件,进行自定义配置

代码风格与质量检查

prettier

先安装prettier prettier 官方文档

bash
pnpm -Dw add prettier

在根目录下添加.prettier.config.js文件【prettier配置文件】和.prettierignore文件【prettier忽略文件】,最后在package.json 当中添加执行的命令,执行pnpm run lint:prettier即可格式化所有文件

js
export default {
  // 换行长度
  printWidth: 120,
  // 缩进长度
  tabWidth: 2,
  // 使用制表符,而不是空格缩进
  useTabs: false,
  // 结尾用分号
  semi: true,
  // 单引号
  singleQuote: true,
  // 在对象字面量中决定是否将属性名用引号括起来
  quoteProps: 'as-needed',
  // 在jsx中使用双引号
  jsxSingleQuote: false,
  // 多行时尽可能打印尾括号
  trailingComma: 'none',
  // 使用分号
  bracketSpacing: true,
  // 在对象字面量中,是否将括号放在同一行
  bracketSameLine: false,
  // 箭头函数的参数是否用括号括起来
  arrowParens: 'avoid',
  // 指定要使用的解析器
  requirePragma: false,
  // 是否在文件顶部插入 @format 注释
  insertPragma: false,
  // 用于控制换行的行为
  proseWrap: 'preserve',
  htmlWhitespaceSensitivity: 'css',
  // 控制vue单文件组件中的script和style标签是否缩进
  vueIndentScriptAndStyle: false,
  // 换行符使用if结尾
  endOfLine: 'auto',
  // 用于格式化以给定字符偏移量
  rangeStart: 0,
  rangeEnd: Infinity
};
bash
dist
node_modules
.local
public
pnpm-lock.yaml
json
{
  "scripts": {
    "lint:prettier": "prettier --write \"**/*.{js,jsx,ts,tsx,vue,html,css,scss,md}\""
  }
}

ESLint

安装依赖 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
Prettier集成eslint-plugin-prettier,eslint-config-prettier
Vue.js 支持eslint-plugin-vue

随后添加eslint.config.js文件

js
import {defineConfig} from 'eslint/config';
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginPrettier from 'eslint-plugin-prettier';
import eslintPluginVue from 'eslint-plugin-vue';
import globals from 'globals';

// 忽略的文件
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', // ECMAScript 版本
      sourceType: 'module', // 模块化类型
      parser: tseslint.parser // 解析器
    },
    rules: {
      // 自定义规则
      // 禁止使用 var
      'no-var': 'error'
    }
  },
  // 前端配置
  {
    ignores,
    files: [
      'app/frontend/**/*.{js,jsx,ts,tsx,vue}',
      'packages/components/**/*.{js,jsx,ts,tsx,vue}'
    ],
    extends: [
      ...eslintPluginVue.configs['flat/recommended'],
      eslintConfigPrettier
    ],
    languageOptions: {
      globals: {
        ...globals.browser
      }
    }
  },
  // 后端配置
  {
    ignores,
    files: ['apps/backend/**/*.{js,jsx,ts,tsx}'],
    languageOptions: {
      globals: {
        ...globals.node
      }
    }
  }
);

随后执行命令pnpm eslint,在前面的配置中,我们已经定义了no-var规则,所以我用var定义一个变量之后看效果

bash
PS D:\xxxx\monorepo> pnpm eslint

D:\xxxx\monorepo\apps\frontend\src\index.ts
  2:1  error  Unexpected var, use let or const instead  no-var

拼写检查

还是先安装依赖:cspell 官方文档

bash
pnpm -Dw add cspell @cspell/dict-lorem-ipsum

添加cspell.config.json文件,在配置文件当中配置了./.cspell/custom-dictionary.txt 文件,这个文件用来存放自定义的字典。同时在package.json文件中添加了cspell命令,执行pnpm cspell命令,即可检查所有文件的拼写错误。

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/**"
  ]
}
json
{
  "scripts": {
    "lint:spell": "cspell \"(packages|apps)/**/*.{js,jsx,ts,tsx,vue,html,css,scss,md}\""
  }
}

此时我在ts代码当中故意写一个phoness这个变量,运行pnpm cspell命令,可以看到有错误提示。

bash
> monorepo@1.0.0 lint:spell
> cspell "(packages|apps)/**/*.{js,jsx,ts,tsx,vue,html,css,scss,md}"

1/4 apps/backend/src/index.ts 523.64ms X
apps/backend/src/index.ts:2:7 - Unknown word (phoness)
CSpell: Files checked: 4, Issues found: 1 in 1 file.

Git提交规范

commitizen

先安装对应依赖 commitlint 官方文档

bash
pnpm -Dw add @commitlint/cli @commitlint/config-conventional commitizen cz-git
  • @commitlint/cli:是commitlint工具的核心
  • @commitlint/config-conventional:用于定义conventional commit的规范
  • commitizen:提供了一个交互式撰写commit信息的插件
  • cz-git:是国人开发的一款工具,工程性更强,自定义更高,交互性更好

添加commitlint.config.js配置文件,同时添加package.json文件中的commit 命令,之后当执行该命令的时候,会进入交互式命令行,根据提示填写信息,生成符合conventional commit规范的提交信息。在webstrom 或者vscode安装通义灵码的插件在提交的时候可以点一下让其自动生成提交内容,也比较符合conventional commit规范。

js
export default {
  extends: ['@commitlint/config-conventional'],
  // https://commitlint.js.org/reference/rules.html 所有的规则
  rules: {
    // 正文以空行开头
    'body-leading-blank': [2, 'always'],
    // 页脚以空行开头
    'footer-leading-blank': [1, 'always'],
    // 标题最大长度为 100 个字符
    'header-max-length': [2, 'always', 100],
    // 标题不能为空
    'subject-empty': [2, 'never'],
    // 类型不能为空
    'type-empty': [2, 'never'],
    // 标题必须为小写
    'subject-case': [0],
    // 类型必须为以下值之一
    'type-enum': [
      2,
      'always',
      [
        'feat', // 新功能
        'fix', // 修复 bug
        'docs', // 文档变更
        'style', // 代码格式(不影响代码运行的变动)
        'refactor', // 重构(即不是新增功能,也不是修复 bug 的代码变动)
        'perf', // 性能优化
        'test', // 增加测试
        'chore', // 构建过程或辅助工具的变动
        'revert' // 回退到上一个版本
      ]
    ]
  },
  prompt: {
    // 禁用提交时自动打开浏览器
    enableBrowser: false,
    types: [
      {value: 'feat', name: '新功能✨'},
      {value: 'fix', name: '修复bug🐛'},
      {value: 'docs', name: '文档变更📚'},
      {value: 'style', name: '代码格式💄'},
      {value: 'refactor', name: '重构♻️'},
      {value: 'perf', name: '性能优化⚡️'},
      {value: 'test', name: '增加测试✅'},
      {value: 'build', name: '构建系统📦'},
      {value: 'ci', name: '持续集成🔧'},
      {value: 'chore', name: '构建过程或辅助工具的变动'},
      {value: 'revert', name: '回退⏪'}
    ],
    scopes: ['root', 'backend', 'frontend', 'components', 'utils'],
    allowCustomScopes: true,
    skipQuestions: ['body', 'footerPrefix', 'footer', 'breaking'],
    message: {
      type: '请选择提交类型:',
      scope: '请选择本次提交所影响的范围:',
      subject: '请输入本次提交的描述:',
      body: '请输入本次提交的详细描述(可选):',
      footerPrefix: '请输入本次提交所关联的issue前缀(可选):',
      footer: '请输入本次提交所关联的issue(可选):',
      confirmCommit: '确认提交?'
    }
  }
};
json
{
  "scripts": {
    "commit": "git-cz"
  },
  "config": {
    "commitizen": {
      "path": "node_modules/cz-git"
    }
  }
}

husky

安装依赖同时初始化:husky 官方文档

bash
pnpm -Dw add husky
pnpm husky init

在执行了初始化命令后会出现.husky/pre-commit 文件,在前面对prettier、eslint、cspell进行了命名行配置,所以修改这个文件的内容,表示在提交之前执行这三个检查,只有当三个检查都通过了之后才能进行commit

bash
pnpm lint:prettier && pnpm lint:eslint && pnpm lint:spell

lint-staged

安装lint-staged lint-staged 官方文档

bash
pnpm -Dw add lint-staged

添加配置文件.lintstagedrc.js,同时添加package.json文件配置lint-staged命令

  • 配置文件的键表示为这些文件类型,值表示为执行哪些命令
  • package.json文件中配置precommit命令,表示在提交之前执行lint-staged命令,其中用git-cz 提交时,会自动执行pre-commit命令
js
export default {
  '**/*.{js,jsx,ts,tsx,vue,html,css,scss,md}': ['cspell lint'],
  '*.{js,ts,vue,md}': ['prettier --write', 'eslint']
};
json
{
  "scripts": {
    "commit": "git-cz",
    "precommit": "lint-staged"
  }
}

统一打包

用rollup来进行统一打包,rollup 官方文档

bash
pnpm -Dw add rollup

建立包依赖

以vue前端项目要使用utils工具包项目的依赖为例,只需要对utils进行打包,打包之后在vue项目的package.json 文件中添加如下依赖,其中@monorepo/utils表示当前项目下的utils模块,workspace:*表示当前项目下的utils模块的最新版本

json
{
  "dependencies": {
    "vue": "^3.5.22",
    "@monorepo/utils": "workspace:*"
  }
}

统一测试

  • jest
  • vitest
  • mocha

vitest

这里就是用vitest来进行测试,先安装依赖;vitest 官方文档,需要先安装一下vite,其中vite注意一下版本和node版本的对应关系

bash
pnpm -Dw add vite@6.0.0 vitest @vitest/browser vitest-browser-vue vue

添加一个vitest的配置文件vitest.config.js。这个暂时先放着,还没完整的研究完vitest

js
import {defineConfig} from 'vitest/config';

// vitest配置 https://cn.vitest.dev/config/
export default defineConfig({
  test: {
    // 匹配排除测试文件的 glob 规则
    exclude: [],
    // 匹配包含测试文件的 glob 规则
    include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)']
  }
});

最后在子包当中创建__tests__目录,用来存放测试文件,贴两个测试文件的代码

js
import { expect, it, test } from 'vitest';
import { add, minus } from '../src/math.ts';

test('adds 1 + 2 to equal 3', () => {
  expect(add(1, 2)).toBe(3);
});

test('subtracts 2 - 1 to equal 1', () => {
  expect(minus(2, 1)).toBe(1);
});

// 多线程并发
it.concurrent('subtracts 100 - 100 to equal 0', () => {
  expect(minus(100, 100)).toBe(0);
  expect(add(100, 100)).toBe(200);
  expect(minus(100, 100)).toBe(0);
  expect(add(100, 100)).toBe(200);
  expect(minus(200, 100)).toBe(100);
});
ts
import { expect, test } from 'vitest';
import { hello } from '../src/string';

test('hello world', () => {
  expect(hello()).toBe('hello world');
});

By Modify.