# 手把手带你玩转 Monorepo，拥抱现代前端开发新范式

原文：https://blog.grtsinry43.com/posts/monorepo-quick-guide/

作者：grtsinry43

发布时间：2025-07-21T06:03:33Z

内容更新时间：2025-07-21T06:03:33Z

> 你是否曾被这些问题困扰？
>
> *   管理多个相互关联的 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？

### 优势

1.  **极致的代码复用与共享**：这是 Monorepo 最核心的优势。UI 组件库、工具函数、TS 类型定义等可以作为本地包，被仓库内的任何应用直接引用，无需发布到 npm。修改后立即生效，开发体验如丝般顺滑。
2.  **简化的依赖管理**：所有项目共享同一个 `node_modules`（或其变体），借助 `pnpm` 等工具可以有效解决依赖版本冲突问题，保证环境一致性。
3.  **原子化的提交（Atomic Commits）**：当一个功能需要同时修改前端应用和其依赖的组件库时，可以在一次提交中完成所有更改。这让代码历史追溯和回滚变得异常清晰。
4.  **统一的工具链与标准化**：可以在仓库根目录配置一次 `ESLint`, `Prettier`, `TypeScript`, `Jest` 等，所有子项目共同遵守，确保了代码风格和质量的统一。
5.  **提升团队协作**：代码透明度高，便于团队成员进行跨项目的 Code Review 和知识共享。

### 挑战

1.  **工具链复杂度**：需要引入 Lerna, Nx, Turborepo 等专门的工具来管理工作区、任务调度和构建缓存，有一定的学习成本。
2.  **性能问题**：当仓库变得非常巨大时，`git clone`, `git status` 等命令可能会变慢。不过现代工具正在努力解决这个问题。
3.  **权限控制**：默认情况下，所有人都拥有所有代码的访问权限。对于需要精细化权限控制的团队，需要借助如 `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 的关键。

```bash
# 在项目根目录
mkdir my-monorepo
cd my-monorepo

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

`pnpm-workspace.yaml` 文件定义了你的工作区（workspace）包含哪些子包。

**`pnpm-workspace.yaml` 示例：**

```yaml
packages:
  # 匹配 packages/ 目录下的所有子文件夹作为包
  - 'packages/*'
  # 匹配 apps/ 目录下的所有子文件夹作为包
  - 'apps/*'
  # 如果你的包在根目录下，也可以直接指定
  # - 'foo'
```

### 创建子包（Packages）

在 `pnpm-workspace.yaml` 中定义的路径下创建你的子包。例如，如果你设置了 `packages/*`，那么可以在 `packages` 目录下创建 `package-a` 和 `package-b`。

```bash
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）工作区内的互相依赖。

```bash
# 在 Monorepo 根目录
pnpm install
```

### 添加/移除依赖

#### 添加通用依赖（安装到所有子包）

如果你想在所有子包中添加相同的依赖，可以使用 `-w` 或 `--workspace-root` 参数在根目录操作，但通常这不常用。更常见的是给特定子包添加依赖。

```bash
# 在 Monorepo 根目录安装依赖到根 package.json (通常用于工具，如eslint, prettier等)
pnpm add <dependency-name> -w
```

#### 添加特定子包依赖

进入子包目录，像普通项目一样添加依赖。`pnpm` 会智能地处理依赖关系。

```bash
# 例如，给 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`。

```bash
# 在 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`。

```bash
# 在 package-a 中移除 react
cd packages/package-a
pnpm remove react
```

###  运行脚本

在 Monorepo 中，你可以从根目录运行特定子包的脚本，也可以运行所有子包的通用脚本。

#### 运行特定子包的脚本

使用 `-F` 或 `--filter` 参数指定要运行脚本的子包。

```bash
# 运行 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` 命令可以在所有工作区包中运行指定的脚本。

```bash
# 运行所有子包的 test 脚本
pnpm -r test
```

### 发布子包

发布子包时，你需要进入相应的子包目录进行操作。

```bash
# 进入要发布的子包目录
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。

```bash
❯ 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](https://nodejs.org/) (我建议还是最新 lts v22 吧)。然后全局安装 pnpm：

```bash
npm install -g pnpm
```

现在，创建我们的项目：

**初始化项目和 Monorepo (使用 pnpm):**

``` bash
mkdir amore-ui
cd amore-ui
pnpm init # 创建根 package.json
touch pnpm-workspace.yaml
```

编辑 `pnpm-workspace.yaml`:

``` yaml
packages:
  - 'packages/*'
  - 'apps/*' # 单独的应用，如 Storybook 或文档站
  - 'cypress' # 把 cypress 也看作一个包，方便测试
```

在根目录安装通用开发依赖：

``` bash
pnpm add -Dw typescript eslint prettier eslint-plugin-vue @typescript-eslint/parser @typescript-eslint/eslint-plugin husky lint-staged vue # -Dw 表示安装到根目录的 devDependencies
```

### 创建主组件库

先从我们的组件库开始！

**创建组件库包 (`packages/components`):**

```bash
mkdir -p packages/components/src/components
cd packages/components
pnpm init
```

安装组件库特定依赖：

```bash
# -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` (用于库构建):**

```typescript
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`:**

```json
{
  "name": "amore-ui",
  "version": "0.0.5",
  "keywords": [
    "vue",
    "vue3",
    "components",
    "ui library",
    "typescript",
    "vite"
  ],
  "author": {
    "name": "grtsinry43",
    "email": "grtsinry43@outlook.com",
    "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": "pnpm@10.11.0",
  "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`:**

```typescript
// 例如：导出 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`:**

```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`

```javascript
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`):**

