React 组件库版本控制:遵循语义化版本(SemVer)发布 React UI 库的工程化规范

各位同学,各位前端界的“代码工匠”们,大家好!

欢迎来到今天的讲座:《React 组件库版本控制:遵循语义化版本(SemVer)发布 React UI 库的工程化规范》

别急着去拿键盘敲代码,今天我们不谈怎么把那个该死的 Flexbox 弄对,也不谈怎么把 TypeScript 的泛型搞得像天书一样。今天,我们要聊聊一个稍微严肃,但如果你不懂,你的职业生涯就会像过山车一样刺激——刺激到你想吐——的话题:版本号

你可能会说:“版本号不就是 1.0.0,然后 1.0.1,接着 1.1.0 吗?这有什么好讲的?”

错!大错特错!

如果你把版本号当成随便写的数字,那你就是在给你的用户写一封“分手信”。如果你把版本号当成神圣的契约,那你就是在给你的用户写一封“情书”。今天,我就要教大家如何用代码和规范,把这份“情书”写得滴水不漏,让用户在更新时不会哭晕在厕所。

第一章:版本号的“三剑客”

首先,我们要搞清楚版本号到底长什么样。它不是 v2.0-beta.1,也不是 rc.12,虽然这些在开发阶段很常见,但一旦你准备发布给大众,那就是另一回事了。

标准格式是:MAJOR.MINOR.PATCH

想象一下,这三个人是住在同一个屋檐下的三个室友。

  1. MAJOR(老大): 他脾气暴躁,一言不合就掀桌子。如果 MAJOR 变了,说明你改了“破坏性变更”。你告诉用户:“嘿,我把你家的门锁换了,钥匙我收走了,你自己想办法吧。”
  2. MINOR(老二): 他比较随和,虽然有点新花样,但老家具还在。如果 MINOR 变了,说明你加了“向后兼容的新功能”。你告诉用户:“嘿,我给你家多开了一扇窗户,原来的门还在,你可以随便进出。”
  3. PATCH(老三): 他是负责修修补补的。如果 PATCH 变了,说明你修了“Bug”。你告诉用户:“嘿,那个漏水的水龙头我修好了,没动你家其他东西。”

代码示例 1:package.json 的自我修养

{
  "name": "awesome-ui",
  "version": "1.2.3"
}

看到没?这就是标准。1 是老大,2 是老二,3 是老三。这不仅是数字,这是 React 组件库的身份证。

第二章:MAJOR 版本 —— “分手的艺术”

什么时候该动 MAJOR 版本?这是最难的。

很多同学觉得:“哎呀,我把 Button 组件的 onClickfunction 改成了 Arrow Function,这算不算 MAJOR?”

不算! 这种微小的改动,连 PATCH 都轮不到你,你连 PATCH 都没有,你连个标点符号都不是。

MAJOR 版本必须用于破坏性变更

什么叫破坏性变更?简单说,就是你的 API 变了,导致用户的现有代码报错。

场景:
你写了一个 Input 组件,v1.0.0 版本里,用户传一个 value 属性来控制输入框内容。
到了 v2.0.0,你觉得 value 这个名字太普通了,你想改成 inputValue

代码示例 2:破坏性变更的惨痛教训

v1.0.0

// User's code
<Input value="Hello" onChange={handleChange} />

v2.0.0 (你的新代码)

// You changed the prop name
<Input inputValue="Hello" onChange={handleChange} /> 
// 这里的 value 已经不存在了!用户代码直接挂掉!

这就是 MAJOR 变更。你告诉用户:“不好意思,为了代码更规范,我移除了 value 属性。请更新你的代码。”

重点来了:
如果你的 MAJOR 版本变了,你必须提供迁移指南
别指望用户自己能猜到。你要写清楚:“如果你用了 Input,请把 value 改成 inputValue。”

第三章:MINOR 版本 —— “新玩具的诱惑”

MINOR 版本代表“向后兼容的新功能”。这是最安全、最令人愉悦的版本号。

当你给组件增加了新的属性,或者增加了新的导出模块,但没有删除任何旧的东西,你就可以升级 MINOR。

代码示例 3:MINOR 变更的优雅加入

假设你的 Button 组件现在很流行,但大家觉得不够潮,想加点样式。

v1.0.0

<Button onClick={handleClick}>Click Me</Button>

v1.1.0 (增加了 variant 属性)

<Button variant="ghost" onClick={handleClick}>Click Me</Button>
// 用户没传 variant 也没事,默认还是原来的样子,老用户无缝衔接!

