组件库自动化最容易踩的几个坑
组件库的问题常在发布之后才显现
公共 UI 组件连接设计规范与业务代码。一个看似局部的属性调整,可能影响多个调用方;一个为了单页需求加入的样式入口,也可能逐渐成为无法移除的公共 API。组件库自动化若只追求生成、构建和发包速度,会把兼容性与维护成本留给下游。
因此,自动化要围绕可复查的边界:组件职责是否清楚,导出与副作用是否符合打包约定,设计令牌是否被正确使用,公共类型变化能否提前发现。下面几类问题不能靠一条通用规则解决,但都可以在评审与流水线中较早暴露。
容易积累维护成本的做法
1. “上帝组件”陷阱(The God Component Anti-Pattern)
设计者不断给Table或Modal增加布尔开关和业务属性,久而久之,同一组件承担数据获取、状态、布局与业务流程。Props 数量和文件行数只能提示问题,真正要看的是这些选项是否可以独立组合、是否有相互排斥的状态,以及修改一项是否需要理解全部分支。
2. 破坏 Tree-Shaking 的全量导出
export *本身不必然阻止 Tree-Shaking,结果还受模块格式、包的副作用声明和使用方构建器影响。需要检查的是入口模块是否在导入时执行注册、加载样式或聚合无法静态分析的代码。组件库应输出可分析的 ESM,并用实际消费项目验证按需引入结果,不能只凭导出语法推断体积。
3. Design Token 语义断层与硬编码样式
设计团队定义了 Token,组件内部却继续写入颜色和间距字面量,主题切换时就难以统一调整。但不是所有数值都必须成为公共 Token;几何计算或仅属于组件内部的不变量可以保留。规则应区分语义样式与实现细节,并允许有说明的例外。
4. 缺乏 Breaking Change(破坏性变更)自动化检测
TypeScript 声明变化可以在 CI 中比较,但类型兼容也不是全部。默认值、事件顺序、DOM 结构和 CSS 选择器变化,都可能影响调用方。自动检查负责列出候选破坏项,版本决策仍需结合迁移说明与消费方测试。
把自动化放在明确契约上
复合组件适合表达可组合的层次关系,但简单组件不必为了模式而拆分。选择 API 形式时,先列出稳定职责和需要共同维护的状态。无法独立组合的选项保留在一个接口里可能更清楚,业务流程则不应被塞进基础组件。
sideEffects要与包的真实行为一致。若组件导入会注册全局内容或加载 CSS,错误标成无副作用可能让构建器删掉必要代码。建立一个最小消费应用,分别导入单个组件与完整入口,检查最终产物和样式,比只看 package.json 更可靠。
公共.d.ts可以在每次变更后生成并比较。工具指出删除导出、收紧属性或新增必填项,再由维护者决定这是缺陷、需要主版本发布的变更,还是检测器误报。门禁应输出具体符号与差异,不用一个笼统分数代替评审。
一个 TypeScript 声明比较示例
下面的ComponentApiLinter演示如何读取两个声明文件并查找导出或属性变化。它不是完整的兼容性判断:示例没有覆盖函数参数、联合类型、泛型、别名解析,也没有识别“新版本新增必填属性而旧版不存在”的情况。接入 CI 前需要补齐项目关心的符号,并用已知兼容与不兼容样本验证。
import * as ts from 'typescript'; import * as fs from 'fs'; export interface ApiChangeResult { hasBreakingChange: boolean; messages: string[]; } /** * 组件库 API 破坏性变更 Lint 检测器 */ export class ComponentApiLinter { /** * 比较旧版与新版 .d.ts 导出的接口定义 * @param oldDtsPath 旧版本 d.ts 声明文件路径 * @param newDtsPath 新版本 d.ts 声明文件路径 */ public static compareDts(oldDtsPath: string, newDtsPath: string): ApiChangeResult { const oldExports = this.extractExportedTypes(oldDtsPath); const newExports = this.extractExportedTypes(newDtsPath); const messages: string[] = []; let hasBreakingChange = false; // 1. 检查是否存在被删卸的导出组件或接口 for (const [name, oldType] of oldExports.entries()) { if (!newExports.has(name)) { hasBreakingChange = true; messages.push(`❌ Breaking Change: 导出的类型/组件 '${name}' 被直接删除!`); continue; } const newType = newExports.get(name)!; // 2. 如果是接口 (Interface / Type Props),检查必填属性是否增加 for (const [propName, isRequired] of oldType.properties.entries()) { if (!newType.properties.has(propName)) { hasBreakingChange = true; messages.push(`❌ Breaking Change: 组件 '${name}' 的属性 '${propName}' 被移除!`); } } for (const [propName, isRequired] of newType.properties.entries()) { const oldPropRequired = oldType.properties.get(propName); // 如果旧属性原本不是必填,新版本强行改成了必填 (Required),视为破坏性改动 if (oldPropRequired === false && isRequired === true) { hasBreakingChange = true; messages.push(`❌ Breaking Change: 组件 '${name}' 的属性 '${propName}' 从可选变为了强必填!`); } } } return { hasBreakingChange, messages }; } /** * 使用 TS Compiler API 提取文件的导出 Symbol 类型 */ private static extractExportedTypes(filePath: string): Map<string, { properties: Map<string, boolean> }> { const program = ts.createProgram([filePath], { target: ts.ScriptTarget.ESNext }); const checker = program.getTypeChecker(); const sourceFile = program.getSourceFile(filePath); const result = new Map<string, { properties: Map<string, boolean> }>(); if (!sourceFile) return result; const moduleSymbol = checker.getSymbolAtLocation(sourceFile); if (moduleSymbol) { const exports = checker.getExportsOfModule(moduleSymbol); for (const exp of exports) { const name = exp.getName(); const properties = new Map<string, boolean>(); const type = checker.getTypeOfSymbolAtLocation(exp, exp.valueDeclaration || exp.declarations![0]); const props = type.getProperties(); for (const p of props) { const propName = p.getName(); // 判断该属性是否是可选的 (Optional) const isOptional = (p.flags & ts.SymbolFlags.Optional) !== 0; properties.set(propName, !isOptional); } result.set(name, { properties }); } } return result; } } // ==================== CI/CD 质量检测示例 ==================== if (require.main === module) { const oldDts = './dist/old/index.d.ts'; const newDts = './dist/new/index.d.ts'; if (fs.existsSync(oldDts) && fs.existsSync(newDts)) { const result = ComponentApiLinter.compareDts(oldDts, newDts); if (result.hasBreakingChange) { console.error('🚨 CI 打回: 组件库检测到破坏性 API 变更:'); result.messages.forEach(msg => console.error(msg)); process.exit(1); } else { console.log('✅ API 兼容性检测通过,未发现 Breaking Change!'); } } }评审时可以怎样落地
| 评估维度 | 常见反模式 (Bad Practice) | 可复查的改进方式 |
|---|---|---|
| 组件 API 设计 | 业务选项不断进入基础组件,状态组合难以说明 | 根据职责选择复合组件、受控属性或业务包装层 |
| 样式覆盖机制 | 任意覆盖内部结构,升级时无法判断影响 | 提供语义 Token 和有限扩展点,例外由评审确认 |
| 构建导出格式 | 入口含隐式副作用,按需引入结果不可预测 | 提供可分析模块并用最小消费应用验证产物 |
| 版本变更管理 | 只靠肉眼查看公共类型 | 生成声明差异,再补充行为测试和迁移说明 |
发布前还要验证消费方
组件库自身测试通过后,选择代表性的消费项目安装候选版本,运行类型检查、构建和关键交互。CSS、运行时依赖与框架版本问题往往只有在消费端出现。测试使用公开示例或虚构页面,不复制业务系统的敏感内容。
发布记录应关联声明差异、产物信息、变更说明和迁移办法。发现破坏项时,不要为了保持小版本号而隐藏它;决定撤回时也要确认包仓库、锁文件和缓存中的版本状态。组件库自动化的价值,是让变化较早被看见并能被复验,而不是让发包动作本身更快。