各位同学,各位前端界的“代码工匠”们,大家好!
欢迎来到今天的讲座:《React 组件库版本控制:遵循语义化版本(SemVer)发布 React UI 库的工程化规范》。
别急着去拿键盘敲代码,今天我们不谈怎么把那个该死的 Flexbox 弄对,也不谈怎么把 TypeScript 的泛型搞得像天书一样。今天,我们要聊聊一个稍微严肃,但如果你不懂,你的职业生涯就会像过山车一样刺激——刺激到你想吐——的话题:版本号。
你可能会说:“版本号不就是 1.0.0,然后 1.0.1,接着 1.1.0 吗?这有什么好讲的?”
错!大错特错!
如果你把版本号当成随便写的数字,那你就是在给你的用户写一封“分手信”。如果你把版本号当成神圣的契约,那你就是在给你的用户写一封“情书”。今天,我就要教大家如何用代码和规范,把这份“情书”写得滴水不漏,让用户在更新时不会哭晕在厕所。
第一章:版本号的“三剑客”
首先,我们要搞清楚版本号到底长什么样。它不是 v2.0-beta.1,也不是 rc.12,虽然这些在开发阶段很常见,但一旦你准备发布给大众,那就是另一回事了。
标准格式是:MAJOR.MINOR.PATCH
想象一下,这三个人是住在同一个屋檐下的三个室友。
- MAJOR(老大): 他脾气暴躁,一言不合就掀桌子。如果 MAJOR 变了,说明你改了“破坏性变更”。你告诉用户:“嘿,我把你家的门锁换了,钥匙我收走了,你自己想办法吧。”
- MINOR(老二): 他比较随和,虽然有点新花样,但老家具还在。如果 MINOR 变了,说明你加了“向后兼容的新功能”。你告诉用户:“嘿,我给你家多开了一扇窗户,原来的门还在,你可以随便进出。”
- PATCH(老三): 他是负责修修补补的。如果 PATCH 变了,说明你修了“Bug”。你告诉用户:“嘿,那个漏水的水龙头我修好了,没动你家其他东西。”
代码示例 1:package.json 的自我修养
{
"name": "awesome-ui",
"version": "1.2.3"
}
看到没?这就是标准。1 是老大,2 是老二,3 是老三。这不仅是数字,这是 React 组件库的身份证。
第二章:MAJOR 版本 —— “分手的艺术”
什么时候该动 MAJOR 版本?这是最难的。
很多同学觉得:“哎呀,我把 Button 组件的 onClick 从 function 改成了 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 的同时,顺手把 Input 的 size 属性从 lg 改成了 large,这就变成了 MINOR 版本!或者你把 Input 的 onChange 回调的参数结构改了,这就变成了 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 会问你:
- 哪个包改了?(
packages/button) - 改了什么?(Bug fix)
- 版本从
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 流水线就会自动分析:
feat-> MINORfix-> PATCHchore-> 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-lib 的 package.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,然后去发布你的代码吧!
记住,代码是写给人看的,顺便给机器运行。而版本号,就是代码写给用户的情书。
谢谢大家!
(讲座结束,掌声雷动)
附录:常用工具速查表
- Semantic Release: 自动化版本发布与 CHANGELOG 生成。
npm install -D @semantic-release/npm @semantic-release/git @semantic-release/changelog @semantic-release/commit-analyzer
- Lerna: 管理多包仓库版本。
npx lerna version patch --yes
- Nx: 强大的 Monorepo 工具,自带版本管理。
npx nx affected --target=version
- Commitlint: 强制 Commit Message 格式。
npm install -D @commitlint/cli @commitlint/config-angular
希望这篇文章能成为你 React 组件库开发路上的“红宝书”。祝大家版本号升得开心,Bug 修得漂亮!