代码示例 4:导出模块的升级

有时候,你不想破坏旧的导出方式,想加新的。

// src/index.js
// v1.0.0
export { default as Button } from './Button';
export { default as Input } from './Input';

// v1.1.0 - 增加了一个新组件 Link
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Link } from './Link'; // 新增!旧代码不受影响

这就是 MINOR 版本。它是给用户的新礼物,不是炸弹。

第四章:PATCH 版本 —— “创可贴的艺术”

PATCH 版本用于向后兼容的 Bug 修复

注意关键词:向后兼容

很多同学容易在这里犯错。比如,你发现 Input 组件在 Safari 上有 Bug,你修好了。这没问题,你发个 PATCH (1.0.1)。

但是!如果你在修 Bug 的同时,顺手把 Inputsize 属性从 lg 改成了 large,这就变成了 MINOR 版本!或者你把 InputonChange 回调的参数结构改了,这就变成了 MAJOR 版本!

代码示例 5:PATCH 的界限

v1.0.0

// Input 组件有个 Bug:输入框在聚焦时高度会跳动
<Input value="test" />

v1.0.1 (修复 Bug)

// 你通过 CSS 或者 DOM 操作,修好了跳动问题
// Input 的 API 完全没变,还是那个 Input
<Input value="test" />

v1.0.2 (错误的 PATCH)

// 你觉得之前的写法太丑,顺手把 `value` 改成了 `text`
<Input text="test" />
// 坏了!这是 MAJOR!或者至少是 MINOR!

记住:PATCH 只能用来修 Bug,不能用来整容。

第五章:工程化实战 —— 当你的库变成了 Monorepo

现在我们不是在写一个简单的 Button.js,我们是在维护一个庞大的组件库,可能包含 Button, Input, Modal, Table 甚至 Utils

这时候,你面临一个巨大的问题:版本号怎么升?

如果你改了 Button 的代码,是 Button 升级了,还是整个库升级了?通常情况下,我们希望整个库的版本号保持一致。

这时候,Lerna, Nx, Turborepo 这些工具就登场了。

代码示例 6:使用 Lerna 进行版本管理

假设你的项目结构是这样的:

packages/
  button/
  input/
  index/ (主入口)

当你修改了 button 组件的代码,你需要运行:

# Lerna 会自动分析哪些包变了,然后统一 bump 版本
lerna version patch

Lerna 会问你:

  1. 哪个包改了?(packages/button)
  2. 改了什么?(Bug fix)
  3. 版本从 1.0.0 变成 1.0.1

然后,它会自动更新所有包的 package.json,并生成一个 CHANGELOG。

代码示例 7:lerna.json 配置

{
  "packages": ["packages/*"],
  "version": "independent", // 或者是 "fixed" (所有包保持一致)
  "command": {
    "publish": {
      "ignoreChanges": ["**/test/**", "**/__tests__/**"],
      "message": "chore(release): publish %v"
    }
  }
}

这里有个坑:independent 模式下,每个包都有自己的版本号(Button 1.0.1, Input 1.2.3)。fixed 模式下,所有包必须版本号一致(Button 1.0.1, Input 1.0.1)。

对于组件库来说,通常建议 fixed 模式,或者至少让主包的版本号决定一切。

第六章:自动化 —— 别让人类的手指犯错

人类是不可靠的。你今天心情好,发了个 PATCH,明天心情不好,发了个 MINOR。后天你喝多了,发了个 MAJOR。

我们需要机器来帮我们。

这时候,Semantic Release 登场了。

它的工作原理很简单:它读你的 Git Commit 消息,根据消息的内容(比如 fix: 开头还是 feat: 开头),自动决定发什么版本,自动写 CHANGELOG,自动打 Git Tag,甚至自动发布到 npm。

代码示例 8:.releaserc.json 配置

{
  "branches": ["main"],
  "plugins": [
    [
      "@semantic-release/commit-analyzer",
      {
        "preset": "angular",
        "releaseRules": [
          { "type": "feat", "release": "minor" },
          { "type": "fix", "release": "patch" },
          { "type": "chore", "release": "patch" }
        ]
      }
    ],
    "@semantic-release/release-notes-generator",
    "@semantic-release/changelog",
    "@semantic-release/npm",
    "@semantic-release/git",
    "@semantic-release/github"
  ]
}

配合 Conventional Commits 规范,你的提交信息必须像这样:

git commit -m "feat(button): add new ghost variant"
git commit -m "fix(input): correct border radius in dark mode"
git commit -m "chore(deps): bump lodash to 4.17.21"

