跳到主内容

从手动发版到自动发布:pnpm Monorepo组件库治理实战 ​

问题背景:多包组件库的传统发布痛点 ​

中大型前端团队的公共组件库迭代到一定阶段后,普遍会遇到三类核心痛点:第一是单包耦合迭代效率低,所有组件、工具函数、自定义Hook打包在同一个NPM包里,小项目引入时会产生不必要的体积冗余,不同模块的发版节奏完全绑定,无法单独灰度更新;第二是手动发版失误率高,开发者需要手动修改每个子包的版本号、手写变更日志,经常出现版本号跳号、依赖版本不匹配、漏发子包的问题;第三是文档站和组件库版本不同步,手动更新文档部署流程,经常出现线上文档和最新发布的组件库功能不一致的情况。

从我们维护过的一个 60+ 组件、12 个工具子包的组件库数据来看,单包时代一次“只改一个 Button 组件”的发布,往往要牵动整库版本号、全量回归和全量产物上传,CI 平均耗时 12~18 分钟;而真正与 Button 相关的源码改动可能不足 30 行。业务侧即使只使用其中 3 个组件,安装 node_modules 时也要下载整包源码与样式,tree-shaking 在历史写法、副作用样式和 CJS/ESM 混发的情况下并不总是稳定生效,产物体积经常出现 30%~50% 的无效冗余。

手动发版的问题在多人协作时会被进一步放大。常见故障包括:components-button 已经升级到 1.4.0,但 components-form 里仍锁着 1.2.0;开发者本地 npm publish 成功后忘记打 Git Tag;CHANGELOG 只写了主包,漏写内部依赖包;或者同一周内两个同学分别从不同分支发布,造成版本号覆盖和语义化版本混乱。根据团队内部统计,在没有统一版本治理工具时,每 10 次发布平均会出现 1.2 次返工,典型返工耗时 20~40 分钟,严重时还需要撤回版本并重新发布补丁版本。

文档站不同步则会直接影响业务接入体验。比如组件新增了 size="large" 属性,但文档站仍停留在上个版本;或者 Hook 的返回值结构已经发生破坏性变更,业务按照旧文档接入后在线上报错。文档与源码分离维护时,这类问题很难靠口头约定彻底解决。

很多团队早期尝试过用传统Lerna搭配npm实现Monorepo组件库,但Lerna老版本依赖安装速度慢、配置繁琐,大仓库下的操作卡顿问题非常突出,已经很难适配现在的前端工程化需求。尤其是 npm/yarn 经典的扁平 node_modules 方案,会让包间隐式依赖“碰巧可用”,子包没有声明的包也可能因为提升而被引用,长期维护后依赖边界会越来越模糊。

方案对比:三类多包组件库方案选型 ​

我们对比了行业内主流的三类多包组件库搭建方案,最终选定pnpm Monorepo + Changesets + Vite Build的组合方案:

  1. 传统单包组件库方案:优点是配置简单上手成本低,缺点是模块完全耦合,无法实现子包独立发版,按需引入成本高,仅适合10个组件以内的小型组件库;
  2. 旧Lerna + Webpack方案:优点是生态成熟,早期Monorepo场景应用广泛,缺点是依赖安装速度慢,大仓库下执行命令耗时是pnpm的3倍以上,版本治理需要额外引入第三方插件,配置复杂度高;
  3. pnpm Monorepo + Changesets + Vite Build方案:依赖安装速度提升70%以上,Changesets天然支持多包版本追溯、自动生成变更日志,Vite构建速度远快于传统Webpack,完全满足多包组件库的版本治理、自动发布、文档站同步的全流程需求。

更细一点看,pnpm 的核心优势来自内容寻址存储和硬链接机制:同一个版本的依赖在全局 store 中只保存一份,各个项目通过硬链接与符号链接复用,而不是每个仓库重复复制。实测在 1500 个依赖左右的中型组件库中,冷安装耗时可从 npm 的 3 分 40 秒、Yarn Classic 的 2 分 50 秒下降到 pnpm 的 45~60 秒;二次安装通常能进一步压缩到 15~25 秒。同时,pnpm 默认非扁平的 node_modules 结构可以避免“幽灵依赖”,子包只能访问自己在 package.json 中声明过的依赖。