```typescript
    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:**

```json
    {
      // ...
      "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:

```bash
    pnpm -F amore-ui add -D vitest @vue/test-utils happy-dom # happy-dom 或 jsdom 用于模拟 DOM
```

**配置 `packages/components/vite.config.ts` (添加 test 配置):**
(在现有 `defineConfig` 内添加 `test` 字段)

```typescript
    // ... (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` (可选):**

```typescript
    // import { config } from '@vue/test-utils';
    // config.global.plugins = [/* ... */]; // 全局插件或配置
```

**在 `packages/components/package.json` 添加测试脚本:**

```json
    {
      "scripts": {
        // ...
        "test": "vitest",
        "test:ui": "vitest --ui", // 带 UI 的测试
        "coverage": "vitest run --coverage"
      }
    }
```

**写一个测试 (`packages/components/src/components/Button/Button.test.ts`):**

```typescript
    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 测试):**

趁热打铁，让我们继续！接下来是端到端测试

```bash
    # 在项目根目录
    mkdir cypress
    cd cypress
    pnpm init # 创建 cypress/package.json
    cd ..

    # 安装 Cypress
    pnpm -F cypress add -D cypress
    # (这里把 cypress 目录看作一个独立的包)
```

**配置 `cypress/cypress.config.ts`:**

```typescript
    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 脚本:**

```json
    {
      "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`):**

```typescript
    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，到这里大家可能都看累了或者是感觉无聊，我们来小小整理下我们有了什么命令呢：

```json
"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 来实现

就像往常一样建好你的文档站，之后...

```typescript
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:**

```bash
pnpm add -Dw husky lint-staged
npx husky init # 会创建 .husky 目录
```

`.husky/pre-commit` 内容:

```bash
npx lint-staged
# 如果想在提交前运行所有测试 (可能会很慢)
# pnpm test: all
```

在根 `package.json` 添加 `lint-staged` 配置：

```json
        {
          // ...
          "lint-staged": {
            "*.{js,jsx,ts,tsx,vue}": "eslint --fix",
            "*.{json,md,html,css,scss}": "prettier --write"
          }
        }
```

### **TypeScript 配置 (`tsconfig.json`):**

太多了，贴不过来了，影响正常阅读

具体看我的仓库 [amore-ui](https://github.com/amore-ui/amore-ui)

## 引入 Turborepo 提升效率

目前，我们需要手动进入每个目录去运行命令。当项目变多时，这会变得很麻烦。`Turborepo` 可以帮我们统一管理和加速这些任务。

**1. 在根目录安装 Turborepo**

```bash
# -w 表示 --workspace-root，安装到根工作区
pnpm add turbo --save-dev -w
```

**2. 配置 `turbo.json`**

在项目根目录创建 `turbo.json` 文件：

```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` 中添加脚本**

```json
// package.json (根目录)
"scripts": {
  "dev": "turbo run dev",
  "build": "turbo run build",
  "lint": "turbo run lint"
}
```

现在，你可以从根目录统一运行命令了！

```bash
# 同时启动所有应用的 dev 服务
pnpm dev

# 构建所有应用和包
pnpm build
```

## Turborepo 自建缓存

我对 Vercel 公司不喜欢也不讨厌，但是我希望自建一个缓存。

参考这个项目就好啦:

[Turborepo Remote Cache](https://adirishi.github.io/turborepo-remote-cache-cloudflare/introduction/getting-started)

## Monorepo 最佳实践

1.  **统一配置**：将 `ESLint`, `Prettier`, `tsconfig.json` 等配置文件放在 `packages` 目录下（如 `packages/eslint-config-custom`, `packages/tsconfig`），然后让各个应用和包去继承这些配置，保持一致性。
2.  **明确的目录结构**：`apps` 放应用，`packages` 放可复用包，是一种广泛采纳的约定。
3.  **版本管理**：使用如 [Changesets](https://github.com/changesets/changesets) 这样的工具来管理包的版本发布和生成 `CHANGELOG`，它与 Monorepo 配合得非常好。
4.  **精简根目录**：保持根目录 `package.json` 的 `dependencies` 干净，只存放对整个项目都至关重要的开发依赖（如 `turbo`, `typescript`, `prettier`）。

## 尾声

恭喜你！你已经成功搭建并体验了一个现代化的前端 Monorepo 项目。

感觉这个例子选的不是很好啊，有点太上难度了，直接把我之前研究好久的架子全搬上来了，一般只有组件库或者大框架会这样操作了，不过基础的入门操作还是可以提升效率的🥹

从现在开始拥抱现代前端开发吧！