一旦你 push 代码,CI/CD 流水线就会自动分析:

  1. feat -> MINOR
  2. fix -> PATCH
  3. chore -> PATCH (或者不发布)

第七章:TypeScript 与版本号的“相爱相杀”

如果你的组件库是用 TypeScript 写的,版本号就不仅仅是数字了,它是类型契约

当你升级 MINOR 版本,你增加了新的属性。TypeScript 会很开心,因为它的 interface 扩展了,用户可以传新的属性了。

当你升级 MAJOR 版本,你删除了一个属性。TypeScript 会非常生气,因为它会直接报错:“Property ‘xxx’ does not exist on type ‘yyy’”。

代码示例 9:TS 类型定义与版本

// components/Button.d.ts

// v1.0.0
export interface ButtonProps {
  onClick?: () => void;
  children?: React.ReactNode;
}

// v2.0.0 (破坏性变更)
export interface ButtonProps {
  onClick?: () => void; // 这个属性被移除了!
  children?: React.ReactNode;
  variant?: 'primary' | 'secondary'; // 新增了属性
}

如果你发布了 v2.0.0,用户安装了,他们的 TypeScript 编译器会立刻报警。这迫使他们在升级依赖的同时,也更新他们的代码。这就是 MAJOR 版本的意义:强制更新。

第八章:依赖地狱 —— 为什么你的版本号控制这么难?

很多同学抱怨:“我只想改个 Bug,为什么要把所有依赖都升级一遍?”

这就是版本范围的问题。

当你写 dependencies: { "react": "^17.0.0" } 时,你是在告诉 npm:“嘿,我只要 17.0.0 或者 17.x.x 的版本,只要不跨大版本就行。”

代码示例 10:版本范围的陷阱

假设你的组件库 v1.0.0 发布时,依赖了 react-dom: ^16.8.0

一年后,你的组件库 v2.0.0 发布了,你依赖了 react-dom: ^17.0.0

用户安装了 v2.0.0,npm 会自动帮他把 react-dom 从 16 升级到 17。如果 16 到 17 有破坏性变更(比如 Context API 的变化),用户的项目可能直接崩了。

解决方案:
尽量锁定依赖版本,或者只在 MAJOR 版本中升级核心依赖。

{
  "dependencies": {
    "react": "17.0.2", // 锁死版本,不随你的库自动升级
    "react-dom": "17.0.2"
  }
}

第九章:幽灵依赖 —— 别偷看别人的东西

这是新手最容易犯的错误。

你写了一个库 my-lib,里面导出了 Button 组件。你写代码的时候,直接 import { Button } from 'my-lib'

但是,my-libpackage.json 里,main 字段指向的文件里,并没有显式地 export { Button }。也许 Button 是在 Button/index.js 里,而那个文件被错误地配置了 sideEffects: false,导致打包工具把它忽略了。

结果就是:my-lib 发布了,用户安装了,用户运行时报错:“Cannot find module ‘my-lib/Button’”。

这跟你版本号没关系,但跟你的工程化配置有关。

代码示例 11:正确的导出配置

使用 exports 字段(Node 12+)来明确告诉用户:我有这些东西。

{
  "name": "my-lib",
  "version": "1.0.0",
  "exports": {
    ".": "./dist/index.js",
    "./Button": "./dist/Button/index.js",
    "./Input": "./dist/Input/index.js"
  }
}

这样,用户 import { Button } from 'my-lib/Button' 时,npm 会直接去读 dist/Button/index.js,不会产生歧义。

第十章:实战演练 —— 一个完整的发布流程

让我们假设我们要发布一个名为 CoolUI 的库。

Step 1: 开发
你修改了 Button 组件,修复了一个在移动端显示不全的 Bug。

Step 2: 提交
按照规范提交代码:

git commit -m "fix(button): fix display issue on mobile devices"

Step 3: 自动化分析
Semantic Release 看到 fix,决定发布 PATCH 版本。
它更新了 CHANGELOG.md:

## [1.0.1](https://github.com/xxx/CoolUI/compare/v1.0.0...v1.0.1) (2023-10-27)

### Bug Fixes
* **button:** fix display issue on mobile devices

Step 4: 打包
它运行了你的打包脚本,生成了 dist 文件夹。

Step 5: 发布
它运行 npm publish,版本号 1.0.0 变成了 1.0.1

Step 6: 用户侧
用户运行 npm install cool-ui,安装了 1.0.1
用户运行 npm list cool-ui,看到 [email protected]
用户的代码没有报错,因为 Button 的 API 没变。

