设计组件库
问题
如何从零设计和架构一个组件库(类似 Ant Design、Element Plus、shadcn/ui、Radix UI)?架构分层、API 设计模式、样式方案、可访问性等方面需要做哪些关键的技术决策?
答案
组件库的架构设计是前端系统设计面试中的高频题目。不同于组件库建设侧重工程化流程(Storybook、打包、发布、测试),本文聚焦架构层面的设计决策和模式选型:如何分层、如何设计 API、如何实现 Headless 逻辑、如何搭建主题系统等。同时也可以参考 React 组件通用设计 中关于单组件设计模式的内容。
一、概述:何时自建组件库
在决定是否自建组件库前,需要做清晰的场景判断:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 中后台项目,标准化 UI | 直接用 Ant Design / Element Plus | 开箱即用,维护成本低 |
| C 端产品,有独立设计系统 | Headless UI + 自定义样式 | 完全控制 UI,适配品牌设计 |
| 多产品线,统一设计语言 | 自建组件库 | 一致性、品牌化、沉淀业务能力 |
| 快速验证原型 | shadcn/ui(复制粘贴模式) | 灵活修改,无版本锁定 |
ROI(投入产出比) 是关键。自建组件库的隐性成本很高(开发、维护、文档、迁移),只有在多个产品线需要统一设计语言,或者现有组件库严重无法满足定制需求时才考虑自建。
三种建设路径
| 路径 | 开发成本 | 维护成本 | 灵活度 | 代表方案 |
|---|---|---|---|---|
| 全量自建 | 极高 | 高 | 极高 | Ant Design、MUI |
| 基于 Headless 封装 | 中 | 低 | 高 | shadcn/ui(基于 Radix) |
| Fork + 定制 | 低 | 高(升级困难) | 中 | Fork Ant Design |
二、组件库架构分层
一个成熟的组件库通常采用四层架构,从底层到上层依次是:
各层职责
| 层级 | 职责 | 包含样式 | 示例 |
|---|---|---|---|
| Headless 逻辑层 | 纯状态管理 + 交互逻辑 + ARIA | 无 | useSelect、useCombobox、useDialog |
| Primitive 原子层 | 最小 UI 单元,自带基础样式 | 有 | Button、Input、Popover、Checkbox |
| Composed 组合层 | 多个原子组件的组合 | 有 | DatePicker、Combobox、Transfer |
| Recipe 业务层 | 封装特定业务逻辑 | 有 | LoginForm、PaymentCard |
分层让不同的使用者可以在不同层级接入:需要完全自定义 UI 的用 Headless 层,需要快速开发的用 Composed 层,需要业务开箱即用的用 Recipe 层。
项目目录结构
packages/
├── headless/ // 第一层:Headless Hooks
│ ├── use-select/
│ ├── use-combobox/
│ ├── use-dialog/
│ └── use-date-picker/
├── primitives/ // 第二层:原子组件
│ ├── button/
│ ├── input/
│ ├── popover/
│ └── checkbox/
├── components/ // 第三层:组合组件
│ ├── date-picker/
│ ├── combobox/
│ └── color-picker/
├── recipes/ // 第四层:业务组件
│ ├── login-form/
│ └── user-card/
├── tokens/ // Design Token
├── theme/ // 主题系统
└── utils/ // 通用工具
三、组件 API 设计原则
组件 API 的设计质量直接决定了组件库的开发体验。以下是五种核心 API 设计模式。
3.1 受控/非受控统一模式
所有涉及状态的组件(Input、Select、Modal、Tabs 等)都应同时支持受控和非受控模式。核心实现是 useControllableState Hook:
import { useState, useCallback, useRef } from 'react';
function useControllableState<T>(
controlledValue: T | undefined,
defaultValue: T,
onChange?: (value: T) => void,
): [T, (next: T | ((prev: T) => T)) => void] {
const isControlled = controlledValue !== undefined;
const isControlledRef = useRef(isControlled);
// 开发环境下检测模式切换
if (process.env.NODE_ENV !== 'production') {
if (isControlledRef.current !== isControlled) {
console.warn(
'组件在受控和非受控模式之间切换,这可能导致不可预期的行为。'
);
}
}
const [internalValue, setInternalValue] = useState<T>(defaultValue);
const value = isControlled ? controlledValue : internalValue;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === 'function'
? (next as (prev: T) => T)(value)
: next;
// 非受控模式下更新内部状态
if (!isControlled) {
setInternalValue(nextValue);
}
// 无论哪种模式都触发回调
onChange?.(nextValue);
},
[isControlled, value, onChange],
);
return [value, setValue];
}
export { useControllableState };
使用示例:
interface SwitchProps {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
}
function Switch({ checked, defaultChecked = false, onChange, disabled }: SwitchProps) {
const [isChecked, setIsChecked] = useControllableState(checked, defaultChecked, onChange);
return (
<button
role="switch"
aria-checked={isChecked}
disabled={disabled}
onClick={() => !disabled && setIsChecked((prev) => !prev)}
className={`switch ${isChecked ? 'switch-on' : 'switch-off'}`}
>
<span className="switch-thumb" />
</button>
);
}
// 受控模式
<Switch checked={value} onChange={setValue} />
// 非受控模式
<Switch defaultChecked={true} />
// 非受控 + 监听
<Switch defaultChecked={false} onChange={(v) => console.log(v)} />
3.2 Compound Components 模式
Compound Components 通过 Context 在父子组件间隐式共享状态,用户以声明式的方式自由组合子组件。
import React, { createContext, useContext, useState, useCallback } from 'react';
// ---- Context 定义 ----
interface SelectContextValue {
value: string | undefined;
onSelect: (value: string) => void;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
highlightedIndex: number;
setHighlightedIndex: (index: number) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext(): SelectContextValue {
const ctx = useContext(SelectContext);
if (!ctx) {
throw new Error('Select 子组件必须在 <Select> 内部使用');
}
return ctx;
}
// ---- 父组件 ----
interface SelectProps {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
children: React.ReactNode;
}
function Select({ value, defaultValue, onChange, children }: SelectProps) {
const [selectedValue, setSelectedValue] = useControllableState(value, defaultValue ?? '', onChange);
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const onSelect = useCallback(
(val: string) => {
setSelectedValue(val);
setIsOpen(false);
},
[setSelectedValue],
);
return (
<SelectContext.Provider
value={{ value: selectedValue, onSelect, isOpen, setIsOpen, highlightedIndex, setHighlightedIndex }}
>
<div className="select-root" role="listbox">
{children}
</div>
</SelectContext.Provider>
);
}
// ---- Trigger 子组件 ----
function SelectTrigger({ children, placeholder }: { children?: React.ReactNode; placeholder?: string }) {
const { value, isOpen, setIsOpen } = useSelectContext();
return (
<button
className="select-trigger"
aria-expanded={isOpen}
onClick={() => setIsOpen(!isOpen)}
>
{children ?? value ?? placeholder ?? '请选择'}
</button>
);
}
// ---- Option 子组件 ----
function SelectOption({ value, children, disabled }: { value: string; children: React.ReactNode; disabled?: boolean }) {
const ctx = useSelectContext();
const isSelected = ctx.value === value;
return (
<div
role="option"
aria-selected={isSelected}
aria-disabled={disabled}
className={`select-option ${isSelected ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
onClick={() => !disabled && ctx.onSelect(value)}
>
{children}
{isSelected && <span className="check-icon">✓</span>}
</div>
);
}
// ---- 挂载子组件到命名空间 ----
Select.Trigger = SelectTrigger;
Select.Option = SelectOption;
export { Select };
使用方式对比:
// 配置式 — 灵活性低
<Select options={[{ label: 'React', value: 'react' }]} />
// 组合式 — 灵活性高
<Select defaultValue="react">
<Select.Trigger placeholder="选择框架" />
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
<Select.Option value="angular" disabled>Angular</Select.Option>
</Select>
3.3 Polymorphic Components(多态组件)
多态组件通过 as prop 改变底层渲染的 HTML 元素或组件,在保持样式和行为的同时灵活切换语义标签。
import React from 'react';
// 多态组件的核心类型定义
type PolymorphicProps<E extends React.ElementType, P = {}> = P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'> & {
as?: E;
};
type PolymorphicRef<E extends React.ElementType> =
React.ComponentPropsWithRef<E>['ref'];
// 带 ref 转发的多态组件类型
type PolymorphicComponentWithRef<
DefaultElement extends React.ElementType,
Props = {},
> = <E extends React.ElementType = DefaultElement>(
props: PolymorphicProps<E, Props> & { ref?: PolymorphicRef<E> }
) => React.ReactNode;
// ---- 实现 ----
interface ButtonOwnProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
const Button: PolymorphicComponentWithRef<'button', ButtonOwnProps> =
React.forwardRef(function Button<E extends React.ElementType = 'button'>(
{ as, variant = 'primary', size = 'md', loading, children, ...rest }: PolymorphicProps<E, ButtonOwnProps>,
ref: PolymorphicRef<E>,
) {
const Component = as ?? 'button';
return (
<Component
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={loading}
{...rest}
>
{loading && <span className="spinner" />}
{children}
</Component>
);
});
使用示例:
// 默认渲染为 <button>
<Button variant="primary">提交</Button>
// 渲染为 <a> 标签
<Button as="a" href="/docs" variant="ghost">查看文档</Button>
// 渲染为 React Router 的 Link
import { Link } from 'react-router-dom';
<Button as={Link} to="/dashboard" variant="secondary">进入控制台</Button>
3.4 Slot 模式(asChild)
Radix UI 首创的 asChild 模式,将组件的行为和属性"传递"到子元素上,而非包裹一层 DOM。比 as 更灵活,避免了泛型类型体操。
import React from 'react';
interface SlotProps extends React.HTMLAttributes<HTMLElement> {
children: React.ReactNode;
}
/**
* Slot 组件:将自身的 props 合并到唯一子元素上
*/
function Slot({ children, ...slotProps }: SlotProps) {
if (!React.isValidElement(children)) {
return null;
}
// 合并 className
const mergedProps = {
...slotProps,
...children.props,
className: [slotProps.className, children.props.className]
.filter(Boolean)
.join(' '),
style: { ...slotProps.style, ...children.props.style },
};
return React.cloneElement(children, mergedProps);
}
// ---- Button 支持 asChild ----
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
asChild?: boolean;
}
function Button({ asChild, variant = 'primary', className, ...props }: ButtonProps) {
const combinedClassName = `btn btn-${variant} ${className ?? ''}`;
if (asChild) {
return <Slot className={combinedClassName} {...props} />;
}
return <button className={combinedClassName} {...props} />;
}
使用示例:
// 普通用法
<Button variant="primary">点击</Button>
// asChild:把 Button 的样式和行为传递给子元素
<Button asChild variant="primary">
<a href="/docs">查看文档</a>
</Button>
// 渲染结果:<a href="/docs" class="btn btn-primary">查看文档</a>
3.5 API 设计模式对比
| 模式 | 适用场景 | 优点 | 缺点 | 代表库 |
|---|---|---|---|---|
| 受控/非受控 | 所有有状态组件 | 灵活,兼容两种用法 | 需要额外 Hook | 所有主流库 |
| Compound Components | 复杂组合组件 | 声明式、灵活 | Context 开销 | Radix、Chakra |
as 多态 | 需要切换元素的组件 | 类型安全 | 泛型复杂 | Chakra UI、MUI |
asChild Slot | 需要合并属性到子元素 | 无泛型、更灵活 | 限制单子元素 | Radix UI |
| Render Props | 需要完全控制渲染 | 极致灵活 | 嵌套较深 | Downshift |
四、Headless UI 设计
什么是 Headless UI
Headless UI 将交互逻辑和视觉样式完全分离。逻辑层以 Hook 或 renderless 组件的形式输出状态和事件处理器,用户完全掌控渲染。
实现一个 Headless useCombobox
Combobox(可搜索下拉选择器)是 Headless 设计的经典案例,它涉及状态管理、键盘导航和 ARIA 属性三大核心能力。
import { useState, useCallback, useRef, useMemo } from 'react';
interface ComboboxOption {
label: string;
value: string;
disabled?: boolean;
}
interface UseComboboxOptions {
options: ComboboxOption[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
onInputChange?: (input: string) => void;
}
function useCombobox({
options,
value,
defaultValue,
onChange,
onInputChange,
}: UseComboboxOptions) {
const [selectedValue, setSelectedValue] = useControllableState(
value, defaultValue ?? '', onChange
);
const [inputValue, setInputValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// 过滤选项
const filteredOptions = useMemo(
() => options.filter((opt) =>
opt.label.toLowerCase().includes(inputValue.toLowerCase())
),
[options, inputValue],
);
// 选中某项
const selectOption = useCallback(
(val: string) => {
const option = options.find((o) => o.value === val);
if (option && !option.disabled) {
setSelectedValue(val);
setInputValue(option.label);
setIsOpen(false);
setHighlightedIndex(-1);
}
},
[options, setSelectedValue],
);
// 键盘导航
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
Math.min(prev + 1, filteredOptions.length - 1)
);
}
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (isOpen && highlightedIndex >= 0) {
selectOption(filteredOptions[highlightedIndex].value);
}
break;
case 'Escape':
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
},
[isOpen, highlightedIndex, filteredOptions, selectOption],
);
// ---- Props Getter 模式:返回要 spread 的 props ----
const getInputProps = useCallback(() => ({
ref: inputRef,
value: inputValue,
role: 'combobox' as const,
'aria-expanded': isOpen,
'aria-autocomplete': 'list' as const,
'aria-activedescendant':
highlightedIndex >= 0 ? `combobox-option-${highlightedIndex}` : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setInputValue(val);
setIsOpen(true);
setHighlightedIndex(-1);
onInputChange?.(val);
},
onKeyDown: handleKeyDown,
onFocus: () => setIsOpen(true),
onBlur: () => setTimeout(() => setIsOpen(false), 200),
}), [inputValue, isOpen, highlightedIndex, handleKeyDown, onInputChange]);
const getListProps = useCallback(() => ({
ref: listRef,
role: 'listbox' as const,
}), []);
const getOptionProps = useCallback(
(index: number) => ({
id: `combobox-option-${index}`,
role: 'option' as const,
'aria-selected': filteredOptions[index]?.value === selectedValue,
'aria-disabled': filteredOptions[index]?.disabled,
'data-highlighted': index === highlightedIndex,
onClick: () => selectOption(filteredOptions[index].value),
onMouseEnter: () => setHighlightedIndex(index),
}),
[filteredOptions, selectedValue, highlightedIndex, selectOption],
);
return {
// 状态
isOpen,
inputValue,
selectedValue,
highlightedIndex,
filteredOptions,
// Props getter
getInputProps,
getListProps,
getOptionProps,
// 操作
setIsOpen,
selectOption,
};
}
使用 Headless Hook 构建自定义 UI:
function MyCombobox() {
const {
isOpen,
filteredOptions,
getInputProps,
getListProps,
getOptionProps,
} = useCombobox({
options: [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Angular', value: 'angular' },
{ label: 'Svelte', value: 'svelte' },
],
});
return (
<div className="my-combobox">
<input {...getInputProps()} placeholder="搜索框架..." />
{isOpen && filteredOptions.length > 0 && (
<ul {...getListProps()} className="dropdown">
{filteredOptions.map((opt, i) => (
<li key={opt.value} {...getOptionProps(i)} className="dropdown-item">
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
Headless 组件库对比
| 特性 | Radix UI | Headless UI | React Aria | Ark UI |
|---|---|---|---|---|
| 维护者 | WorkOS | Tailwind Labs | Adobe | Chakra 团队 |
| 框架支持 | React | React / Vue | React | React / Vue / Solid |
| 组件数量 | 30+ | 10+ | 40+ | 30+ |
| ARIA 完整度 | 高 | 中 | 极高 | 高 |
| 样式方案 | 无样式 + data-state | 无样式 | 无样式 | 无样式 |
| 动画支持 | data-state + CSS | Transition 组件 | 无内置 | 状态属性 |
| 包体积 | 按组件引入,小 | 小 | 中等 | 中等 |
| 适用场景 | 通用 | Tailwind 项目 | 高 a11y 要求 | 多框架项目 |
五、主题系统设计
5.1 Design Token 分层
Design Token 是设计系统与代码的桥梁。Token 的分层决定了主题的灵活度和维护成本。
| Token 层级 | 职责 | 变动频率 | 示例 |
|---|---|---|---|
| Global Token | 原始设计值,与品牌无关 | 极少 | --color-blue-500、--spacing-4 |
| Alias Token | 语义化映射 | 主题切换时变动 | --color-primary、--color-bg-page |
| Component Token | 组件专属 | 组件定制时变动 | --btn-bg、--input-border |
5.2 CSS Variables 实现
// ---- Global Tokens ----
const globalTokens = {
// 调色板
'--color-blue-50': '#eff6ff',
'--color-blue-500': '#3b82f6',
'--color-blue-600': '#2563eb',
'--color-blue-700': '#1d4ed8',
'--color-gray-50': '#f9fafb',
'--color-gray-100': '#f3f4f6',
'--color-gray-900': '#111827',
// 间距
'--spacing-1': '4px',
'--spacing-2': '8px',
'--spacing-3': '12px',
'--spacing-4': '16px',
'--spacing-6': '24px',
// 圆角
'--radius-sm': '4px',
'--radius-md': '8px',
'--radius-lg': '12px',
'--radius-full': '9999px',
// 字号
'--font-size-xs': '12px',
'--font-size-sm': '14px',
'--font-size-base': '16px',
'--font-size-lg': '18px',
} as const;
// ---- Alias Tokens(Light 主题)----
const lightAliasTokens = {
'--color-primary': 'var(--color-blue-500)',
'--color-primary-hover': 'var(--color-blue-600)',
'--color-primary-active': 'var(--color-blue-700)',
'--color-bg-page': '#ffffff',
'--color-bg-elevated': '#ffffff',
'--color-text-primary': 'var(--color-gray-900)',
'--color-text-secondary': '#6b7280',
'--color-border': '#e5e7eb',
} as const;
// ---- Alias Tokens(Dark 主题)----
const darkAliasTokens = {
'--color-primary': '#60a5fa',
'--color-primary-hover': '#93bbfd',
'--color-primary-active': '#3b82f6',
'--color-bg-page': '#0f172a',
'--color-bg-elevated': '#1e293b',
'--color-text-primary': '#f1f5f9',
'--color-text-secondary': '#94a3b8',
'--color-border': '#334155',
} as const;
export { globalTokens, lightAliasTokens, darkAliasTokens };
5.3 暗色模式实现
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { globalTokens, lightAliasTokens, darkAliasTokens } from '../tokens/tokens';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextValue {
mode: ThemeMode;
resolvedMode: 'light' | 'dark';
setMode: (mode: ThemeMode) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
mode: 'system',
resolvedMode: 'light',
setMode: () => {},
});
export const useTheme = () => useContext(ThemeContext);
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
interface ThemeProviderProps {
defaultMode?: ThemeMode;
storageKey?: string;
children: React.ReactNode;
}
export function ThemeProvider({
defaultMode = 'system',
storageKey = 'ui-theme',
children,
}: ThemeProviderProps) {
const [mode, setModeState] = useState<ThemeMode>(() => {
if (typeof window === 'undefined') return defaultMode;
return (localStorage.getItem(storageKey) as ThemeMode) ?? defaultMode;
});
const resolvedMode = mode === 'system' ? getSystemTheme() : mode;
const setMode = useCallback(
(newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem(storageKey, newMode);
},
[storageKey],
);
// 监听系统主题变化
useEffect(() => {
if (mode !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setModeState('system'); // 触发重新渲染
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [mode]);
// 应用 CSS Variables
useEffect(() => {
const root = document.documentElement;
const aliasTokens = resolvedMode === 'dark' ? darkAliasTokens : lightAliasTokens;
// 设置 Global + Alias tokens
const allTokens = { ...globalTokens, ...aliasTokens };
for (const [key, value] of Object.entries(allTokens)) {
root.style.setProperty(key, value);
}
root.setAttribute('data-theme', resolvedMode);
}, [resolvedMode]);
return (
<ThemeContext.Provider value={{ mode, resolvedMode, setMode }}>
{children}
</ThemeContext.Provider>
);
}
5.4 多品牌主题
同一组件库支持多品牌皮肤的核心思路是 Alias Token 层按品牌覆盖:
// ---- 品牌 A(蓝色调)----
const brandATokens = {
'--color-primary': '#3b82f6',
'--color-primary-hover': '#2563eb',
'--radius-md': '8px',
'--font-family': "'Inter', sans-serif",
};
// ---- 品牌 B(紫色调)----
const brandBTokens = {
'--color-primary': '#8b5cf6',
'--color-primary-hover': '#7c3aed',
'--radius-md': '12px',
'--font-family': "'Poppins', sans-serif",
};
// ---- 品牌 C(绿色调)----
const brandCTokens = {
'--color-primary': '#10b981',
'--color-primary-hover': '#059669',
'--radius-md': '4px',
'--font-family': "'Noto Sans SC', sans-serif",
};
// 使用方式:
// <ThemeProvider brand="brandB">
// <App />
// </ThemeProvider>
六、样式方案选型
全方位对比
| 维度 | CSS Modules | CSS-in-JS (runtime) | Tailwind CSS | Zero-runtime CSS-in-JS |
|---|---|---|---|---|
| 代表方案 | 原生支持 | styled-components / Emotion | Tailwind CSS | vanilla-extract / Panda CSS |
| DX 开发体验 | 中 | 高(类型安全、动态) | 高(原子化、快速) | 高(类型安全) |
| 运行时性能 | 极佳(零运行时) | 有开销(插入 style) | 极佳(纯 CSS) | 极佳(编译时生成) |
| SSR 兼容性 | 天然支持 | 需额外配置 | 天然支持 | 天然支持 |
| 动态主题 | CSS Variables | 完全动态 | CSS Variables | CSS Variables |
| Tree Shaking | 文件级 | 需 babel 插件 | PurgeCSS 自动 | 编译时按需 |
| 包体积 | 按组件引入 | 包含运行时 ~12KB | 原子类去重 | 接近零运行时 |
| 类型安全 | 无 | 完全支持 | Tailwind Intellisense | 完全支持 |
| 生态/社区 | 广泛 | 成熟但逐渐下降 | 极火 | 上升中 |
2024-2025 年社区的明显趋势是从运行时 CSS-in-JS(styled-components/Emotion)迁出,转向零运行时方案或 Tailwind CSS。核心原因:
- React 18 Streaming SSR 与运行时 CSS-in-JS 不兼容
- React Server Components 不支持 Context(运行时 CSS-in-JS 依赖 ThemeContext)
- 运行时性能开销在大型应用中明显
Ant Design 5 从 Less 迁到 cssinjs(编译时生成),MUI 也在探索零运行时方案。
组件库样式方案推荐
七、可访问性(Accessibility)
可访问性不是"加分项",而是组件库的基本要求。WAI-ARIA 规范定义了常见交互组件的标准行为模式。
WAI-ARIA 模式清单
| 组件 | ARIA Role | 关键属性 | 键盘交互 |
|---|---|---|---|
| Dialog | role="dialog" | aria-modal、aria-labelledby | Esc 关闭、Tab 陷阱 |
| Select | role="listbox" | aria-expanded、aria-selected | Arrow 导航、Enter 选中 |
| Tabs | role="tablist" | aria-selected、aria-controls | Arrow 切换、Home/End |
| Menu | role="menu" | aria-expanded、aria-haspopup | Arrow 导航、Enter 激活 |
| Accordion | role="region" | aria-expanded、aria-controls | Enter/Space 展开 |
| Tooltip | role="tooltip" | aria-describedby | 聚焦显示、Esc 隐藏 |
| Switch | role="switch" | aria-checked | Space 切换 |
键盘导航:Roving Tabindex
Roving Tabindex 是复合组件(Tabs、Menu、RadioGroup)中管理焦点的标准模式:同一时刻只有一个元素 tabIndex={0}(可 Tab 聚焦),其余为 tabIndex={-1}(只能通过 Arrow 键导航到)。
import { useState, useCallback } from 'react';
function useRovingTabindex(itemCount: number, options?: { loop?: boolean; orientation?: 'horizontal' | 'vertical' }) {
const [focusedIndex, setFocusedIndex] = useState(0);
const { loop = true, orientation = 'horizontal' } = options ?? {};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
let nextIndex = focusedIndex;
switch (e.key) {
case nextKey:
e.preventDefault();
nextIndex = focusedIndex + 1;
if (nextIndex >= itemCount) {
nextIndex = loop ? 0 : itemCount - 1;
}
break;
case prevKey:
e.preventDefault();
nextIndex = focusedIndex - 1;
if (nextIndex < 0) {
nextIndex = loop ? itemCount - 1 : 0;
}
break;
case 'Home':
e.preventDefault();
nextIndex = 0;
break;
case 'End':
e.preventDefault();
nextIndex = itemCount - 1;
break;
}
setFocusedIndex(nextIndex);
},
[focusedIndex, itemCount, loop, orientation],
);
const getItemProps = useCallback(
(index: number) => ({
tabIndex: index === focusedIndex ? 0 : -1,
onKeyDown: handleKeyDown,
onFocus: () => setFocusedIndex(index),
}),
[focusedIndex, handleKeyDown],
);
return { focusedIndex, getItemProps };
}
Focus Trap(焦点陷阱)
Dialog 和 Drawer 等模态组件必须实现焦点陷阱,防止 Tab 键跳出模态区域。
import { useEffect, useRef } from 'react';
function useFocusTrap<T extends HTMLElement>(active: boolean) {
const containerRef = useRef<T>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!active || !containerRef.current) return;
// 保存之前的焦点位置
previousFocusRef.current = document.activeElement as HTMLElement;
const container = containerRef.current;
const focusableSelector =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(focusableSelector));
// 初始聚焦
const firstFocusable = getFocusableElements()[0];
firstFocusable?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const focusable = getFocusableElements();
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
// 关闭时恢复之前的焦点
previousFocusRef.current?.focus();
};
}, [active]);
return containerRef;
}
八、Tree Shaking 与按需加载
ESM 导出策略
Tree Shaking 的前提是使用 ESM(ES Modules),打包工具通过静态分析移除未使用的导出。
{
"name": "@mylib/ui",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"sideEffects": [
"*.css",
"*.scss"
],
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/types/index.d.ts"
},
"./button": {
"import": "./dist/esm/components/button/index.js",
"types": "./dist/types/components/button/index.d.ts"
},
"./select": {
"import": "./dist/esm/components/select/index.js",
"types": "./dist/types/components/select/index.d.ts"
},
"./styles.css": "./dist/styles.css"
}
}
Barrel Exports vs Direct Imports
| 方式 | 用法 | Tree Shaking | 打包体积 |
|---|---|---|---|
| Barrel Exports | import { Button } from '@mylib/ui' | 依赖打包工具分析 | 可能包含冗余 |
| Direct Imports | import { Button } from '@mylib/ui/button' | 天然按需 | 最优 |
Barrel file(index.ts 中统一 export *)可能导致 Tree Shaking 失效,因为打包工具无法确定 re-export 的模块是否有副作用。推荐:
sideEffects: false声明所有 JS 模块无副作用- 每个组件提供独立的入口(
exports字段) - 避免在 barrel file 中执行任何副作用代码
CSS 按需加载方案
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| CSS-in-JS | 样式随组件引入 | 天然按需 | 运行时开销 |
| 组件级 CSS 文件 | 每个组件一个 CSS | 无运行时开销 | 需手动引入或插件 |
| 全局 CSS + PurgeCSS | 构建时删除未使用 | 简单 | 需配置正确 |
| CSS Modules | 作用域隔离 + Tree Shaking | 按需 + 隔离 | 无动态能力 |
九、文档与 Playground
方案对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Storybook | 生态成熟、插件丰富、交互测试 | 独立工具,与文档站分离 | 组件开发和测试 |
| Docusaurus + live code | 文档与演示统一 | 交互能力弱于 Storybook | 对外文档站 |
| Ladle | 轻量 Storybook 替代 | 生态小 | 追求轻量的团队 |
| Histoire | Vue/Svelte 优先 | React 支持一般 | Vue 组件库 |
API 文档自动生成
从 TypeScript 类型自动提取 Props 文档,避免手动维护:
import { Project } from 'ts-morph';
import { writeFileSync } from 'fs';
const project = new Project({ tsConfigFilePath: './tsconfig.json' });
interface PropDoc {
name: string;
type: string;
required: boolean;
defaultValue?: string;
description?: string;
}
function extractProps(filePath: string, componentName: string): PropDoc[] {
const sourceFile = project.getSourceFile(filePath);
if (!sourceFile) return [];
const iface = sourceFile.getInterface(`${componentName}Props`);
if (!iface) return [];
return iface.getProperties().map((prop) => ({
name: prop.getName(),
type: prop.getType().getText(),
required: !prop.hasQuestionToken(),
description: prop
.getJsDocs()
.map((doc) => doc.getDescription().trim())
.join(''),
defaultValue: undefined, // 需从组件函数参数的解构默认值中提取
}));
}
// 生成 Markdown 表格
function propsToMarkdown(props: PropDoc[]): string {
const header = '| 属性 | 类型 | 必填 | 默认值 | 说明 |\n|------|------|:----:|--------|------|\n';
const rows = props
.map(
(p) =>
`| \`${p.name}\` | \`${p.type}\` | ${p.required ? '是' : '否'} | ${p.defaultValue ?? '-'} | ${p.description ?? '-'} |`,
)
.join('\n');
return header + rows;
}
十、完整案例:从零设计一个 Select 组件
综合以上所有设计原则,完整走一遍 Select 组件的设计流程。
Step 1:需求分析
Step 2:API 设计
interface SelectOption<T = string> {
label: React.ReactNode;
value: T;
disabled?: boolean;
group?: string;
}
interface SelectProps<T = string> {
// ---- 核心 ----
options?: SelectOption<T>[];
// ---- 受控/非受控 ----
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
// ---- 外观 ----
placeholder?: string;
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
className?: string;
classNames?: {
root?: string;
trigger?: string;
dropdown?: string;
option?: string;
};
// ---- 搜索 ----
searchable?: boolean;
onSearch?: (keyword: string) => void;
filterOption?: (input: string, option: SelectOption<T>) => boolean;
// ---- 自定义渲染 ----
renderOption?: (option: SelectOption<T>, state: { selected: boolean; highlighted: boolean }) => React.ReactNode;
renderValue?: (option: SelectOption<T>) => React.ReactNode;
// ---- 组合模式 ----
children?: React.ReactNode;
}
Step 3:Headless Hook
interface UseSelectReturn<T> {
// 状态
isOpen: boolean;
selectedOption: SelectOption<T> | undefined;
highlightedIndex: number;
filteredOptions: SelectOption<T>[];
// Props getters
getTriggerProps: () => Record<string, unknown>;
getListProps: () => Record<string, unknown>;
getOptionProps: (index: number) => Record<string, unknown>;
getSearchInputProps: () => Record<string, unknown>;
// 操作
open: () => void;
close: () => void;
select: (value: T) => void;
}
function useSelect<T = string>(options: {
items: SelectOption<T>[];
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
searchable?: boolean;
filterOption?: (input: string, option: SelectOption<T>) => boolean;
}): UseSelectReturn<T> {
// ... 状态管理、键盘导航、ARIA 属性
// 复用前面 useCombobox 的思路
// 关键:getTriggerProps / getListProps / getOptionProps
// 返回要 spread 的 props,包含所有 ARIA 和事件
}
Step 4:带样式的组件层
import { useSelect } from '../../headless/useSelect';
function Select<T = string>({
options = [],
value,
defaultValue,
onChange,
placeholder = '请选择',
size = 'md',
disabled,
loading,
searchable,
filterOption,
renderOption,
renderValue,
className,
classNames,
children,
}: SelectProps<T>) {
const {
isOpen,
selectedOption,
highlightedIndex,
filteredOptions,
getTriggerProps,
getListProps,
getOptionProps,
getSearchInputProps,
} = useSelect({
items: options,
value,
defaultValue,
onChange,
searchable,
filterOption,
});
// 如果使用组合模式(children),则渲染 Compound Components
if (children) {
return (
<SelectContext.Provider value={{ /* ... */ }}>
<div className={`select select-${size} ${className ?? ''} ${classNames?.root ?? ''}`}>
{children}
</div>
</SelectContext.Provider>
);
}
// 配置模式渲染
return (
<div className={`select select-${size} ${className ?? ''} ${classNames?.root ?? ''}`}>
<button
{...getTriggerProps()}
className={`select-trigger ${classNames?.trigger ?? ''}`}
disabled={disabled}
>
{selectedOption
? (renderValue?.(selectedOption) ?? selectedOption.label)
: <span className="select-placeholder">{placeholder}</span>
}
{loading ? <Spinner size="sm" /> : <ChevronIcon />}
</button>
{isOpen && (
<div className={`select-dropdown ${classNames?.dropdown ?? ''}`}>
{searchable && (
<input {...getSearchInputProps()} className="select-search" />
)}
<ul {...getListProps()}>
{filteredOptions.map((opt, i) => (
<li
key={String(opt.value)}
{...getOptionProps(i)}
className={`select-option ${classNames?.option ?? ''}`}
>
{renderOption
? renderOption(opt, {
selected: opt.value === selectedOption?.value,
highlighted: i === highlightedIndex,
})
: opt.label
}
</li>
))}
</ul>
</div>
)}
</div>
);
}
Step 5:可访问性测试清单
| 检查项 | ARIA / 行为 | 状态 |
|---|---|---|
Trigger 有 role="combobox" | aria-expanded、aria-haspopup="listbox" | 必须 |
列表有 role="listbox" | 包含 role="option" 子项 | 必须 |
选中项有 aria-selected="true" | 其余为 false | 必须 |
禁用项有 aria-disabled="true" | 键盘跳过禁用项 | 必须 |
| Arrow Up/Down 导航选项 | 到达末尾循环 | 必须 |
| Enter 选中当前高亮项 | 选中后关闭下拉 | 必须 |
| Escape 关闭下拉 | 焦点回到 Trigger | 必须 |
| Tab 关闭下拉并移动焦点 | 焦点移到下一个元素 | 必须 |
搜索时 aria-activedescendant | 指向当前高亮项 id | 搜索模式必须 |
常见面试问题
Q1: 什么是 Headless UI?和传统组件库有什么区别?
答案:
Headless UI 是只提供交互逻辑、状态管理和可访问性,不包含任何视觉样式的组件库。通常以 Hook 或 renderless 组件的形式对外暴露。
| 维度 | 传统组件库 | Headless 组件库 |
|---|---|---|
| 代表 | Ant Design、Element Plus、MUI | Radix UI、React Aria、Headless UI |
| 包含样式 | 是,内置完整 UI | 否,零样式 |
| 定制成本 | 高(覆盖样式 → 冲突) | 低(从零写样式) |
| 开箱即用 | 是 | 否 |
| 包体积 | 大(包含样式+逻辑) | 小(仅逻辑) |
| 设计系统适配 | 需要匹配库的视觉 | 天然适配任何设计 |
| 适用场景 | 中后台、快速交付 | C 端、独立设计系统 |
面试推荐回答:shadcn/ui 的流行代表了一种折中方案——它基于 Radix UI(Headless),预写了一层 Tailwind 样式,以"复制粘贴到你的项目"而非 npm 安装的方式分发。用户既获得了 Headless 的灵活性,又不用从零写 UI。
Q2: Compound Components 模式如何实现?解决什么问题?
答案:
Compound Components 通过 Context 在父子组件间隐式共享状态,让用户以声明式的方式自由组合子组件,而非传递一个庞大的配置对象。
解决的问题:
- 灵活性 — 用户可自由排列子组件、条件渲染、插入自定义元素
- 可读性 — JSX 层级清晰,一眼能看出结构
- TypeScript 友好 — 每个子组件有独立的 Props 类型
// 1. 创建 Context
const TabsContext = createContext<TabsContextValue | null>(null);
// 2. 父组件提供状态
function Tabs({ children, defaultValue, onChange }: TabsProps) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive: (v) => { setActive(v); onChange?.(v); } }}>
<div role="tablist">{children}</div>
</TabsContext.Provider>
);
}
// 3. 子组件消费状态
function TabPanel({ value, label, children }: TabPanelProps) {
const ctx = useContext(TabsContext)!;
return (
<>
<button role="tab" aria-selected={ctx.active === value} onClick={() => ctx.setActive(value)}>
{label}
</button>
{ctx.active === value && <div role="tabpanel">{children}</div>}
</>
);
}
// 4. 挂载到命名空间
Tabs.Panel = TabPanel;
// 使用
<Tabs defaultValue="1">
<Tabs.Panel value="1" label="概览">概览内容</Tabs.Panel>
<Tabs.Panel value="2" label="详情">详情内容</Tabs.Panel>
</Tabs>
Q3: 受控和非受控组件如何统一?useControllableState 的实现
答案:
核心是一个通用 Hook,根据是否传入 value 来判断模式:
function useControllableState<T>(
controlledValue: T | undefined, // 受控值
defaultValue: T, // 非受控默认值
onChange?: (value: T) => void, // 变更回调
): [T, (next: T) => void] {
const isControlled = controlledValue !== undefined;
const [internal, setInternal] = useState(defaultValue);
const value = isControlled ? controlledValue : internal;
const setValue = useCallback((next: T) => {
if (!isControlled) setInternal(next); // 非受控才更新内部
onChange?.(next); // 两种模式都触发回调
}, [isControlled, onChange]);
return [value, setValue];
}
约定:value 存在 → 受控,defaultValue → 非受控。两者不应在生命周期内切换,开发模式下应给出警告。
Q4: 组件库的 CSS 方案如何选型?
答案:
选型取决于三个关键因素:SSR 需求、主题定制深度、团队技术栈。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| SSR + 多主题 | CSS Variables + Token | 零运行时、原生支持 |
| Tailwind 项目 | Tailwind + CVA | 原子化、与 Tailwind 生态一致 |
| 需要类型安全 | vanilla-extract | 编译时生成、类型安全 |
| 传统 SPA 项目 | CSS Modules | 简单、作用域隔离 |
| 不推荐新项目使用 | styled-components / Emotion | 运行时开销、SSR 兼容性差 |
核心原则:新项目优先选择零运行时方案。CSS Variables 是主题系统的首选实现,它天然支持运行时切换,且与任何样式方案兼容。
Q5: Design Token 分层是什么?如何实现主题切换?
答案:
Design Token 分为三层:
- Global Token — 原始设计值,如
--color-blue-500: #3b82f6,与品牌无关 - Alias Token — 语义化别名,如
--color-primary: var(--color-blue-500),主题切换时变化 - Component Token — 组件级别,如
--btn-bg: var(--color-primary)
主题切换的本质是替换 Alias Token 层的值:
// 方式一:data 属性 + CSS
document.documentElement.setAttribute('data-theme', 'dark');
// CSS: [data-theme="dark"] { --color-primary: #60a5fa; --color-bg-page: #0f172a; }
// 方式二:JavaScript 动态设置
const root = document.documentElement;
for (const [key, value] of Object.entries(darkTokens)) {
root.style.setProperty(key, value);
}
// 方式三:React Context + useEffect
function ThemeProvider({ children, mode }) {
useEffect(() => {
const tokens = mode === 'dark' ? darkAliasTokens : lightAliasTokens;
Object.entries(tokens).forEach(([k, v]) => {
document.documentElement.style.setProperty(k, v);
});
}, [mode]);
return <ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>;
}
Q6: 如何实现组件库的 Tree Shaking?
答案:
Tree Shaking 依赖三个条件:
- ESM 格式输出 — 必须输出 ES Modules,打包工具才能做静态分析
sideEffects声明 — 在package.json中标记哪些文件有副作用- 避免 barrel file 副作用 —
index.ts中不执行任何运行时代码
{
"module": "dist/esm/index.js",
"sideEffects": ["*.css"],
"exports": {
".": { "import": "./dist/esm/index.js" },
"./button": { "import": "./dist/esm/button/index.js" }
}
}
最佳实践:同时提供 barrel export(方便使用方)和 direct import(直接路径),配合 exports 字段让打包工具自动选择最优路径。详见构建工具 - Tree Shaking。
Q7: as/asChild 多态组件如何实现?
答案:
as prop(Chakra UI、MUI 方案):
通过泛型让 TypeScript 推断出目标元素的 props 类型:
// 核心类型
type PolymorphicProps<E extends React.ElementType, P = {}> = P &
Omit<React.ComponentPropsWithoutRef<E>, keyof P | 'as'> & { as?: E };
// 使用
<Button as="a" href="/docs">链接按钮</Button> // href 被正确推断
<Button as={Link} to="/home">路由链接</Button> // to 被正确推断
asChild prop(Radix UI 方案):
通过 Slot 组件将 props 合并到唯一子元素上,无需泛型:
// 核心实现:Slot 将 parent 的 props 合并到 child
function Slot({ children, ...props }) {
return React.cloneElement(children, mergeProps(props, children.props));
}
// 使用
<Button asChild><a href="/docs">链接按钮</a></Button>
// 渲染结果:<a href="/docs" class="btn btn-primary">链接按钮</a>
| 对比 | as | asChild |
|---|---|---|
| 类型安全 | 需要复杂泛型 | 无泛型,子元素自身类型 |
| 灵活度 | 高 | 更高(子元素可以是任何组件) |
| 实现复杂度 | TypeScript 类型体操 | React.cloneElement + props 合并 |
| 限制 | 无 | 只能有一个子元素 |
Q8: 如何设计组件库的可访问性方案?
答案:
组件库的可访问性需要系统性设计,不能事后补救。核心步骤:
-
参照 WAI-ARIA 模式 — 每个交互组件对照 WAI-ARIA Authoring Practices 实现
-
语义化优先 — 用原生 HTML 元素而非 div + onClick
// 正确
<button onClick={handleClick}>提交</button>
// 错误
<div onClick={handleClick} role="button" tabIndex={0}>提交</div> -
ARIA 属性完整 —
role、aria-expanded、aria-selected、aria-labelledby等 -
键盘导航 — Tab 聚焦、Arrow 导航(Roving Tabindex)、Enter/Space 激活、Escape 关闭
-
焦点管理 — Modal 焦点陷阱、关闭后恢复焦点、
aria-activedescendant -
自动化测试 — axe-core + Storybook a11y 插件
-
屏幕阅读器测试 — VoiceOver (Mac) / NVDA (Windows) 实际验证
Q9: Ant Design vs shadcn/ui vs Radix UI 的设计理念对比
答案:
| 维度 | Ant Design | shadcn/ui | Radix UI |
|---|---|---|---|
| 定位 | 全功能组件库 | 可复制的 UI 代码集 | Headless 原语库 |
| 分发方式 | npm 安装 | CLI 复制到项目 | npm 安装 |
| 样式 | CSS-in-JS + Token | Tailwind CSS | 无样式 |
| 定制方式 | Token 覆盖 | 直接修改源码 | 完全自写 |
| 版本锁定 | 有(npm 版本) | 无(代码在你项目中) | 有(npm 版本) |
| 组件数量 | 60+ | 40+ | 30+ |
| a11y | 内置 | 基于 Radix,完善 | 极其完善 |
| 学习成本 | 低 | 中 | 高 |
| 适用场景 | 中后台快速开发 | 有设计系统的项目 | 需要极致定制 |
| 包体积 | 较大 | 按需复制 | 按组件安装,小 |
选择建议:
- 中后台标准化 → Ant Design
- 有设计系统 + Tailwind 技术栈 → shadcn/ui
- 纯 Headless 需求 → Radix UI
- Vue 项目 → Element Plus / Naive UI
Q10: 从零设计一个 Select 组件,你会怎么做?
答案:
面试中建议按以下步骤结构化回答:
第 1 步:需求分析 — 明确核心功能(单选/多选、搜索、异步、分组、键盘导航、a11y)
第 2 步:API 设计 — 遵循受控/非受控双模式,Props 分层设计
<Select value={v} onChange={setV} searchable placeholder="选择">
<Select.Option value="react">React</Select.Option>
<Select.Option value="vue">Vue</Select.Option>
</Select>
第 3 步:分层实现
- Headless 层(
useSelectHook)— 状态管理、键盘导航、ARIA - Props Getter 模式 —
getTriggerProps()、getListProps()、getOptionProps() - UI 层 — 消费 Hook 返回值,渲染 DOM + 样式
第 4 步:可访问性 — role="listbox" + role="option" + aria-selected + aria-expanded + 键盘导航
第 5 步:样式方案 — CSS Variables 支持主题定制,classNames 多 slot 支持样式覆盖
第 6 步:测试 — 单元测试(状态切换、回调)+ a11y 测试(axe-core)+ 键盘导航测试
Q11: 组件库的版本管理和 Breaking Change 策略
答案:
-
遵循 SemVer:
Major.Minor.Patch- Major — Breaking Change(移除 API、改变行为)
- Minor — 新增功能,向下兼容
- Patch — Bug 修复
-
Breaking Change 的处理策略:
// Step 1: 标记废弃(Minor 版本)
interface ButtonProps {
/** @deprecated 请使用 variant 代替 */
type?: 'primary' | 'default'; // 旧 API
variant?: 'primary' | 'default'; // 新 API
}
function Button({ type, variant, ...rest }: ButtonProps) {
if (process.env.NODE_ENV !== 'production' && type !== undefined) {
console.warn('[MyUI] Button: "type" prop is deprecated, use "variant" instead.');
}
const resolvedVariant = variant ?? type ?? 'default';
// ...
}
// Step 2: 提供 codemod 自动迁移
// npx @mylib/codemod button-type-to-variant
// Step 3: 下个 Major 版本移除旧 API
- 版本发布工具:推荐 Changesets,支持 Monorepo 多包版本管理。详见组件库建设中的版本管理章节。
Q12: 如何设计组件库的国际化方案?
答案:
组件库的国际化(i18n)需要考虑两个层面:
层面一:组件内置文本
const zhCN = {
Select: {
placeholder: '请选择',
noData: '暂无数据',
loading: '加载中...',
},
Pagination: {
total: (total: number) => `共 ${total} 条`,
prev: '上一页',
next: '下一页',
},
DatePicker: {
placeholder: '请选择日期',
today: '今天',
// ...
},
};
export type Locale = typeof zhCN;
export default zhCN;
const enUS: Locale = {
Select: {
placeholder: 'Please select',
noData: 'No data',
loading: 'Loading...',
},
// ...
};
层面二:通过 Provider 注入
interface ConfigProviderProps {
locale?: Locale;
children: React.ReactNode;
}
const ConfigContext = createContext<{ locale: Locale }>({ locale: zhCN });
export const useConfig = () => useContext(ConfigContext);
function ConfigProvider({ locale = zhCN, children }: ConfigProviderProps) {
return (
<ConfigContext.Provider value={{ locale }}>
{children}
</ConfigContext.Provider>
);
}
// 组件内消费
function Select(props: SelectProps) {
const { locale } = useConfig();
return <div>{/* ... */}<span>{locale.Select.placeholder}</span></div>;
}
设计原则:
- 所有用户可见的文案都不应硬编码,通过 locale 对象管理
- 提供
ConfigProvider全局注入 + 组件级localeprop 覆盖 - 支持 RTL(从右到左)布局(阿拉伯语、希伯来语)
- 日期、数字格式化使用 Intl API
相关链接
- 组件库建设 — 工程化角度:Storybook、打包、发布、测试
- React 组件通用设计 — 单组件设计原则与模式
- Tree Shaking — ESM 静态分析与 Tree Shaking 原理
- TypeScript 与 React — 组件类型、泛型组件
- CSS 自定义属性 — CSS Variables 与 Design Token
- Radix UI — Headless 组件库
- React Aria — Adobe 的可访问性 Hooks
- shadcn/ui — 基于 Radix + Tailwind 的 UI 集
- WAI-ARIA Authoring Practices — ARIA 组件模式规范
- vanilla-extract — 零运行时 CSS-in-JS
- Panda CSS — 构建时 CSS-in-JS
- Changesets — Monorepo 版本管理工具