Changesets 与 Lerna Versioning 的思路也有明显差异。Changesets 要求开发者在改动发生时通过 changeset add 记录“哪些包发生了 major/minor/patch 变化、变更内容是什么”,这些 markdown 元数据会随代码一起评审。合并主分支后,CI 再统一消费 changeset,计算版本号、更新依赖范围并生成 CHANGELOG。这样做的好处是变更意图前置,而不是在发布时靠发布人回忆最近两周改了什么。

Vite Build 更适合组件库中的 ESM 产物开发与构建。它底层基于 Rollup,具备成熟的分包、外部化依赖和 ESM/CJS 产物能力;开发模式下冷启动和 HMR 速度明显优于传统 Webpack 配置。需要注意的是,如果组件库需要兼容非常老的浏览器或复杂的 UMD 场景,仍需配合 @vitejs/plugin-legacy、esbuild target、browserslist 以及充分的产物测试,不能只看到构建速度就直接切换。

方案依赖安装版本治理构建体验适用阶段
单包最简单单版本号,无法独立发布配置少10 个组件以内、试验期
Lerna + npm/yarn + Webpack慢,幽灵依赖明显fixed/independent 模式可用,但插件与配置较重冷启动慢,配置复杂历史 Monorepo 项目
pnpm + Changesets + Vite快,依赖边界清晰变更评审、自动升版、CHANGELOG 友好冷启动/HMR 快,库模式配置简洁中大型长期维护组件库

实操步骤与代码示例 ​

步骤1:初始化pnpm Monorepo基础环境 ​

首先全局安装pnpm,在项目根目录创建pnpm-workspace.yaml配置文件,声明多包工作区范围:

bash
# 安装 pnpm,建议使用 Corepack 锁定包管理器版本
corepack enable
corepack prepare pnpm@9.12.0 --activate
pnpm -v
yaml
# pnpm-workspace.yaml
packages:
  - 'packages/*' # 所有子包存放目录
  - 'docs' # VitePress文档站目录

推荐的目录结构如下:

text
my-ui/
├── packages/
│   ├── components-button/
│   │   ├── src/
│   │   │   └── index.ts
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── components-input/
│   ├── hooks/
│   └── utils/
├── docs/
│   ├── .vitepress/
│   ├── guide/
│   └── components/
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json
└── turbo.json

根目录的package.json中配置公共脚本,统一管理构建、发布命令:

json
{
  "name": "my-ui-monorepo",
  "version": "0.0.0",
  "private": true,
  "packageManager": "pnpm@9.12.0",
  "scripts": {
    "dev": "pnpm --filter ./docs dev",
    "build": "pnpm -r --filter=./packages/* build",
    "build:docs": "pnpm --filter ./docs build",
    "test": "pnpm -r test",
    "lint": "eslint \"packages/**/*.{ts,tsx}\"",
    "typecheck": "pnpm -r typecheck",
    "changeset": "changeset",
    "version-packages": "changeset version",
    "release": "pnpm build && changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.27.8",
    "typescript": "^5.5.4",
    "vite": "^5.4.8",
    "vitepress": "^1.3.4"
  },
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.0.0"
  }
}

子包的 package.json 要显式声明名称、版本、入口产物、导出条件和构建脚本。下面是一个可运行的基础模板:

json
{
  "name": "@my-ui/button",
  "version": "0.1.0",
  "description": "Button component for my-ui.",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./style.css": "./dist/style.css"
  },
  "files": [
    "dist"
  ],
  "sideEffects": [
    "*.css"
  ],
  "scripts": {
    "build": "vite build",
    "typecheck": "tsc --noEmit"
  },
  "peerDependencies": {
    "vue": "^3.4.0"
  }
}

包之间引用时应使用 workspace:* 协议,发布时 Changesets 会自动替换为实际版本范围:

json
{
  "dependencies": {
    "@my-ui/hooks": "workspace:*",
    "@my-ui/utils": "workspace:^"
  }
}

一个最小的 Vite 库模式配置如下:

ts
// packages/components-button/vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'node:path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyUIButton',
      fileName: 'index',
      formats: ['es', 'cjs']
    },
    rollupOptions: {
      external: ['vue', '@my-ui/hooks', '@my-ui/utils'],
      output: {
        preserveModules: false,
        assetFileNames: 'style.[ext]'
      }
    },
    sourcemap: true,
    minify: false
  }
});

初始化 Changesets:

bash
pnpm dlx @changesets/cli init

然后调整 .changeset/config.json:

json
{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@my-ui/docs"]
}

日常开发时,修改 Button 后执行:

bash
pnpm changeset

选择受影响的包,例如 @my-ui/button,选择 patch,填写“修复 loading 状态下图标不居中的问题”。生成的 changeset 文件应随 PR 一起提交:

md
---
'@my-ui/button': patch
---

修复 loading 状态下图标不居中的问题。

CI 中可以拆成两个流程:PR 阶段只做安装、类型检查、测试、构建和 changeset 校验;主分支合并后再执行版本更新与发布。GitHub Actions 示例:

yaml
# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9.12.0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
          registry-url: https://registry.npmjs.org

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build packages
        run: pnpm build

      - name: Create Release Pull Request or Publish
        uses: changesets/action@v1
        with:
          publish: pnpm release
          version: pnpm version-packages
          commit: 'chore(release): version packages'
          title: 'chore(release): version packages'
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

避坑清单建议重点关注以下几项:

  • 不要把 vue、react 等宿主框架打进组件产物,应放入 peerDependencies 并在 Vite/Rollup 中 external,否则业务项目可能出现多份框架实例。
  • 不要在子包中直接引用未声明的依赖,pnpm 的严格 node_modules 会让这类问题在本地暴露,不要通过关闭严格模式绕过。
  • workspace:*、workspace:^、workspace:~ 的发布替换规则不同,内部工具包建议统一规范,避免生成不可预期的版本范围。
  • CSS 文件要通过 sideEffects 标识,否则打包器 tree-shaking 时可能误删样式;但也不要把整个包都标记为有副作用,否则按需引入会失效。
  • Changesets 的 ignore 只适合不发布的文档站或示例工程,真正发布的组件包不要忽略,否则变更不会进入版本计算。
  • 自动发布前必须先执行构建,并确认 files 字段只发布 dist、README、LICENSE 等必要文件,避免把源码测试文件或临时产物发到 NPM。
  • 双包格式 ESM/CJS 要验证 exports 条件顺序,TypeScript 4.7+ 推荐优先提供 types 条件,减少业务侧类型解析到错误入口的问题。
  • 文档站不要依赖 link: 或相对路径硬链接源码,应优先通过 workspace 包名引用构建产物或配置别名,保证文档示例与业务真实接入方式一致。

实战中,我们建议把发布流程分为“变更记录 PR、版本发布 PR、NPM 发布、文档部署”四个阶段。开发者只负责提交 changeset,机器人或 CI 自动创建 Release PR;Release PR 中可以清晰看到所有版本号和 CHANGELOG 变更,维护者审核通过后再发布。以 20 个子包的仓库为例,切换到该模式后,常规发布耗时从人工 25 分钟左右下降到 CI 6~8 分钟,漏发包和依赖版本不一致问题基本清零,文档站在发布后 2~3 分钟内自动完成部署。

如果仓库继续膨胀,还可以引入 Turborepo 或 Nx 做任务编排和远程缓存。例如 Button 未变化时直接复用上次构建产物,只重新构建受影响的 Input、Form 等包。实测在 30 个子包、完整构建约 4 分钟的仓库中,增量构建通常能降到 30~60 秒。但不要在项目初期过早引入复杂缓存,先把 workspace 边界、产物格式、Changesets 流程跑通,收益会更稳定。