Step 7: 下次开发
你这次决定加一个 loading 属性给 Button

Step 8: 提交

git commit -m "feat(button): add loading state"

Step 9: 自动化分析
Semantic Release 看到 feat,决定发布 MINOR 版本。
它更新了 CHANGELOG.md:

## [1.1.0](https://github.com/xxx/CoolUI/compare/v1.0.1...v1.1.0) (2023-10-28)

### Features
* **button:** add loading state

Step 10: 发布
版本号 1.0.1 变成了 1.1.0

用户安装 1.1.0,发现 Button 多了 loading 属性,开心地使用了。

Step 11: 下次开发
你决定重构整个 Button 组件,把原来的 onClick 改成了 onPress,并重写了所有的内部逻辑。

Step 12: 提交

git commit -m "refactor(button): rewrite internal logic"

Step 13: 自动化分析
Semantic Release 看到这是 Refactor,通常是 Patch。但是,这是破坏性变更!
你需要在 commit message 里加上 ! 号,或者配置规则,强制它变成 MAJOR。

git commit -m "refactor(button)!: rename onClick to onPress"

Step 14: 发布
版本号 1.1.0 变成了 2.0.0
CHANGELOG.md 变得惨不忍睹,全是破坏性变更的列表。

用户安装 2.0.0,发现 onClick 没了,必须用 onPress
如果用户不升级,代码报错。
如果用户升级,必须改代码。

这就是版本控制的闭环。

第十一章:常见的误区与“反模式”

在结束之前,我要揭露几个大家常犯的“反模式”,这些都会导致你的库变成“屎山”。

误区 1:为了发版而发版
不要为了凑个 MINOR 版本,就随便加个无用的属性。比如 Button 组件加一个 hidden 属性,虽然向后兼容,但毫无意义。这会让用户的依赖树变得臃肿。

误区 2:忽略 SemVer 的数学规则
SemVer 规定,版本号必须递增。
1.0.0 -> 1.0.1 -> 1.1.0 -> 2.0.0
但是,npm 要求版本号不能跳过!你不能发 1.0.2,然后跳过 1.0.3 直接发 1.1.0
如果你用 Lerna 的 independent 模式,可能没问题。但如果你用 Semantic Release,它会自动补全版本号。

误区 3:把主包和子包混为一谈
如果你的组件库导出了 Button,而 Button 依赖了 my-lib/utils
utils 升级了 MINOR 版本,Button 需要跟着升级吗?
通常情况下,Button 应该跟着 my-lib 整体升级。
但如果 utils 的 MINOR 变更是纯新增功能,不影响 Button,那 Button 可以保持不动。
但为了简单起见,只要子包变了,主包就升级 MINOR。这是最安全的做法。

第十二章:总结与展望

好了,同学们,我们今天讲了太多东西。

版本控制不仅仅是改几个数字。它是一种沟通的语言,一种承诺,一种工程化的艺术。

  • MAJOR 是誓言:我变了,你要跟着变。
  • MINOR 是邀请:我加了新东西,你可以不用,但可以用。
  • PATCH 是补救:我修了 Bug,感谢你的宽容。

当你遵循 SemVer 规范时,你不仅仅是在发布一个库。你是在构建一个生态系统。你是在告诉全世界:“嘿,我的代码是稳定的,我是专业的,你可以信任我。”

不要让你的版本号变成一句空话。让 1.0.0 成为你的起点,让 2.0.0 成为你的里程碑。

现在,拿起你的键盘,去写一份完美的 CHANGELOG,去写一段完美的 commit message,然后去发布你的代码吧!

记住,代码是写给人看的,顺便给机器运行。而版本号,就是代码写给用户的情书。

谢谢大家!

(讲座结束,掌声雷动)


附录:常用工具速查表

  1. Semantic Release: 自动化版本发布与 CHANGELOG 生成。
    • npm install -D @semantic-release/npm @semantic-release/git @semantic-release/changelog @semantic-release/commit-analyzer
  2. Lerna: 管理多包仓库版本。
    • npx lerna version patch --yes
  3. Nx: 强大的 Monorepo 工具,自带版本管理。
    • npx nx affected --target=version
  4. Commitlint: 强制 Commit Message 格式。
    • npm install -D @commitlint/cli @commitlint/config-angular

希望这篇文章能成为你 React 组件库开发路上的“红宝书”。祝大家版本号升得开心,Bug 修得漂亮!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注