如何设计一个高可复用的前端组件库:从 API 设计到文档自动化
我们团队同时维护储能云平台、监控大屏、运维后台、售前工具四个前端项目。早先每个项目里都躺着三份「自己写的按钮」和五份互相矛盾的表格组件:A 项目改了圆角,B 项目还在用老样式;新人入职第一周全在问「分页组件到底哪个仓库是最新的」。痛点逼着我们把散落的组件收敛成一个统一组件库,两年迭代下来我最大的体会是:组件库的成败 80% 由 API 设计和文档体验决定,而不是写了多少个组件。这篇文章分享我在设计这套库时沉淀下来的方法论,全部来自真实踩坑。
一、先定复用边界:组件分三层,别把「组件库」写成「UI 框架」
做组件库最容易犯的错是一上来就封装所有东西。我的建议是先做减法,把代码按职责切成三层,只把前两层放进库里:
- Headless 层:管行为不管长相------弹层的焦点陷阱、键盘导航、开关的状态机。这一层尽量站在成熟原语上(Vue 生态可以选 Headless UI / Radix Vue / Ark UI),自己只做薄封装,因为无障碍和状态管理是「高成本低收益」的活,交给专家实现最划算;
- Styled 层:这才是「组件库」本体。消费 Headless 行为 + Design Token,输出带视觉的 Button、Table、Modal;
- 应用层:业务组件(如「电站状态卡片」)绝不进通用库,放进各自的业务包,否则库会膨胀成第二个业务系统。
判断边界有个很实用的检验标准:如果一个组件需要引你的 API 请求层、路由或业务枚举,它就属于应用层。我们库现在 32 个基础组件,三年没超过 40 个,因为新需求来了先问一句「这是通用能力还是业务页面?」,一半的需求被挡在门外。
二、API 设计:收敛变体、组合优于配置、类型先于文档
一个组件 API 好不好,看两个场景就知道:使用方要「改一下配色」,是加一个 color prop 还是能直接覆盖?要「在弹层里放自定义 footer」,是堆十几个布尔 prop 还是能自由组合?
原则 1:变体收敛,禁止「自由落体」的样式 prop。 按钮是重灾区------color、bg、rounded 全暴露出来,等于把样式实现细节泄漏给使用者,主题一改全部失效。正确做法是用受控的 variant + size 收敛:
typescript
// types.ts ------ 变体是联合类型,不是 string
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps {
/** 视觉变体:业务代码里禁止出现 style 覆盖按钮颜色 */
variant?: ButtonVariant;
size?: ButtonSize;
/** 是否撑满父容器宽度 */
block?: boolean;
/** 加载态:自动禁用并替换为 spinner */
loading?: boolean;
}
联合类型配合编辑器提示,使用方根本不需要查文档就知道可以传什么。真有个别需要「破例」的样式,让它走 class 透传而不是新增样式 prop。
原则 2:组合优于配置(Compound Component)。 还是以弹层为例。配置型 API 长这样:<Modal visible title footer={footer} width={520} ...>,一旦需求是「footer 左侧要放勾选框」,你就得给 Modal 加 footerLeft;下次需求又变,你再加。这种 API 永远追不上业务。组合型 API 把决策权还给使用方:
tsx
// Modal.tsx ------ 组合式 API,结构与语义一一对应
<Modal open={open} onClose={onClose}>
<Modal.Header>
删除电站确认
<button onClick={onClose}>×</button>
</Modal.Header>
<Modal.Body>
将删除该电站的 {stationCount} 条实时数据,此操作不可恢复。
</Modal.Body>
<Modal.Footer>
<Checkbox v-model="confirm">我已知晓数据不可恢复</Checkbox>
<Button variant="danger" :disabled="!confirm" @click="doDelete">
确认删除
</Button>
</Modal.Footer>
</Modal>
父组件通过 provide/inject 把关闭方法、上下文传给子部件,内部子组件自动注册。使用方想怎么摆就怎么摆,库作者不用再猜「别人会不会要一个 footerLeft」。
原则 3:类型即文档。 每个组件导出 XxxProps 并写 JSDoc 注释,我们要求新增组件必须通过一个类型测试,防止重构时悄悄破坏公共 API:
typescript
// api-contract.test.ts ------ 公共 API 变更会被 CI 拦下
import type { ButtonProps } from '../Button';
import { describe, expectTypeOf, it } from 'vitest';
describe('Button API 契约', () => {
it('variant 只能是受控联合类型', () => {
expectTypeOf<ButtonProps['variant']>().toEqualTypeOf<
'primary' | 'secondary' | 'danger' | 'ghost' | undefined
>();
});
it('不暴露自由样式 prop', () => {
expectTypeOf<keyof ButtonProps>().not.toContain('bgColor');
expectTypeOf<keyof ButtonProps>().not.toContain('borderRadius');
});
});
三、样式复用靠 Design Token 双层架构,不靠「复制色值」
组件能跨项目换肤(客户 A 要蓝色主题、客户 B 要绿色主题、还有深色模式),靠的不是在每个组件里写死颜色,而是 Token。我们采用的是业界主流的「原始层 + 语义层」双层结构:
- 原始 Token(Primitive) :与业务无关的调色板,如
--pr-gray-50、--pr-blue-600; - 语义 Token(Semantic) :组件只消费语义层,如
--bg-primary、--text-on-primary。换主题 = 只改语义层到底层原始 Token 的映射,组件代码一行不动。
css
/* tokens/light.css ------ 组件只认语义变量 */
:root {
/* 原始层:中性色与品牌色 */
--pr-gray-50: #f8fafc;
--pr-gray-900: #0f172a;
--pr-brand-600: #2563eb;
--pr-danger-600: #dc2626;
/* 语义层:按钮/输入框/文字按「角色」取色 */
--bg-primary: var(--pr-brand-600);
--bg-primary-hover: #1d4ed8;
--text-primary: var(--pr-gray-900);
--border-input: #cbd5e1;
}
/* tokens/dark.css ------ 只需重映射语义层 */
.dark {
--bg-primary: #3b82f6;
--text-primary: var(--pr-gray-50);
--border-input: #334155;
}
css
/* Button 组件样式:零硬编码色值 */
.btn-primary {
background: var(--bg-primary);
color: #fff;
}
.btn-primary:hover { background: var(--bg-primary-hover); }
Token 由 Style Dictionary 这类工具从一份 JSON 统一生成 CSS 变量 / SCSS / Tailwind 配置 / 甚至 iOS、Android 资源,设计稿改色号时只需改 JSON 再跑一次构建,四端自动同步。组件里出现任何十六进制色值,Code Review 直接打回。
四、文档自动化:让「示例代码」成为唯一事实源
组件库没人用的头号原因不是不好用,而是不会用。手写 Markdown 文档的宿命是:API 更新了、文档忘了改,使用者照着过期示例写出一堆 bug。我们的解法是文档自动生成 + 示例即代码:
1. 每个组件配一个 .story 文件(或 Storybook CSF),组件的 props 表格、事件、插槽全部从 TypeScript 类型自动生成,不再手写 API 清单:
typescript
// Button.story.ts ------ 声明式定义示例,文档站点自动渲染
import type { Meta } from '@storybook/vue3-vite';
import Button from './Button.vue';
export default {
title: '基础/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'danger', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
} satisfies Meta;
export const Primary = { args: { variant: 'primary', children: '保存' } };
export const Danger = { args: { variant: 'danger', children: '删除电站' } };
每个 story 同时在文档站点生成「代码示例 + 一键复制」区块------展示的代码就是仓库里真实运行的源码,杜绝文档与实现分叉。新同事改主题或改样式,先在文档站交互验证,再回仓库改,反馈链路短了很多。
2. 视觉回归兜底:接入 Chromatic(或自建 Playwright 截图比对),每次合并请求自动对全部组件截图对比。我们靠这个机制抓到过「改了全局圆角变量导致弹层变方」「暗色模式下 disabled 按钮看不清」这类纯靠人眼极易漏掉的回归。
3. 版本与发布纪律 :遵循 SemVer,破坏性变更必须走 deprecation 过渡期并给出 codemod;发布日志从 commit 规范自动生成。库稳定后,业务侧升级成本极低------过去半年四个项目从 1.x 升到 2.x,全部在一天内完成。
总结
回看这两年的组件库建设,真正让复用率从「没人用」走到「四个项目默认依赖」的,不是组件的数量,而是下面几条:
- 边界先行:Headless / Styled / 业务组件三层分离,只把通用能力收进库,库才不会腐化成第二个业务系统;
- API 是产品:变体收敛成联合类型、组合优于配置、TypeScript 类型加契约测试,把错误用法挡在编译期;
- 样式走 Token:原始层 + 语义层双层架构,换肤换主题不动组件代码,彻底消灭硬编码色值;
- 文档自动化:示例代码即源码、props 表由类型自动生成、视觉回归进 CI,让文档永远追得上实现;
- 纪律比技巧重要:SemVer + codemod + 自动化日志,决定了使用者敢不敢跟着你升级。
组件库本质是「给团队用的内部产品」------API 设计的每个决定、文档里的每个示例,都在替未来的使用者做选择。把它当成产品认真打磨,回报会远超你的投入。