组件库建设
问题
如何从零搭建一套企业级前端组件库?组件设计原则、打包策略、样式方案、版本管理和测试策略分别是什么?
答案
组件库建设是前端工程化的核心能力之一。一个成熟的组件库需要从组件设计、文档工具、打包策略、样式方案、版本管理、主题定制和测试策略等多个维度进行系统化建设。
组件库整体架构
组件设计原则
良好的组件设计是组件库质量的基石。以下是核心设计原则:
1. 单一职责原则(SRP)
每个组件只做一件事,保持组件功能的内聚性。当一个组件承担过多职责时,应拆分为多个子组件。
import React from 'react';
interface ButtonProps {
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'small' | 'medium' | 'large';
loading?: boolean;
disabled?: boolean;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
// Button 只负责按钮的渲染和交互,不处理表单提交逻辑
const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'medium',
loading = false,
disabled = false,
onClick,
}) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
onClick={onClick}
>
{loading && <span className="btn-spinner" />}
{children}
</button>
);
};
export default Button;
当组件代码超过 300 行,或者 props 超过 10 个时,说明组件可能承担了过多职责,考虑拆分。例如将 Table 拆分为 TableHeader、TableBody、TableRow、TableCell 等子组件。
2. 开闭原则(OCP)
组件对扩展开放、对修改关闭。通过 组合、插槽(children/render props) 和 配置 来扩展功能,而不是频繁修改组件内部实现。
import React from 'react';
interface CardProps {
// 通过插槽实现扩展,而非为每种场景添加 prop
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
className?: string;
bordered?: boolean;
}
const Card: React.FC<CardProps> = ({
header,
footer,
children,
className = '',
bordered = true,
}) => {
return (
<div className={`card ${bordered ? 'card-bordered' : ''} ${className}`}>
{/* 头部区域可自定义渲染任意内容 */}
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
};
// 使用方通过组合扩展,无需修改 Card 源码
const UserCard: React.FC<{ name: string; avatar: string }> = ({ name, avatar }) => (
<Card
header={<h3>{name}</h3>}
footer={<button>关注</button>}
>
<img src={avatar} alt={name} />
</Card>
);
3. 受控与非受控模式
组件应同时支持受控模式(外部控制状态)和非受控模式(内部管理状态),让使用方灵活选择。这在 Input、Select、Modal 等涉及状态的组件中尤为重要。
import React, { useState, useCallback } from 'react';
interface InputProps {
/** 非受控模式的默认值 */
defaultValue?: string;
/** 受控模式的值 */
value?: string;
/** 值变化时的回调 */
onChange?: (value: string) => void;
placeholder?: string;
}
const Input: React.FC<InputProps> = ({
defaultValue = '',
value: controlledValue,
onChange,
placeholder,
}) => {
// 判断是否为受控模式
const isControlled = controlledValue !== undefined;
// 非受控模式下使用内部 state
const [internalValue, setInternalValue] = useState(defaultValue);
// 最终使用的值:受控用外部值,非受控用内部值
const mergedValue = isControlled ? controlledValue : internalValue;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
// 非受控模式下更新内部状态
if (!isControlled) {
setInternalValue(newValue);
}
// 无论哪种模式都触发回调
onChange?.(newValue);
},
[isControlled, onChange]
);
return (
<input
value={mergedValue}
onChange={handleChange}
placeholder={placeholder}
/>
);
};
Ant Design、Arco Design 等主流组件库都采用了受控/非受控双模式设计。推荐封装一个通用的 useControlled Hook 来统一处理这种逻辑,避免在每个组件中重复编写判断代码。
4. 其他重要设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| Props 向下传递 | 组件应支持透传原生 HTML 属性 | ...restProps 展开到根元素 |
| 组合优于继承 | 使用 children、render props 组合 | <Select renderOption={...} /> |
| 默认值合理 | 每个 prop 都应有合理的默认值 | size="medium" |
| 类型安全 | Props 使用 TypeScript 严格定义 | 使用 interface 而非 any |
| 无障碍(a11y) | 支持 ARIA 属性和键盘操作 | role、aria-label、tabIndex |
| 国际化友好 | 文本内容支持外部传入 | 避免硬编码中文 |
组件分类
一个完善的组件库通常包含三类组件:
基础组件(UI Components)
通用的、与业务无关的原子组件,是组件库的核心。
// 基础组件 —— 纯 UI,无业务逻辑
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Select } from './Select';
export { default as Modal } from './Modal';
export { default as Table } from './Table';
export { default as Tooltip } from './Tooltip';
export { default as Tabs } from './Tabs';
export { default as Tag } from './Tag';
export { default as Pagination } from './Pagination';
export { default as DatePicker } from './DatePicker';
业务组件(Business Components)
封装特定业务逻辑的组件,通常在基础组件之上组合而成。
import React from 'react';
import { Button, Input } from '@mylib/ui';
interface LoginFormProps {
onLogin: (username: string, password: string) => Promise<void>;
/** 是否显示第三方登录 */
showOAuth?: boolean;
/** 自定义登录校验规则 */
rules?: {
username?: (val: string) => string | undefined;
password?: (val: string) => string | undefined;
};
}
const LoginForm: React.FC<LoginFormProps> = ({
onLogin,
showOAuth = false,
rules,
}) => {
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [loading, setLoading] = React.useState(false);
const handleSubmit = async (): Promise<void> => {
// 校验
const usernameError = rules?.username?.(username);
const passwordError = rules?.password?.(password);
if (usernameError || passwordError) return;
setLoading(true);
try {
await onLogin(username, password);
} finally {
setLoading(false);
}
};
return (
<form className="login-form">
<Input
placeholder="用户名"
value={username}
onChange={setUsername}
/>
<Input
placeholder="密码"
value={password}
onChange={setPassword}
/>
<Button loading={loading} onClick={handleSubmit}>
登录
</Button>
{showOAuth && <div className="oauth-buttons">{/* ... */}</div>}
</form>
);
};
布局组件(Layout Components)
控制页面布局和元素间距的组件。
import React from 'react';
interface SpaceProps {
/** 间距方向 */
direction?: 'horizontal' | 'vertical';
/** 间距大小 */
size?: number | 'small' | 'medium' | 'large';
/** 对齐方式 */
align?: 'start' | 'center' | 'end' | 'baseline';
/** 是否自动换行 */
wrap?: boolean;
children: React.ReactNode;
}
const sizeMap: Record<string, number> = {
small: 8,
medium: 16,
large: 24,
};
const Space: React.FC<SpaceProps> = ({
direction = 'horizontal',
size = 'medium',
align = 'center',
wrap = false,
children,
}) => {
const gap = typeof size === 'number' ? size : sizeMap[size];
return (
<div
style={{
display: 'flex',
flexDirection: direction === 'horizontal' ? 'row' : 'column',
alignItems: align,
gap: `${gap}px`,
flexWrap: wrap ? 'wrap' : 'nowrap',
}}
>
{React.Children.map(children, (child) =>
child ? <div className="space-item">{child}</div> : null
)}
</div>
);
};
| 组件类型 | 特点 | 示例 | 复用范围 |
|---|---|---|---|
| 基础组件 | 无业务逻辑,纯 UI | Button、Input、Modal | 跨项目复用 |
| 业务组件 | 封装业务逻辑 | LoginForm、UserCard | 项目内复用 |
| 布局组件 | 控制布局和间距 | Grid、Space、Layout | 跨项目复用 |
文档工具:Storybook
Storybook 是目前最主流的组件文档和开发工具,支持隔离开发、交互式预览和自动化测试。
安装与配置
- npm
- yarn
- pnpm
npx storybook@latest init
yarn dlx storybook@latest init
pnpm dlx storybook@latest init
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
// 故事文件的匹配规则
stories: ['../src/**/*.stories.@(ts|tsx)'],
// 插件配置
addons: [
'@storybook/addon-essentials', // 核心工具集(Controls、Actions、Docs)
'@storybook/addon-a11y', // 无障碍检测
'@storybook/addon-interactions', // 交互测试
'@storybook/addon-designs', // 设计稿对比
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag', // 自动生成文档
},
typescript: {
reactDocgen: 'react-docgen-typescript', // 自动提取 Props 类型
},
};
export default config;
编写 Story
每个组件对应一个 .stories.tsx 文件,展示组件的所有用法和状态。
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';
// Meta 定义组件的全局配置
const meta: Meta<typeof Button> = {
title: 'Components/Button', // 文档中的路径
component: Button,
tags: ['autodocs'], // 自动生成文档页
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger'],
description: '按钮样式类型',
},
size: {
control: 'radio',
options: ['small', 'medium', 'large'],
},
onClick: { action: 'clicked' }, // Actions 面板记录事件
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// 默认状态
export const Primary: Story = {
args: {
children: '主按钮',
variant: 'primary',
},
};
// 加载状态
export const Loading: Story = {
args: {
children: '提交中...',
loading: true,
},
};
// 禁用状态
export const Disabled: Story = {
args: {
children: '不可用',
disabled: true,
},
};
// 所有尺寸展示
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
),
};
- 每个组件至少覆盖默认状态、各种变体、边界状态(加载、禁用、错误)
- 利用
argTypes让文档页自动生成可交互的 Controls 面板 - 使用
tags: ['autodocs']自动生成 API 文档,减少手动维护成本 - 利用 Storybook 的 play function 编写交互测试
打包策略
组件库需要输出多种模块格式以适配不同的使用场景。
模块格式对比
| 格式 | 用途 | 适用环境 | Tree Shaking |
|---|---|---|---|
| ESM | 现代打包工具(Vite、Webpack 5+) | 浏览器 / Node.js | 支持 |
| CJS | 旧版 Node.js / SSR | Node.js | 不支持 |
| UMD | CDN 直接引入 / script 标签 | 浏览器 | 不支持 |
Rollup 打包配置
Rollup 是组件库打包的首选工具,体积小、Tree Shaking 效果好。
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
import postcss from 'rollup-plugin-postcss';
import { readFileSync } from 'fs';
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'));
export default {
input: 'src/index.ts', // 入口文件
output: [
{
file: pkg.main, // dist/index.cjs.js
format: 'cjs',
sourcemap: true,
},
{
file: pkg.module, // dist/index.esm.js
format: 'esm',
sourcemap: true,
},
{
file: pkg.unpkg, // dist/index.umd.js
format: 'umd',
name: 'MyUI', // 全局变量名
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
],
plugins: [
peerDepsExternal(), // 自动外部化 peerDependencies
resolve(), // 解析 node_modules
commonjs(), // 转换 CJS 模块
typescript({ tsconfig: './tsconfig.build.json' }),
postcss({
extract: true, // 提取 CSS 为单独文件
minimize: true,
}),
],
external: ['react', 'react-dom'], // 不打包到产物中
};
package.json 关键字段
{
"name": "@myorg/ui",
"version": "1.0.0",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"unpkg": "dist/index.umd.js",
"files": ["dist"],
"sideEffects": ["*.css"],
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
},
"./styles": "./dist/styles.css",
"./*": {
"import": "./dist/components/*/index.esm.js",
"require": "./dist/components/*/index.cjs.js",
"types": "./dist/components/*/index.d.ts"
}
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
}
peerDependencies中声明宿主项目需要提供的依赖(如 React),避免重复打包sideEffects设为["*.css"],表示 CSS 文件有副作用不能被 Tree Shaking 移除,其余 JS 模块可以安全摇树exports字段是 Node.js 12+ 的模块导出规范,优先级高于main/module
按需加载
按需加载让使用方只引入需要的组件,减小最终打包体积。
// 方式一:直接路径引入(推荐,天然按需加载)
import Button from '@myorg/ui/button';
import Input from '@myorg/ui/input';
// 方式二:配合 babel-plugin-import 或 unplugin-vue-components
// 自动将 import { Button } from '@myorg/ui'
// 转换为 import Button from '@myorg/ui/button'
实现按需加载需要把每个组件单独打包:
import { build } from 'vite';
import { readdirSync } from 'fs';
import path from 'path';
// 获取所有组件目录
const components = readdirSync(path.resolve(__dirname, '../src/components'));
// 为每个组件单独构建
for (const name of components) {
await build({
build: {
lib: {
entry: path.resolve(__dirname, `../src/components/${name}/index.ts`),
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'esm' : 'cjs'}.js`,
},
outDir: `dist/components/${name}`,
rollupOptions: {
external: ['react', 'react-dom'],
},
},
});
}
样式方案
组件库的样式方案直接影响使用体验和主题定制能力。
方案对比
| 方案 | 优点 | 缺点 | 代表库 |
|---|---|---|---|
| CSS Variables | 原生支持、运行时切换、零依赖 | 需手动管理变量 | Ant Design 5、Arco Design |
| CSS Modules | 作用域隔离、不冲突 | 不支持动态主题 | Vant |
| CSS-in-JS | 完全动态、类型安全 | 运行时开销、SSR 复杂 | MUI、Chakra UI |
| Tailwind / UnoCSS | 开发效率高、体积小 | 组件库不易封装 | shadcn/ui、daisyUI |
CSS Variables 方案(推荐)
CSS Variables(CSS 自定义属性)是目前最主流的组件库样式方案,性能好且支持运行时主题切换。
/* 定义设计变量 */
:root {
/* 颜色 */
--color-primary: #1677ff;
--color-primary-hover: #4096ff;
--color-primary-active: #0958d9;
--color-success: #52c41a;
--color-warning: #faad14;
--color-error: #ff4d4f;
/* 尺寸 */
--font-size-sm: 12px;
--font-size-base: 14px;
--font-size-lg: 16px;
/* 圆角 */
--border-radius-sm: 4px;
--border-radius-base: 6px;
--border-radius-lg: 8px;
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* 暗色主题 */
[data-theme='dark'] {
--color-primary: #4096ff;
--color-bg: #141414;
--color-text: #ffffffd9;
}
.button {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-base);
border-radius: var(--border-radius-base);
background-color: var(--color-primary);
color: #fff;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.button:hover {
background-color: var(--color-primary-hover);
}
.button:active {
background-color: var(--color-primary-active);
}
CSS-in-JS 方案
以 styled-components 和 Emotion 为代表,适合需要高度动态样式的场景。
import styled from '@emotion/styled';
interface StyledButtonProps {
$variant: 'primary' | 'secondary' | 'danger';
$size: 'small' | 'medium' | 'large';
}
const sizeStyles = {
small: { padding: '4px 12px', fontSize: '12px' },
medium: { padding: '8px 16px', fontSize: '14px' },
large: { padding: '12px 24px', fontSize: '16px' },
};
const variantStyles = {
primary: { bg: 'var(--color-primary)', color: '#fff' },
secondary: { bg: 'transparent', color: 'var(--color-primary)' },
danger: { bg: 'var(--color-error)', color: '#fff' },
};
const StyledButton = styled.button<StyledButtonProps>`
border: none;
border-radius: var(--border-radius-base);
cursor: pointer;
transition: opacity 0.2s;
padding: ${({ $size }) => sizeStyles[$size].padding};
font-size: ${({ $size }) => sizeStyles[$size].fontSize};
background-color: ${({ $variant }) => variantStyles[$variant].bg};
color: ${({ $variant }) => variantStyles[$variant].color};
&:hover {
opacity: 0.85;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
2024 年以来,社区逐步从运行时 CSS-in-JS(如 styled-components)转向零运行时方案(如 vanilla-extract、Panda CSS),Ant Design 5 也转向了基于 CSS Variables 的 cssinjs 方案,在编译阶段生成静态 CSS,保留了类型安全的同时消除了运行时开销。
Design Token 系统
Design Token 是设计系统的基本单元,它将设计决策(颜色、间距、字号等)抽象为与平台无关的变量,确保设计与代码的一致性。
// Design Token 定义(与平台无关)
export const tokens = {
color: {
primary: { value: '#1677ff', description: '品牌主色' },
primaryHover: { value: '#4096ff', description: '主色悬停' },
success: { value: '#52c41a', description: '成功色' },
warning: { value: '#faad14', description: '警告色' },
error: { value: '#ff4d4f', description: '错误色' },
bgBase: { value: '#ffffff', description: '基础背景色' },
textBase: { value: '#000000e0', description: '基础文字色' },
},
spacing: {
xs: { value: '4px' },
sm: { value: '8px' },
md: { value: '16px' },
lg: { value: '24px' },
xl: { value: '32px' },
},
borderRadius: {
sm: { value: '4px' },
base: { value: '6px' },
lg: { value: '8px' },
full: { value: '9999px' },
},
fontSize: {
xs: { value: '12px' },
sm: { value: '14px' },
base: { value: '16px' },
lg: { value: '18px' },
xl: { value: '20px' },
},
shadow: {
sm: { value: '0 1px 2px rgba(0,0,0,0.06)' },
md: { value: '0 4px 12px rgba(0,0,0,0.08)' },
lg: { value: '0 8px 24px rgba(0,0,0,0.12)' },
},
} as const;
type TokenValue = { value: string; description?: string };
type TokenCategory = Record<string, TokenValue>;
// Token 转 CSS Variables
export function tokensToCSSVariables(
tokenObj: Record<string, TokenCategory>,
prefix = '--my'
): Record<string, string> {
const result: Record<string, string> = {};
for (const [category, values] of Object.entries(tokenObj)) {
for (const [key, token] of Object.entries(values)) {
const varName = `${prefix}-${category}-${key}`
.replace(/([A-Z])/g, '-$1')
.toLowerCase();
result[varName] = token.value;
}
}
return result;
}
主题提供者
import React, { createContext, useContext, useMemo } from 'react';
import { tokens, tokensToCSSVariables } from '../tokens/tokens';
type ThemeMode = 'light' | 'dark';
type TokenOverride = Record<string, Record<string, { value: string }>>;
interface ThemeContextValue {
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
mode: 'light',
setMode: () => {},
});
export const useTheme = () => useContext(ThemeContext);
interface ThemeProviderProps {
mode?: ThemeMode;
/** 自定义 Token 覆盖 */
tokenOverride?: TokenOverride;
children: React.ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
mode: initialMode = 'light',
tokenOverride,
children,
}) => {
const [mode, setMode] = React.useState<ThemeMode>(initialMode);
// 合并默认 Token 和用户自定义 Token
const mergedTokens = useMemo(() => {
const base = JSON.parse(JSON.stringify(tokens));
if (tokenOverride) {
for (const [category, values] of Object.entries(tokenOverride)) {
if (base[category]) {
Object.assign(base[category], values);
}
}
}
return base;
}, [tokenOverride]);
// 生成 CSS Variables
const cssVars = useMemo(
() => tokensToCSSVariables(mergedTokens),
[mergedTokens]
);
return (
<ThemeContext.Provider value={{ mode, setMode }}>
<div
data-theme={mode}
style={cssVars as React.CSSProperties}
>
{children}
</div>
</ThemeContext.Provider>
);
};
使用方可以在应用根部进行主题定制:
import { ThemeProvider, Button } from '@myorg/ui';
function App() {
return (
<ThemeProvider
mode="light"
tokenOverride={{
color: {
primary: { value: '#722ed1' }, // 覆盖主色为紫色
},
}}
>
<Button variant="primary">自定义主题色按钮</Button>
</ThemeProvider>
);
}
版本管理与发布
语义化版本(SemVer)
| 版本类型 | 格式 | 何时升级 | 示例 |
|---|---|---|---|
| Major | X.0.0 | 有破坏性变更(Breaking Change) | 1.0.0 -> 2.0.0 |
| Minor | 0.X.0 | 新增功能,向下兼容 | 1.0.0 -> 1.1.0 |
| Patch | 0.0.X | 修复 bug,向下兼容 | 1.0.0 -> 1.0.1 |
Changesets 工作流
Changesets 是 Monorepo 场景下最推荐的版本管理工具,可以自动生成 CHANGELOG、管理版本号。
- npm
- Yarn
- pnpm
- Bun
npm install --save-dev @changesets/cli @changesets/changelog-github
yarn add --dev @changesets/cli @changesets/changelog-github
pnpm add --save-dev @changesets/cli @changesets/changelog-github
bun add --dev @changesets/cli @changesets/changelog-github
- npm
- yarn
- pnpm
npx changeset init
yarn changeset init
pnpm changeset init
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": [
"@changesets/changelog-github",
{ "repo": "myorg/my-ui" }
],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}
日常开发流程:
- npm
- yarn
- pnpm
# 1. 添加变更描述
npx changeset
# 2. 发版时消费所有 changeset
npx changeset version
# 3. 发布
npx changeset publish
yarn changeset
yarn changeset version
yarn changeset publish
pnpm changeset
pnpm changeset version
pnpm changeset publish
流程说明:
changeset— 交互式选择影响的包、版本类型、变更描述changeset version— 自动更新 package.json 版本号 + 生成 CHANGELOG.mdchangeset publish— 发布到 npm
CI 自动发布
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm test
# Changesets 自动创建 Release PR 或发布
- name: Create Release PR or Publish
uses: changesets/action@v1
with:
publish: pnpm changeset publish
version: pnpm changeset version
commit: 'chore: version packages'
title: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- 发布前务必运行完整的测试和构建,确保产物正确
- 使用
npm pack预览将要发布的文件,检查files字段是否正确 - 首次发布 scoped 包(
@org/xxx)需指定--access public - 在 CI 中使用
NPM_TOKEN环境变量进行认证,不要在代码中暴露 Token
测试策略
组件库的质量保证需要多层次的测试策略。
单元测试
使用 Vitest + React Testing Library 编写组件单元测试。
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../Button';
describe('Button', () => {
// 基础渲染测试
it('should render children correctly', () => {
render(<Button>Click Me</Button>);
expect(screen.getByText('Click Me')).toBeInTheDocument();
});
// Props 测试
it('should apply variant class', () => {
render(<Button variant="danger">Delete</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-danger');
});
// 交互测试
it('should call onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Submit</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
// 状态测试
it('should be disabled when loading', () => {
render(<Button loading>Loading...</Button>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
// 非受控输入测试
it('should not call onClick when disabled', () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Disabled</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
});
视觉回归测试
视觉回归测试通过截图对比来检测 UI 变化,防止样式意外改动。推荐使用 Chromatic(与 Storybook 深度集成)或 Playwright 的截图功能。
import { test, expect } from '@playwright/test';
test.describe('Button Visual', () => {
// 基础快照
test('default button', async ({ page }) => {
await page.goto('/iframe.html?id=components-button--primary');
const button = page.locator('button');
await expect(button).toHaveScreenshot('button-primary.png');
});
// 各种状态对比
test('button states', async ({ page }) => {
await page.goto('/iframe.html?id=components-button--all-sizes');
await expect(page).toHaveScreenshot('button-all-sizes.png', {
maxDiffPixelRatio: 0.01, // 允许 1% 的像素差异
});
});
// hover 状态
test('hover state', async ({ page }) => {
await page.goto('/iframe.html?id=components-button--primary');
const button = page.locator('button');
await button.hover();
await expect(button).toHaveScreenshot('button-primary-hover.png');
});
});
a11y 无障碍测试
使用 axe-core 进行自动化无障碍检测,确保组件符合 WCAG 标准。
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from '../Button';
expect.extend(toHaveNoViolations);
describe('Button Accessibility', () => {
// 自动检测所有 WCAG 违规
it('should have no accessibility violations', async () => {
const { container } = render(<Button>Accessible Button</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// 特定的 a11y 检查
it('should have proper ARIA attributes when loading', () => {
const { getByRole } = render(<Button loading>Loading</Button>);
const button = getByRole('button');
expect(button).toHaveAttribute('aria-busy', 'true');
});
it('should be keyboard navigable', () => {
const { getByRole } = render(<Button>Focusable</Button>);
const button = getByRole('button');
button.focus();
expect(button).toHaveFocus();
});
});
测试策略总结
| 测试类型 | 工具 | 检测内容 | 运行频率 |
|---|---|---|---|
| 单元测试 | Vitest + RTL | 功能逻辑、Props、事件 | 每次提交 |
| 视觉回归 | Chromatic / Playwright | 样式变化、布局偏移 | 每次 PR |
| a11y 测试 | axe-core + Storybook addon | WCAG 合规性 | 每次提交 |
| 交互测试 | Storybook play function | 用户操作流程 | 每次 PR |
| E2E 测试 | Playwright / Cypress | 完整用户场景 | 发版前 |
常见面试问题
Q1: 如何设计一个通用的组件库?需要考虑哪些方面?
答案:
设计组件库需要从以下几个维度系统性思考:
1. 技术架构
// monorepo 组织方式(推荐)
packages/
├── components/ // 基础组件
│ ├── button/
│ │ ├── src/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ └── index.ts
│ │ ├── __tests__/
│ │ └── package.json
│ ├── input/
│ └── ...
├── tokens/ // Design Token
├── hooks/ // 通用 Hooks
├── utils/ // 工具函数
├── theme/ // 主题系统
└── docs/ // Storybook 文档站
2. 组件设计
- 遵循单一职责、开闭原则
- 支持受控和非受控模式
- 透传原生 HTML 属性(
...restProps) - TypeScript 严格类型定义
- 支持
ref转发(React.forwardRef)
3. 样式方案
- 使用 CSS Variables + Design Token 实现主题定制
- 样式作用域隔离(CSS Modules 或 BEM 命名)
- 支持暗色模式和自定义主题
4. 打包发布
- 输出 ESM / CJS / UMD 三种格式
- 支持按需加载和 Tree Shaking
sideEffects标记正确
5. 质量保障
- 单元测试覆盖率 > 80%
- 视觉回归测试防止样式变动
- a11y 无障碍测试合规
- 完善的 Storybook 文档
Q2: 组件库如何实现按需加载?Tree Shaking 和 babel-plugin-import 有什么区别?
答案:
按需加载有两种主流方案:
方案一:Tree Shaking(推荐)
依赖 ESM 的静态分析能力,打包工具自动移除未使用的导出。
// 使用方这样写即可,打包工具自动 Tree Shaking
import { Button, Input } from '@myorg/ui';
// 未使用的 Modal、Table 等不会被打包
组件库需要满足以下条件:
{
"module": "dist/index.esm.js",
"sideEffects": ["*.css"],
"exports": {
".": {
"import": "./dist/index.esm.js"
}
}
}
方案二:babel-plugin-import / unplugin
在编译阶段将整包引入转换为路径引入。
// 编译前
import { Button } from 'antd';
// 编译后(babel-plugin-import 自动转换)
import Button from 'antd/es/button';
import 'antd/es/button/style/css';
对比:
| 对比项 | Tree Shaking | babel-plugin-import |
|---|---|---|
| 额外配置 | 无需(原生支持) | 需要 Babel 插件 |
| CSS 处理 | 需手动引入或全局引入 | 自动引入组件样式 |
| 适用场景 | 现代打包工具 | Babel 编译场景 |
| 粒度 | export 级别 | 文件级别 |
| 趋势 | 主流方向 | 逐渐被 Tree Shaking 替代 |
Ant Design 5 已经不再需要 babel-plugin-import,完全依赖 Tree Shaking。新项目推荐直接使用 Tree Shaking 方案。
Q3: 组件库的样式方案怎么选?CSS-in-JS 和 CSS Variables 各有什么优缺点?
答案:
CSS Variables 方案(Ant Design 5、Arco Design):
// 通过修改 CSS Variables 实现主题切换
function toggleTheme(mode: 'light' | 'dark'): void {
document.documentElement.setAttribute('data-theme', mode);
}
// 或者通过 JS 动态修改单个变量
function setPrimaryColor(color: string): void {
document.documentElement.style.setProperty('--color-primary', color);
}
CSS-in-JS 方案(MUI、Chakra UI):
import styled, { ThemeProvider } from 'styled-components';
const theme = {
colors: { primary: '#1677ff' },
spacing: { md: '16px' },
};
const StyledButton = styled.button`
background: ${(props) => props.theme.colors.primary};
padding: ${(props) => props.theme.spacing.md};
`;
// 使用
<ThemeProvider theme={theme}>
<StyledButton>Themed Button</StyledButton>
</ThemeProvider>
完整对比:
| 维度 | CSS Variables | CSS-in-JS | 零运行时 CSS-in-JS |
|---|---|---|---|
| 运行时性能 | 极佳(原生) | 有开销 | 极佳(编译时生成) |
| 动态主题 | 支持 | 完全支持 | 支持 |
| 类型安全 | 不支持 | 完全支持 | 支持 |
| SSR 兼容 | 天然支持 | 需要额外配置 | 天然支持 |
| 包体积 | 零额外依赖 | 需引入运行时 | 接近零 |
| 代表方案 | Ant Design 5 | MUI、Emotion | vanilla-extract、Panda CSS |
面试推荐回答:目前社区趋势是 CSS Variables + 零运行时 CSS-in-JS。CSS Variables 天然支持运行时主题切换且零性能开销,配合 Design Token 系统可以满足大多数定制需求。如果团队需要类型安全的样式开发体验,可以考虑 vanilla-extract 或 Panda CSS 等零运行时方案。
相关链接
- Storybook 官方文档
- Changesets 文档
- Rollup 官方文档
- vanilla-extract - 零运行时 CSS-in-JS
- Panda CSS - 构建时 CSS-in-JS
- Chromatic - 视觉回归测试平台
- axe-core - 无障碍测试引擎
- WCAG 快速参考 - Web 无障碍标准
- Ant Design 设计体系 - 优秀的组件库设计参考
- Semantic Versioning - 语义化版本规范