外观
从手动发版到自动发布: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的组合方案:
- 传统单包组件库方案:优点是配置简单上手成本低,缺点是模块完全耦合,无法实现子包独立发版,按需引入成本高,仅适合10个组件以内的小型组件库;
- 旧Lerna + Webpack方案:优点是生态成熟,早期Monorepo场景应用广泛,缺点是依赖安装速度慢,大仓库下执行命令耗时是pnpm的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 -vyaml
# 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 流程跑通,收益会更稳定。