React 组件化开发
约 2806 字大约 9 分钟
reactcomponentdesign-system
2026年04月08日
组件化不是把页面切碎,而是把稳定的交互模式、视觉结构和状态边界抽成可复用单元。
组件设计的目标
- 复用:同一组件可在多处场景使用,减少重复代码
- 可组合:小组件能拼装成大功能,像乐高而非瑞士军刀
- 易维护:内部修改不影响外部使用者,关注点清晰
- 可测试:行为可预测、输入输出明确,便于单元和集成测试
- 可扩展:通过插槽、props 或组合方式增强能力,而非不断追加 if-else
做组件时先问三个问题:
- 这个组件解决什么问题?
- 哪些状态归它自己管?
- 它暴露给外部的 API 是否稳定?
常见组件分层
基础组件(UI Components)
纯展示和交互的最小单元,不包含业务逻辑。
type ButtonProps = {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
children: React.ReactNode;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
};
export function Button({
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
children,
onClick,
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
onClick={onClick}
>
{loading ? <Spinner /> : children}
</button>
);
}容器组件(Container Components)
负责数据获取、状态协调,将数据和控制逻辑注入展示层。
export function UserListContainer() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
return <UserList users={users} loading={loading} />;
}业务组件(Business Components)
面向具体业务场景,复用基础和容器层能力。例如"用户选择器"、"订单状态面板"。
组件库层(Design System)
Button、Modal、Form、Table、Tree 等系统性基础能力,通常由设计语言团队维护。
分层的关键在于:基础组件不关心业务,业务组件不复制基础逻辑。
Props API 设计
好的组件 API 要满足:
- 命名清晰:
isOpen而不是flag - 默认行为可预测:合理默认值,减少必填 props
- 扩展点位置明确:用
children、renderXxx而不是传一个巨大的 config 对象 - 不要求调用方传入过多内部实现细节
常用设计模式
// value / onChange —— 受控场景标准模式
type InputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
};
// defaultValue —— 非受控初始值
type UncontrolledInputProps = {
defaultValue?: string;
onChange?: (value: string) => void;
placeholder?: string;
};
// renderItem —— 列表项自定义插槽
type ListProps<T> = {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
};
// children as function —— 更灵活的插槽
type DataFetcherProps = {
url: string;
children: (data: any, loading: boolean) => React.ReactNode;
};避免反模式
// ❌ 不推荐:config 大对象,调用方难以理解
type BadTableProps = {
config: {
columns: { key: string; render: (row: any) => React.ReactNode }[];
rowKey: string;
expandable: boolean;
pagination: { pageSize: number; showTotal: boolean };
};
};
// ✅ 推荐:扁平化 props,逐项清晰
type GoodTableProps<T> = {
data: T[];
columns: ColumnDef<T>[];
rowKey?: keyof T;
expandable?: boolean;
pagination?: PaginationConfig;
};受控与非受控
做表单、上传、树形组件时,这个边界特别重要。
受控组件
状态由父组件管理,适合统一校验、联动、回显。
function ControlledSelect({
value,
onChange,
options,
}: {
value: string;
onChange: (val: string) => void;
options: { label: string; value: string }[];
}) {
return (
<select value={value} onChange={e => onChange(e.target.value)}>
{options.map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}非受控组件
内部维护状态,适合降低调用复杂度。
function UncontrolledInput({
defaultValue = '',
onChange,
}: {
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const [value, setValue] = useState(defaultValue);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setValue(next);
onChange?.(next);
};
return <input value={value} onChange={handleChange} />;
}双模式支持
复杂组件通常同时支持两种模式,以 value 是否传入作为判断依据。
function FlexibleInput({
value: controlledValue,
defaultValue = '',
onChange,
}: {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}) {
const isControlled = controlledValue !== undefined;
const [internalValue, setInternalValue] = useState(defaultValue);
const currentValue = isControlled ? controlledValue : internalValue;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
if (!isControlled) {
setInternalValue(next);
}
onChange?.(next);
};
return <input value={currentValue} onChange={handleChange} />;
}典型组件设计案例
知识架构里提到的表单、拖拽上传、树形组件、轮播图,本质上考察的是这些能力:
- 状态归属
- 事件设计
- 可访问性
- 可组合性
- 性能边界
表单组件
表单组件需要明确:
- 字段注册机制
- 校验时机(onChange / onBlur / onSubmit)
- 错误信息组织方式
- 嵌套字段结构
- 提交和重置生命周期
type FieldState = {
value: any;
error?: string;
touched: boolean;
};
function useForm<T extends Record<string, any>>(initialValues: T) {
const [fields, setFields] = useState<Record<keyof T, FieldState>>(
Object.fromEntries(
Object.entries(initialValues).map(([key, value]) => [
key,
{ value, error: undefined, touched: false },
])
) as any
);
const register = (name: keyof T, rules?: { validate?: (v: any) => string | undefined }) => ({
value: fields[name].value,
error: fields[name].error,
onChange: (val: any) => {
setFields(prev => ({
...prev,
[name]: {
value: val,
touched: true,
error: rules?.validate?.(val),
},
}));
},
onBlur: () => {
setFields(prev => ({ ...prev, [name]: { ...prev[name], touched: true } }));
},
});
const handleSubmit = (onSubmit: (values: T) => void) => (e: React.FormEvent) => {
e.preventDefault();
const values = Object.fromEntries(
Object.entries(fields).map(([k, f]) => [k, f.value])
) as T;
onSubmit(values);
};
return { fields, register, handleSubmit };
}拖拽上传组件
关键点:
- 拖拽区域的状态管理(dragover / dragleave / drop)
- 文件列表的内部状态与外部通知
- 上传进度的反馈
function Dropzone({
accept,
maxFiles = 1,
onFilesSelected,
}: {
accept?: string;
maxFiles?: number;
onFilesSelected: (files: File[]) => void;
}) {
const [isDragging, setIsDragging] = useState(false);
const [fileList, setFileList] = useState<File[]>([]);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
setFileList(files);
onFilesSelected(files);
};
return (
<div
role="button"
tabIndex={0}
aria-label="文件拖拽区域"
className={`dropzone ${isDragging ? 'active' : ''}`}
onDragOver={e => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
{fileList.length > 0 ? (
<ul>
{fileList.map(f => (
<li key={f.name}>{f.name}</li>
))}
</ul>
) : (
<p>拖拽文件到此处,或点击选择</p>
)}
</div>
);
}树形组件
关键点:
- 递归数据结构与组件递归渲染
- 懒加载(按需加载子节点)
- 展开/收起状态管理
- 虚拟滚动(节点极多时)
type TreeNodeData = {
key: string;
label: string;
children?: TreeNodeData[];
isLeaf?: boolean;
};
function TreeNode({
node,
onLoadChildren,
}: {
node: TreeNodeData;
onLoadChildren?: (key: string) => Promise<TreeNodeData[]>;
}) {
const [expanded, setExpanded] = useState(false);
const [children, setChildren] = useState<TreeNodeData[]>(node.children ?? []);
const [loading, setLoading] = useState(false);
const handleToggle = async () => {
if (!onLoadChildren || node.isLeaf) return;
if (!expanded && children.length === 0) {
setLoading(true);
try {
const loaded = await onLoadChildren(node.key);
setChildren(loaded);
} finally {
setLoading(false);
}
}
setExpanded(prev => !prev);
};
return (
<div className="tree-node">
<button onClick={handleToggle} aria-expanded={expanded}>
{loading ? '加载中...' : expanded ? '▼' : '▶'} {node.label}
</button>
{expanded && children.length > 0 && (
<div className="tree-node-children">
{children.map(child => (
<TreeNode key={child.key} node={child} onLoadChildren={onLoadChildren} />
))}
</div>
)}
</div>
);
}轮播图组件
关键点:
- 自动播放与手动切换的冲突处理
- 过渡动画与性能
- 手势支持(移动端 swipe)
- 可访问性(键盘和屏幕阅读器)
function Carousel({
slides,
interval = 5000,
autoplay = true,
}: {
slides: React.ReactNode[];
interval?: number;
autoplay?: boolean;
}) {
const [current, setCurrent] = useState(0);
useEffect(() => {
if (!autoplay) return;
const timer = setInterval(() => {
setCurrent(prev => (prev + 1) % slides.length);
}, interval);
return () => clearInterval(timer);
}, [autoplay, interval, slides.length]);
const goTo = (index: number) => setCurrent(index);
const prev = () => setCurrent(prev => (prev - 1 + slides.length) % slides.length);
const next = () => setCurrent(prev => (prev + 1) % slides.length);
return (
<div className="carousel" role="region" aria-label="轮播图">
<button onClick={prev} aria-label="上一张">‹</button>
<div className="carousel-track">
{slides.map((slide, i) => (
<div
key={i}
className={`slide ${i === current ? 'active' : ''}`}
aria-hidden={i !== current}
>
{slide}
</div>
))}
</div>
<button onClick={next} aria-label="下一张">›</button>
<div className="carousel-dots">
{slides.map((_, i) => (
<button
key={i}
className={`dot ${i === current ? 'active' : ''}`}
onClick={() => goTo(i)}
aria-label={`第 ${i + 1} 张`}
/>
))}
</div>
</div>
);
}组合模式
React 里常见的组件复用模式:
1. Composition(组合)
通过 children 和 props 传递 React 节点来组合组件,最简单也最推荐。
function Card({ header, children, footer }: {
header?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// 使用
<Card
header={<h2>标题</h2>}
footer={<Button>操作</Button>}
>
<p>内容区域</p>
</Card>2. Compound Components(复合组件)
多个组件配合使用,通过 Context 共享状态。
const SelectContext = createContext<{
value: string;
onChange: (val: string) => void;
}>({ value: '', onChange: () => {} });
function Select({ value, onChange, children }: {
value: string;
onChange: (val: string) => void;
children: React.ReactNode;
}) {
return (
<SelectContext.Provider value={{ value, onChange }}>
<div className="select">{children}</div>
</SelectContext.Provider>
);
}
function Option({ value, children }: { value: string; children: React.ReactNode }) {
const { value: selectedValue, onChange } = useContext(SelectContext);
const isSelected = value === selectedValue;
return (
<div
className={`option ${isSelected ? 'selected' : ''}`}
onClick={() => onChange(value)}
role="option"
aria-selected={isSelected}
>
{children}
</div>
);
}
// 使用
<Select value={selected} onChange={setSelected}>
<Option value="apple">Apple</Option>
<Option value="banana">Banana</Option>
</Select>3. Render Props
通过函数类型的 prop 传递数据或能力。
function MouseTracker({ render }: {
render: (pos: { x: number; y: number }) => React.ReactNode;
}) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return <>{render(pos)}</>;
}
// 使用
<MouseTracker render={({ x, y }) => <span>Mouse: {x}, {y}</span>} />4. Higher-Order Components(高阶组件)
接收组件返回新组件的模式,在现代 React 中使用频率下降。
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingComponent(
props: P & { loading?: boolean }
) {
if (props.loading) return <Spinner />;
return <WrappedComponent {...(props as P)} />;
};
}
// 使用
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading users={users} loading={loading} />;5. Headless Components(无头组件)
只提供状态和行为,不渲染任何 UI,调用方完全自定义外观。
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
return {
on,
toggle: () => setOn(prev => !prev),
setOn,
};
}
// 使用
function MySwitch() {
const { on, toggle } = useToggle();
return (
<button
role="switch"
aria-checked={on}
onClick={toggle}
className={on ? 'switch-on' : 'switch-off'}
>
{on ? '开' : '关'}
</button>
);
}模式优先级
现代实践中,优先级通常是:
- 组合优先:
children、命名插槽最简单 - Headless + 自定义渲染:需要完全控制外观时
- Compound Components:多个组件紧密协作时
- HOC / Render Props:历史兼容和特殊场景手段
可访问性与交互一致性
组件库质量差异通常不是在样式,而是在细节:
键盘可操作
所有交互元素必须可通过 Tab 到达,通过 Enter 或 Space 触发。
// 自定义可聚焦元素添加 tabIndex 和键盘事件
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}}
>
点击我
</div>焦点管理
弹出层打开时将焦点移入,关闭时返还焦点到触发元素。
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const triggerRef = useRef<HTMLButtonElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
triggerRef.current = document.activeElement as HTMLButtonElement;
modalRef.current?.focus();
} else {
triggerRef.current?.focus();
}
}, [isOpen]);
// ...
}ARIA 属性
// 列表框
<ul role="listbox" aria-label="选择城市">
<li role="option" aria-selected={isSelected}>北京</li>
</ul>
// 标签页
<div role="tablist">
<button role="tab" aria-selected={active === 0} aria-controls="panel-0">标签 1</button>
</div>
<div role="tabpanel" id="panel-0" aria-labelledby="tab-0">内容</div>
// 状态反馈
<div aria-live="polite" aria-atomic="true">
{loading && <span>加载中...</span>}
{error && <span role="alert">{error}</span>}
</div>空态、禁用态、加载态、错误态
一个成熟的组件应覆盖所有状态:
function DataTable({ data, loading, error, emptyLabel = '暂无数据' }: DataTableProps) {
if (loading) return <Skeleton rows={5} />;
if (error) return <Alert type="error" message={error} />;
if (data.length === 0) return <Empty label={emptyLabel} />;
return <table>/* ... */</table>;
}文档与测试
一个可长期维护的组件,至少要有:
使用示例
## Basic
<code src="./demos/basic.tsx" />
## Controlled
<code src="./demos/controlled.tsx" />Props 说明
/**
* 按钮组件
*/
export interface ButtonProps {
/** 按钮类型 */
variant?: 'primary' | 'secondary' | 'danger';
/** 尺寸 */
size?: 'sm' | 'md' | 'lg';
/** 是否禁用 */
disabled?: boolean;
/** 加载状态 */
loading?: boolean;
/** 点击回调 */
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
/** 子元素 */
children: React.ReactNode;
}边界行为说明
- 传入空 children 时的表现
loading和disabled同时为 true 时优先禁用onClick在 disabled 状态下不会触发
基础交互测试
import { render, screen, fireEvent } from '@testing-library/react';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', () => {
const handleClick = vi.fn();
render(<Button disabled onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).not.toHaveBeenCalled();
});
it('shows loading state', () => {
render(<Button loading>Click me</Button>);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
});对团队来说,组件文档和组件源码同样重要。
实践建议
- 先为业务抽局部组件,再沉淀到公共层
- 一个组件只负责一类稳定抽象
- API 设计优先于内部实现
- 把可访问性当成默认要求,不是上线前补丁
- 对复杂组件先写状态图,再写代码
- 用 TypeScript 约束 props 边界,减少运行时防御代码
- 不要过早优化——先让它工作、正确,再让它快
- 组件发布后保持向后兼容,破坏性变更用版本和文档明确说明
