主题
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:prettierESLint
- 安装依赖
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-ipsumcspell: 核心拼写库 @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
- 通过命令行交互式构建commit-msg,保证工程提交信息格式统一,提高提交的可追溯性
- 对手动写的提交信息(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
- 创建
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 提取公共的包,便于后续维护
