你是否曾被这些问题困扰?
- 管理多个相互关联的 Git 仓库(或是复杂的 git submodules),心力交瘁。
- 想在项目 A 中复用项目 B 的组件或工具函数,只能复制粘贴或者发布成 npm 包,流程繁琐。
- 多个项目依赖同一个库,版本不一致导致“依赖地狱”。
- 每次进行一个跨项目的需求,需要在多个仓库中提交代码、创建 PR,联调测试苦不堪言。
如果你对以上场景感同身受,无论是组件库,框架,或是日常的大型项目,不妨试试 Monorepo。
到底什么是 Monorepo?
想必大家听过这个概念无数次了
Monorepo(Monolithic Repository),直译为“单体仓库”,是一种将多个独立的项目、包(package)或应用(app)存放在同一个代码仓库中进行管理的代码组织策略。
与之相对的是 Polyrepo(Multiple Repositories),也就是我们传统的多仓库管理模式,每个项目都有自己独立的 Git 仓库。
| 对比项 | Monorepo(单体仓库) | Polyrepo(多仓库) |
|---|---|---|
| 代码组织 | 所有项目在一个仓库中 | 每个项目一个独立仓库 |
| 依赖管理 | 根目录统一管理,易于保持版本一致 | 各项目独立管理,易产生版本冲突 |
| 代码复用 | 极为方便,通过工作区(workspace)直接引用 | 需发布 npm 包或 Git Submodule,流程复杂 |
| 原子提交 | 一次提交可跨越多个项目,保证原子性 | 无法实现跨项目的原子提交 |
| 构建与部署 | 工具链复杂,但可实现统一构建和按需部署 | 各项目独立,简单直接 |
| 协作 | 团队成员可见所有代码,便于协作和代码审查 | 边界清晰,便于权限管理 |
一个常见的误解:Monorepo ≠ 单体应用(Monolith)
- Monorepo 是一种 代码组织方式。仓库里可以包含多个独立的、可独立部署的应用。
- 单体应用 是一种 软件架构模式。它指的是将所有功能模块打包成一个单一的、不可分割的部署单元。
例如在 Monorepo 中,我们可以同时管理一个 React 主应用、一个 Vue 管理后台、一个共享的 UI 组件库和一个通用的工具函数库。它们虽然在同一个仓库,但架构上是解耦的,可以独立开发、测试和部署。
为什么选择 Monorepo?
优势
- 极致的代码复用与共享:这是 Monorepo 最核心的优势。UI 组件库、工具函数、TS 类型定义等可以作为本地包,被仓库内的任何应用直接引用,无需发布到 npm。修改后立即生效,开发体验如丝般顺滑。
- 简化的依赖管理:所有项目共享同一个
node_modules(或其变体),借助pnpm等工具可以有效解决依赖版本冲突问题,保证环境一致性。 - 原子化的提交(Atomic Commits):当一个功能需要同时修改前端应用和其依赖的组件库时,可以在一次提交中完成所有更改。这让代码历史追溯和回滚变得异常清晰。
- 统一的工具链与标准化:可以在仓库根目录配置一次
ESLint,Prettier,TypeScript,Jest等,所有子项目共同遵守,确保了代码风格和质量的统一。 - 提升团队协作:代码透明度高,便于团队成员进行跨项目的 Code Review 和知识共享。
挑战
- 工具链复杂度:需要引入 Lerna, Nx, Turborepo 等专门的工具来管理工作区、任务调度和构建缓存,有一定的学习成本。
- 性能问题:当仓库变得非常巨大时,
git clone,git status等命令可能会变慢。不过现代工具正在努力解决这个问题。 - 权限控制:默认情况下,所有人都拥有所有代码的访问权限。对于需要精细化权限控制的团队,需要借助如
GitLab/GitHub CODEOWNERS等功能。
总的来说,对于需要高度协作、代码共享频繁的前端团队,Monorepo 带来的收益远大于其挑战。
选择合适的 Monorepo 工具
工欲善其事,必先利其器。现代 Monorepo 生态已经非常成熟,以下是几个主流工具:
- 包管理器(必须):
- pnpm: 目前 Monorepo 的首选。它通过符号链接(symlinks)和内容寻址存储来高效管理
node_modules,天生支持workspace(工作区)协议,完美契合 Monorepo 场景。 npm(v7+) /yarn(v2+):也都支持workspace,但pnpm在性能和磁盘空间占用上更具优势。- 任务编排与构建系统(强烈推荐):
- Turborepo: 由 Vercel(Next.js 的母公司)出品,主打“高性能构建系统”。它通过智能任务调度和远程缓存,可以极大地提升 CI/CD 和本地开发的速度。简单、快速、易于上手,是目前的热门选择。
- Nx: 功能极其强大且全面的 Monorepo 工具集。除了 Turborepo 的功能外,还提供了代码生成、依赖图可视化、插件生态等企业级功能,但配置也相对复杂。
这篇文章将教你从基础 pnpm workspaces,到引入 truborepo 加速构建,再使用自建缓存代替 Vercel Remote Cache。
熟悉使用
下面是 pnpm 在 Monorepo 中常用的基本操作:
初始化 Monorepo
首先,你需要一个项目根目录。在根目录下创建 pnpm-workspace.yaml 文件,这是 pnpm 识别 Monorepo 的关键。
# 在项目根目录
mkdir my-monorepo
cd my-monorepo
# 创建 pnpm-workspace.yaml
touch pnpm-workspace.yaml
pnpm-workspace.yaml 文件定义了你的工作区(workspace)包含哪些子包。
pnpm-workspace.yaml 示例:
packages:
# 匹配 packages/ 目录下的所有子文件夹作为包
- 'packages/*'
# 匹配 apps/ 目录下的所有子文件夹作为包
- 'apps/*'
# 如果你的包在根目录下,也可以直接指定
# - 'foo'
创建子包(Packages)
在 pnpm-workspace.yaml 中定义的路径下创建你的子包。例如,如果你设置了 packages/*,那么可以在 packages 目录下创建 package-a 和 package-b。
mkdir packages
mkdir packages/package-a
mkdir packages/package-b
# 在每个子包中初始化 package.json
cd packages/package-a
pnpm init -y
cd ../package-b
pnpm init -y
cd ../.. # 回到 Monorepo 根目录
安装依赖
在 Monorepo 根目录运行 pnpm install 会安装所有子包的依赖,并且 pnpm 会自动识别并符号链接(symlink)工作区内的互相依赖。
# 在 Monorepo 根目录
pnpm install
添加/移除依赖
添加通用依赖(安装到所有子包)
如果你想在所有子包中添加相同的依赖,可以使用 -w 或 --workspace-root 参数在根目录操作,但通常这不常用。更常见的是给特定子包添加依赖。
# 在 Monorepo 根目录安装依赖到根 package.json (通常用于工具,如eslint, prettier等)
pnpm add <dependency-name> -w
添加特定子包依赖
进入子包目录,像普通项目一样添加依赖。pnpm 会智能地处理依赖关系。
# 例如,给 package-a 添加 react 依赖
cd packages/package-a
pnpm add react
# 给 package-b 添加 lodash 依赖
cd ../package-b
pnpm add lodash
添加工作区内部依赖
当一个子包需要依赖 Monorepo 内的另一个子包时,可以直接使用子包的名称(即 package.json 中的 name 字段)作为依赖。
假设 package-a 的 name 是 @my-monorepo/package-a,package-b 的 name 是 @my-monorepo/package-b。
# 在 package-b 中添加对 package-a 的依赖
cd packages/package-b
pnpm add @my-monorepo/package-a
# 这会在 package-b 的 package.json 中添加 `"@my-monorepo/package-a": "workspace:^1.0.0"` 这样的依赖
# "workspace:" 协议告诉 pnpm 这是一个工作区内部的依赖
提示: 使用 workspace:* 或 workspace:^ 可以更好地管理内部依赖的版本。pnpm 默认会使用 workspace:^。
移除依赖
与添加依赖类似,进入子包目录或在根目录使用 -w。
# 在 package-a 中移除 react
cd packages/package-a
pnpm remove react
运行脚本
在 Monorepo 中,你可以从根目录运行特定子包的脚本,也可以运行所有子包的通用脚本。
运行特定子包的脚本
使用 -F 或 --filter 参数指定要运行脚本的子包。
# 运行 package-a 的 build 脚本
pnpm --filter package-a build
# 运行多个子包的 build 脚本
pnpm --filter package-a --filter package-b build
# 使用通配符运行符合条件的包的脚本
pnpm --filter 'packages/*' build
运行所有子包的脚本
pnpm -r 或 pnpm recursive 命令可以在所有工作区包中运行指定的脚本。
# 运行所有子包的 test 脚本
pnpm -r test
发布子包
发布子包时,你需要进入相应的子包目录进行操作。
# 进入要发布的子包目录
cd packages/package-a
# 发布(请确保在发布前登录 npm)
pnpm publish
7. 一些有用的 pnpm 命令
pnpm ls -r:列出所有工作区包及其依赖。pnpm outdated -r:检查所有工作区包的过时依赖。pnpm up -r:更新所有工作区包的依赖。pnpm store prune:清理本地pnpm存储,删除未引用的包。
实战:从零搭建一个前端 Monorepo
好吧,光说不做没有任何作用,讲这些东西没啥意思,csdn 分分钟给我抄走,ai 几秒钟就能生成,咱们实践才能出真知,Let’s get our hands dirty
我们的目标是建一个 Vue3 组件库,包含 storybook 文档站 单测 cypress 端测。
核心技术栈选择:
- Vue 3: 利用 Composition API 和更好的性能。
- TypeScript: 为组件库提供类型安全和更好的开发体验。
- Vite: 用于组件库的构建和 Storybook 的开发服务器,速度快。
- pnpm / yarn / npm (with workspaces): 推荐 pnpm 或 yarn workspaces 来管理 monorepo。这里以 pnpm 为例,因为它对 monorepo 支持良好且高效。
- Storybook: 用于组件的交互式开发、文档和展示。
- Vitest: 用于单元/组件测试,与 Vite 集成良好。
- Vue Test Utils: Vue 官方的组件测试库。
- Cypress: 用于端到端 (E2E) 测试。
- ESLint & Prettier: 代码规范和格式化。
- Husky & lint-staged: Git 钩子,在提交前自动检查和格式化代码。
- Turborepo: 一个优秀的高性能构建系统,用于 JavaScript/TypeScript monorepos。
❯ tree -I node_modules -L 3 .
.
├── apps
│ ├── docs
│ │ ├── api-examples.md
│ │ ├── components
│ │ ├── guide
│ │ ├── index.md
│ │ ├── markdown-examples.md
│ │ ├── package.json
│ │ ├── postcss.config.js
│ │ └── tailwind.config.js
│ └── storybook
│ ├── package.json
│ └── tsconfig.json
├── cypress
│ ├── cypress
│ │ └── screenshots
│ ├── cypress.config.ts
│ ├── e2e
│ │ └── button.cy.ts
│ ├── fixtures
│ │ └── example.json
│ ├── package.json
│ ├── support
│ │ ├── commands.ts
│ │ └── e2e.ts
│ └── tsconfig.json
├── cypress.config.ts
├── eslint.config.js
├── package.json
├── packages
│ └── components
│ ├── components.d.ts
│ ├── dist
│ ├── package.json
│ ├── postcss.config.js
│ ├── README.md
│ ├── src
│ ├── tailwind.config.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.json
│ └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── tsconfig.eslint.json
└── tsconfig.json
16 directories, 31 files
环境准备与项目初始化
我们这里就用我自己刚刚开始写的项目 amore-ui 为例。
确保你已经安装了 Node.js (我建议还是最新 lts v22 吧)。然后全局安装 pnpm:
npm install -g pnpm
现在,创建我们的项目:
初始化项目和 Monorepo (使用 pnpm):
mkdir amore-ui
cd amore-ui
pnpm init # 创建根 package.json
touch pnpm-workspace.yaml
编辑 pnpm-workspace.yaml:
packages:
- 'packages/*'
- 'apps/*' # 单独的应用,如 Storybook 或文档站
- 'cypress' # 把 cypress 也看作一个包,方便测试
在根目录安装通用开发依赖:
pnpm add -Dw typescript eslint prettier eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin husky lint-staged vue # -Dw 表示安装到根目录的 devDependencies
创建主组件库
先从我们的组件库开始!
创建组件库包 (packages/components):
mkdir -p packages/components/src/components
cd packages/components
pnpm init
安装组件库特定依赖:
# -F 或 --filter 指定在哪个包内执行命令
pnpm -F components add vue
pnpm -F components add -D vite @vitejs/plugin-vue vite-plugin-dts typescript sass # sass 是可选的
配置 packages/components/vite.config.ts (用于库构建):
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import path from 'path';
import Components from 'unplugin-vue-components/vite';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Treat all tags with a-prefix as custom elements
isCustomElement: (tag) => tag.startsWith('a-'),
},
},
}),
Components({
// Automatically register components with pattern matching
// This will transform kebab-case tags to PascalCase components
resolvers: [
// custom resolver for our component library
(name) => {
// Convert a-button -> AButton, a-input -> AInput, etc.
if (name.startsWith('A') && /[A-Z]/.test(name.charAt(1))) {
const componentName = name;
// const _ = name
// .replace(/([A-Z])/g, '-$1')
// .toLowerCase()
// .substring(1); // Remove first dash
return { name: componentName, from: 'amore-ui' };
}
},
],
// Support for custom component naming convention
directoryAsNamespace: false,
dts: true,
}),
dts({
// 生成 .d.ts 类型声明文件
insertTypesEntry: true,
copyDtsFiles: false, // 如果你有多层目录结构,可能需要设为 true
}),
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'AmoreUI', // UMD 构建的全局变量名
fileName: (format) => `amore-ui.${format}.js`,
formats: ['es', 'umd', 'cjs'], // 构建的格式
},
rollupOptions: {
// 确保外部化处理那些你不想打包进库的依赖
external: ['vue'],
output: {
// 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
globals: {
vue: 'Vue',
},
},
},
sourcemap: true,
emptyOutDir: true,
},
// @ts-ignore
test: {
// Vitest 配置
globals: true,
environment: 'happy-dom', // 或 'jsdom'
// setupFiles: ['./vitest.setup.ts'], // 可选的 setup 文件
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
配置 packages/components/package.json:
{
"name": "amore-ui",
"version": "0.0.5",
"keywords": [
"vue",
"vue3",
"components",
"ui library",
"typescript",
"vite"
],
"author": {
"name": "grtsinry43",
"email": "[email protected]",
"url": "https://www.grtsinry43.com"
},
"repository": {
"type": "git",
"url": "https://github.com/amore-ui/amore-ui.git",
"directory": "packages/components"
},
"license": "MIT",
"private": false,
"description": "A Vue 3 Component Library Born from Passion, including a set of awesome components for building modern web applications.",
"type": "module",
"main": "./dist/amore-ui.umd.js",
"module": "./dist/amore-ui.es.js",
"types": "./dist/index.d.ts",
"exports": { // 这里用来配置打包之后对外导出的文件
".": {
"import": "./dist/amore-ui.es.js",
"require": "./dist/amore-ui.umd.js",
"types": "./dist/index.d.ts"
},
"./style.css": "./dist/amore-ui.css"
},
"files": [
"dist"
],
"scripts": {
"dev": "vite",
"build": "vite build && vue-tsc --declaration --emitDeclarationOnly",
"lint": "eslint . --ext .vue,.js,.ts,.jsx,.tsx --fix",
"format": "prettier --write src/",
"test": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage",
"test:watch": "vitest watch",
"prepublishOnly": "pnpm run build && pnpm run test"
},
"peerDependencies": {
"vue": "^3.5.14"
},
"packageManager": "[email protected]",
"dependencies": {
"@tailwindcss/vite": "^4.1.7"
},
"devDependencies": {
"@types/node": "^22.15.21",
"@vitejs/plugin-vue": "^5.2.4",
"@vitest/ui": "3.1.4",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"happy-dom": "^17.4.7",
"postcss": "^8.5.3",
"sass": "^1.89.0",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"unplugin-vue-components": "^28.5.0",
"vite": "^6.3.5",
"vite-plugin-dts": "^4.5.4",
"vitest": "^3.1.4"
}
}
创建 packages/components/src/index.ts:
// 例如:导出 Button 组件
export { default as MyButton } from './components/Button/Button.vue';
// 如果 Button.vue 有自己的 index.ts (推荐)
// export * from './components/Button';
// 如果你有全局样式,可以在这里导入,并在 vite.config.ts 中配置提取
// import './styles/main.scss';
创建示例组件 packages/components/src/components/Button/Button.vue:
<template>
<button class="my-button" :type="type" @click="$emit('click', $event)">
<slot></slot>
</button>
</template>
<script setup lang="ts">
defineProps({
type: {
type: String as () => 'button' | 'submit' | 'reset',
default: 'button',
},
});
defineEmits(['click']);
</script>
<style lang="scss" scoped>
.my-button {
padding: 8px 16px;
border-radius: 4px;
background-color: #42b983;
color: white;
border: none;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #3aa373;
}
}
</style>
创建 Storybook 应用
Storybook 是组件库开发使用的利器!
作为应用,我们将其放置在 app/ 目录,仅需在你建好的文件夹中执行
pnpm create storybook@latest
之后修改 .storybook/main.js
import path from 'path';
import { fileURLToPath } from 'url';
import { mergeConfig } from 'vite';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
// 获取当前 main.js 文件的目录路径
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // 这是 .storybook 目录
const config = {
// 故事文件的路径现在是相对于 apps/storybook 目录的
// 我们要指向 packages/components/src
stories: [
'../../../packages/components/src/**/*.mdx',
'../../../packages/components/src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-a11y',
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
// Vite 配置调整,路径也需要相应调整
async viteFinal(config) {
config.resolve = config.resolve || {};
config.resolve.alias = config.resolve.alias || {};
// 路径别名 '@' 指向 packages/components/src
// path.resolve(__dirname, '../../../packages/components/src')
// __dirname 是 apps/storybook/.storybook
// ../ -> apps/storybook/
// ../../ -> apps/
// ../../../ -> amore-ui/ (项目根目录)
config.resolve.alias['@'] = path.resolve(__dirname, '../../../packages/components/src');
// 让 stories 文件可以直接通过包名导入组件
const componentsPackageName = 'amore-ui';
config.resolve.alias[componentsPackageName] = path.resolve(
__dirname,
'../../../packages/components/src/index.ts'
);
return mergeConfig(config, {
plugins: [
// 引入并使用插件
import('@vitejs/plugin-vue').then((m) => m.default()),
],
});
},
};
export default config;
创建组件的 Story (packages/components/src/components/Button/Button.stories.ts):
import type { Meta, StoryObj } from '@storybook/vue3';
import MyButton from './Button.vue'; // 直接引用组件
// 或者 import { MyButton } from 'amore-ui'; // 如果配置了 alias
const meta: Meta<typeof MyButton> = {
title: 'Components/MyButton', // Storybook 中的路径
component: MyButton,
tags: ['autodocs'], // 开启自动文档
argTypes: {
// onClick: { action: 'clicked' }, // 如果需要手动配置事件监听
type: {
control: { type: 'select' },
options: ['button', 'submit', 'reset'],
},
},
args: { // 默认 props
default: 'Click Me', // slot 内容
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
// Props for this story
},
render: (args) => ({
components: { MyButton },
setup() {
return { args };
},
template: '<MyButton v-bind="args">{{ args.default }}</MyButton>',
}),
};
export const SubmitButton: Story = {
args: {
type: 'submit',
default: 'Submit Form',
},
render: (args) => ({
components: { MyButton },
setup() {
return { args };
},
template: '<MyButton v-bind="args">{{ args.default }}</MyButton>',
}),
};
修改根 package.json 的 scripts:
{
// ...
"scripts": {
"dev:storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"build:components": "pnpm --filter amore-ui build", // 根据你的包名调整
// ...其他脚本
}
}
[!info]
上文的注释提到,这里详细解释一下,
-f,即 filter,意为过滤器,也就是在对应的仓库中执行,-f 之后跟随的仓库名就是你package.json中为每个模块配置的名字,利用这种功能,我们可以为主仓库添加很多模块:命令的快捷命令
现在可以运行 pnpm dev:storybook 来启动 Storybook。
设置 Vitest (单元/组件测试):
好的项目通常都有高的单元测试覆盖率
在 packages/components 包中安装 Vitest 和 Vue Test Utils:
pnpm -F amore-ui add -D vitest @vue/test-utils happy-dom # happy-dom 或 jsdom 用于模拟 DOM
配置 packages/components/vite.config.ts (添加 test 配置): (在现有 defineConfig 内添加 test 字段)
// ... (imports 和其他配置)
export default defineConfig({
// ... plugins, build, resolve ...
test: { // Vitest 配置
globals: true,
environment: 'happy-dom', // 或 'jsdom'
setupFiles: ['./vitest.setup.ts'], // 可选的 setup 文件
},
});
创建 packages/components/vitest.setup.ts (可选):
// import { config } from '@vue/test-utils';
// config.global.plugins = [/* ... */]; // 全局插件或配置
在 packages/components/package.json 添加测试脚本:
{
"scripts": {
// ...
"test": "vitest",
"test:ui": "vitest --ui", // 带 UI 的测试
"coverage": "vitest run --coverage"
}
}
写一个测试 (packages/components/src/components/Button/Button.test.ts):
import { describe, it, expect, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import MyButton from './Button.vue';
describe('MyButton.vue', () => {
it('renders slot content', () => {
const wrapper = mount(MyButton, {
slots: {
default: 'Test Button',
},
});
expect(wrapper.text()).toContain('Test Button');
});
it('emits click event when clicked', async () => {
const wrapper = mount(MyButton);
const emitSpy = vi.spyOn(wrapper.emitted(), 'click'); // 不推荐这种方式了
// 更好的方式是直接检查 wrapper.emitted()
await wrapper.trigger('click');
expect(wrapper.emitted()).toHaveProperty('click');
expect(wrapper.emitted().click).toHaveLength(1);
});
it('has correct type attribute', () => {
const wrapper = mount(MyButton, {
props: {
type: 'submit',
},
});
expect(wrapper.attributes('type')).toBe('submit');
});
});
运行 pnpm -F amore-ui test。
设置 Cypress (E2E 测试):
趁热打铁,让我们继续!接下来是端到端测试
# 在项目根目录
mkdir cypress
cd cypress
pnpm init # 创建 cypress/package.json
cd ..
# 安装 Cypress
pnpm -F cypress add -D cypress
# (这里把 cypress 目录看作一个独立的包)
配置 cypress/cypress.config.ts:
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:6006', // Storybook 的地址
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.ts',
setupNodeEvents(on, config) {
// implement node event listeners here
},
},
component: { // 如果你也想用 Cypress 进行组件测试 (不同于 Vitest 的单元/集成测试)
devServer: {
framework: 'vue',
bundler: 'vite',
},
specPattern: 'packages/components/src/**/*.cy.{js,jsx,ts,tsx}', // 指向组件的测试文件
},
});
在根 package.json 添加 Cypress 脚本:
{
"scripts": {
// ...
"cy:open": "cypress open",
"cy:run": "cypress run",
"test:e2e": "pnpm dev:storybook & pnpm cy:run --headed; pkill -f storybook", // 简单示例,实际CI中需要更健壮的启动和停止
"test:e2e:ci": "pnpm build:storybook && start-server-and-test dev:storybook-static http-get://localhost:6006 cy:run" // 需安装 start-server-and-test
}
}
start-server-and-test是一个有用的 npm 包,可以帮你启动服务器,等待它响应,然后运行测试,最后关闭服务器。pnpm add -Dw start-server-and-test。dev:storybook-static脚本可以是你构建 Storybook 后用http-server或类似工具启动静态文件的命令,例如:pnpm build:storybook && http-server storybook-static -p 6006。
创建 E2E 测试 (cypress/e2e/button.cy.ts):
describe('MyButton in Storybook', () => {
beforeEach(() => {
// 访问 Button 在 Storybook 中的 Primary story
// URL 结构可能是 /iframe.html?id = components-mybutton--primary&viewMode = story
// 请根据你的 Storybook URL 调整
cy.visit('/iframe.html?id=components-mybutton--primary&viewMode=story');
});
it('should display the button with correct text', () => {
cy.get('.my-button').should('be.visible').and('contain.text', 'Click Me');
});
it('should change background on hover (visual test or check class if applicable)', () => {
// Cypress 不擅长直接测试 : hover 状态的样式,但可以触发 hover
// cy.get('.my-button').trigger('mouseover');
// 如果 hover 改变了 class 或者有其他 DOM 变化,可以断言
// 或者结合 Percy / Applitools 进行视觉回归测试
});
});
确保 Storybook 在 http://localhost:6006 运行,然后执行 pnpm cy:open。
Okok,到这里大家可能都看累了或者是感觉无聊,我们来小小整理下我们有了什么命令呢:
"build:components": "pnpm --filter amore-ui build",
"dev:storybook": "pnpm --filter storybook dev",
"build:storybook": "pnpm --filter storybook build",
"cy:open": "pnpm --filter cypress cy:open",
"cy:run": "pnpm --filter cypress cy:run",
"test:unit": "pnpm --filter amore-ui test",
"test:e2e": "start-server-and-test dev:storybook http://localhost:6006 cy:run",
可以休息下,下面我们继续
创建文档站
这部分很简单,我们依然靠 Vite 来实现
就像往常一样建好你的文档站,之后…
import { defineConfig } from 'vitepress';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Components from 'unplugin-vue-components/vite';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); // 这是 .vitepress 目录
export default defineConfig({
title: 'My Vue Component Library', // 站点标题
description: 'Awesome Vue components built with love.',
base: '/amore-ui/', // 如果部署到 GitHub Pages 的子路径
themeConfig: {
logo: '/logo.svg', // (可选) 放置在 docs/public/logo.svg
nav: [ // 顶部导航
{ text: '指南', link: '/guide/getting-started' },
{ text: '组件', link: '/components/button' },
{ text: 'GitHub', link: 'https://github.com/your-repo' },
],
sidebar: { // 侧边栏
'/guide/': [
{
text: '入门',
items: [
{ text: '简介', link: '/guide/introduction' },
{ text: '快速上手', link: '/guide/getting-started' },
],
},
],
'/components/': [
{
text: '基础组件',
items: [
{ text: 'Button 按钮', link: '/components/button' },
{ text: 'Input 输入框', link: '/components/input' },
// ... 其他组件
],
},
],
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/your-repo' },
],
footer: {
message: 'Released under the MIT License.',
copyright: 'Copyright © 2024-present Your Name',
},
},
// Markdown 配置
markdown: {
// theme: 'material-palenight', // (可选) 代码高亮主题
lineNumbers: true, // (可选) 显示代码块行号
},
// Vite 特定配置 (重要!用于解析你的组件库)
vite: {
resolve: {
alias: {
'amore-ui': path.resolve(__dirname, '../../../packages/components/src'),
},
},
plugins: [
// 使用 unplugin-vue-components 进行自动组件导入
Components({
// 自动导入组件
dirs: [],
// 自定义组件解析器
resolvers: [
// 自定义解析 a- 前缀的组件
(name) => {
// 如果组件名是以 A 开头的,如 AButton
if (name.startsWith('A') && /[A-Z]/.test(name.charAt(1))) {
return { name, from: 'amore-ui' };
}
// 如果是 kebab-case 形式的组件名 (a-button, a-input)
const kebabMatch = name.match(/^a-(.+)$/);
if (kebabMatch) {
// 转换 a-button 到 AButton
const componentName = 'A' + kebabMatch[1].charAt(0).toUpperCase() + kebabMatch[1].slice(1);
return { name: componentName, from: 'amore-ui' };
}
}
],
// 在这里添加自定义组件
include: [/\.vue$/, /\.vue\?vue/, /\.md$/],
dts: path.resolve(__dirname, './components.d.ts'),
}),
],
},
});
我这里使用了 unplugin-vue-components,当然你也可以从工作区直接导入打包之后的产物
到此为止,这个 monorepo 已经初具形态了。
ESLint, Prettier, Husky, lint-staged:
好的代码建立在规范之上
我的项目一直都是 eslint error 模式+有 eslint/单测/e2e 测试不过就禁止 commit,也就是受虐模式
- Husky & lint-staged:
pnpm add -Dw husky lint-staged
npx husky init # 会创建 .husky 目录
.husky/pre-commit 内容:
npx lint-staged
# 如果想在提交前运行所有测试 (可能会很慢)
# pnpm test: all
在根 package.json 添加 lint-staged 配置:
{
// ...
"lint-staged": {
"*.{js,jsx,ts,tsx,vue}": "eslint --fix",
"*.{json,md,html,css,scss}": "prettier --write"
}
}
TypeScript 配置 (tsconfig.json):
太多了,贴不过来了,影响正常阅读
具体看我的仓库 amore-ui
引入 Turborepo 提升效率
目前,我们需要手动进入每个目录去运行命令。当项目变多时,这会变得很麻烦。Turborepo 可以帮我们统一管理和加速这些任务。
1. 在根目录安装 Turborepo
# -w 表示 --workspace-root,安装到根工作区
pnpm add turbo --save-dev -w
2. 配置 turbo.json
在项目根目录创建 turbo.json 文件:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
// "build" 任务依赖于其所有依赖包的 "build" 任务
"dependsOn": ["^build"],
// 构建产物在这些目录下,用于缓存
"outputs": ["dist/**", ".next/**"]
},
"lint": {},
"dev": {
// dev 任务的结果不缓存
"cache": false,
// 保持任务持续运行
"persistent": true
}
}
}
3. 在根 package.json 中添加脚本
// package.json (根目录)
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint"
}
现在,你可以从根目录统一运行命令了!
# 同时启动所有应用的 dev 服务
pnpm dev
# 构建所有应用和包
pnpm build
Turborepo 自建缓存
我对 Vercel 公司不喜欢也不讨厌,但是我希望自建一个缓存。
参考这个项目就好啦:
Monorepo 最佳实践
- 统一配置:将
ESLint,Prettier,tsconfig.json等配置文件放在packages目录下(如packages/eslint-config-custom,packages/tsconfig),然后让各个应用和包去继承这些配置,保持一致性。 - 明确的目录结构:
apps放应用,packages放可复用包,是一种广泛采纳的约定。 - 版本管理:使用如 Changesets 这样的工具来管理包的版本发布和生成
CHANGELOG,它与 Monorepo 配合得非常好。 - 精简根目录:保持根目录
package.json的dependencies干净,只存放对整个项目都至关重要的开发依赖(如turbo,typescript,prettier)。
尾声
恭喜你!你已经成功搭建并体验了一个现代化的前端 Monorepo 项目。
感觉这个例子选的不是很好啊,有点太上难度了,直接把我之前研究好久的架子全搬上来了,一般只有组件库或者大框架会这样操作了,不过基础的入门操作还是可以提升效率的🥹
从现在开始拥抱现代前端开发吧!