Appearance
高频场景题汇总 - React面试实战题库
1. 组件设计题
1.1 实现可控和非可控组件
typescript
// 题目: 实现一个Input组件,同时支持可控和非可控模式
// 解答
function Input({ value: propValue, defaultValue, onChange, ...props }) {
// 判断是否是可控组件
const isControlled = propValue !== undefined;
const [internalValue, setInternalValue] = useState(defaultValue || '');
// 使用value或内部状态
const value = isControlled ? propValue : internalValue;
const handleChange = (e) => {
const newValue = e.target.value;
// 非可控模式更新内部状态
if (!isControlled) {
setInternalValue(newValue);
}
// 调用onChange回调
onChange?.(e);
};
return <input value={value} onChange={handleChange} {...props} />;
}
// 使用
// 可控模式
<Input value={value} onChange={(e) => setValue(e.target.value)} />
// 非可控模式
<Input defaultValue="hello" onChange={(e) => console.log(e.target.value)} />1.2 实现Modal弹窗
typescript
// 题目: 实现一个Modal组件,支持打开/关闭、遮罩层、ESC键关闭
function Modal({ isOpen, onClose, children }) {
// ESC键关闭
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
// 禁止body滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}
}, [isOpen]);
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
>
<button className="modal-close" onClick={onClose}>×</button>
{children}
</div>
</div>,
document.body
);
}
// CSS
const modalStyles = `
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 500px;
max-height: 80vh;
overflow: auto;
position: relative;
}
.modal-close {
position: absolute;
top: 10px;
right: 10px;
border: none;
background: none;
font-size: 24px;
cursor: pointer;
}
`;1.3 实现无限滚动
typescript
// 题目: 实现无限滚动加载
function InfiniteScroll() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef();
const loadMoreRef = useRef();
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const newItems = await fetchItems(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(p => p + 1);
}
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 1.0 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
observerRef.current = observer;
return () => observer.disconnect();
}, [loadMore]);
return (
<div>
{items.map(item => (
<ItemCard key={item.id} item={item} />
))}
{hasMore && (
<div ref={loadMoreRef} style={{ height: 20 }}>
{loading && <Loading />}
</div>
)}
{!hasMore && <div>没有更多了</div>}
</div>
);
}2. Hooks使用题
2.1 实现useDebounce
typescript
// 题目: 实现防抖Hook
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
function SearchInput() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 500);
useEffect(() => {
if (debouncedInput) {
searchAPI(debouncedInput);
}
}, [debouncedInput]);
return <input value={input} onChange={(e) => setInput(e.target.value)} />;
}2.2 实现usePrevious
typescript
// 题目: 获取上一次的值
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}2.3 实现useInterval
typescript
// 题目: 实现可靠的interval Hook
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const tick = () => {
savedCallback.current();
};
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
}
// 使用
function Timer() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
const [running, setRunning] = useState(true);
useInterval(
() => setCount(c => c + 1),
running ? delay : null
);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setRunning(!running)}>
{running ? 'Pause' : 'Start'}
</button>
</div>
);
}3. 性能优化题
3.1 优化列表渲染
typescript
// 题目: 优化包含1000个项的列表
// ❌ 问题版本
function List({ items }) {
const [filter, setFilter] = useState('');
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<ul>
{items
.filter(item => item.name.includes(filter))
.map(item => (
<li key={item.id} onClick={() => console.log(item)}>
{item.name}
</li>
))}
</ul>
</div>
);
}
// ✓ 优化版本
function OptimizedList({ items }) {
const [filter, setFilter] = useState('');
const filteredItems = useMemo(
() => items.filter(item => item.name.includes(filter)),
[items, filter]
);
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<FixedSizeList
height={600}
itemCount={filteredItems.length}
itemSize={35}
>
{({ index, style }) => (
<ListItem
style={style}
item={filteredItems[index]}
/>
)}
</FixedSizeList>
</div>
);
}
const ListItem = React.memo(({ style, item }) => {
const handleClick = useCallback(() => {
console.log(item);
}, [item]);
return (
<div style={style} onClick={handleClick}>
{item.name}
</div>
);
});4. 数据获取题
4.1 实现自动重试
typescript
// 题目: 实现带重试功能的数据获取Hook
function useFetchWithRetry(url, maxRetries = 3) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(\`HTTP \${response.status}\`);
}
const result = await response.json();
if (!cancelled) {
setData(result);
setLoading(false);
setRetryCount(0);
}
} catch (err) {
if (!cancelled) {
if (retryCount < maxRetries) {
console.log(\`Retrying... (\${retryCount + 1}/\${maxRetries})\`);
setTimeout(() => {
setRetryCount(c => c + 1);
}, 1000 * Math.pow(2, retryCount)); // 指数退避
} else {
setError(err);
setLoading(false);
}
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [url, retryCount, maxRetries]);
const retry = useCallback(() => {
setRetryCount(0);
}, []);
return { data, loading, error, retry };
}
// 使用
function DataComponent() {
const { data, loading, error, retry } = useFetchWithRetry('/api/data');
if (loading) return <Loading />;
if (error) return <Error error={error} onRetry={retry} />;
return <div>{JSON.stringify(data)}</div>;
}5. 状态管理题
5.1 实现全局状态Hook
typescript
// 题目: 实现简单的全局状态管理
function createGlobalState(initialState) {
let state = initialState;
const listeners = new Set();
const getState = () => state;
const setState = (newState) => {
state = typeof newState === 'function'
? newState(state)
: newState;
listeners.forEach(listener => listener(state));
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { getState, setState, subscribe };
}
function useGlobalState(globalState) {
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
return globalState.subscribe(() => forceUpdate());
}, [globalState]);
return [
globalState.getState(),
globalState.setState
];
}
// 使用
const counterState = createGlobalState(0);
function Counter1() {
const [count, setCount] = useGlobalState(counterState);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
function Counter2() {
const [count] = useGlobalState(counterState);
return <div>Count: {count}</div>;
}
// Counter1和Counter2共享状态6. 面试技巧
6.1 答题思路
typescript
const answeringStrategy = {
分析问题: [
'明确需求',
'识别关键点',
'考虑边界情况',
'思考性能影响'
],
设计方案: [
'选择合适的Hooks',
'考虑组件结构',
'规划数据流',
'设计API'
],
编码实现: [
'从简单开始',
'逐步完善',
'考虑类型安全',
'添加错误处理'
],
优化和测试: [
'识别性能瓶颈',
'添加必要优化',
'编写测试用例',
'考虑可维护性'
]
};6. 拖拽场景
6.1 拖拽排序
jsx
function DraggableList({ items, onReorder }) {
const [list, setList] = useState(items);
const [draggedIndex, setDraggedIndex] = useState(null);
const handleDragStart = (index) => setDraggedIndex(index);
const handleDragOver = (index) => {
if (draggedIndex === null || draggedIndex === index) return;
const newList = [...list];
const draggedItem = newList[draggedIndex];
newList.splice(draggedIndex, 1);
newList.splice(index, 0, draggedItem);
setList(newList);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
onReorder(list);
};
return (
<ul>
{list.map((item, index) => (
<li key={item.id} draggable onDragStart={() => handleDragStart(index)} onDragOver={(e) => { e.preventDefault(); handleDragOver(index); }} onDragEnd={handleDragEnd} style={{ opacity: draggedIndex === index ? 0.5 : 1 }}>
{item.text}
</li>
))}
</ul>
);
}7. 图片懒加载
jsx
function LazyImage({ src, alt }) {
const [imageSrc, setImageSrc] = useState(null);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setImageSrc(src);
observer.disconnect();
}
}, { rootMargin: '100px' });
if (imgRef.current) observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src]);
return <img ref={imgRef} src={imageSrc || 'placeholder.jpg'} alt={alt} />;
}8. 权限控制
jsx
const PERMISSIONS = {
VIEW_DASHBOARD: ['admin', 'user'],
EDIT_CONTENT: ['admin'],
DELETE: ['admin']
};
function usePermission() {
const { user } = useAuth();
const hasPermission = (perm) => PERMISSIONS[perm]?.includes(user?.role);
return { hasPermission };
}
function PermissionGuard({ permission, children, fallback = null }) {
const { hasPermission } = usePermission();
return hasPermission(permission) ? children : fallback;
}9. 文件上传
jsx
function FileUpload({ onUpload }) {
const [file, setFile] = useState(null);
const [progress, setProgress] = useState(0);
const [uploading, setUploading] = useState(false);
const handleChange = (e) => {
const selected = e.target.files[0];
if (selected) setFile(selected);
};
const handleUpload = async () => {
if (!file) return;
setUploading(true);
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
onUpload(JSON.parse(xhr.response));
setFile(null);
setProgress(0);
}
});
xhr.open('POST', '/api/upload');
xhr.send(formData);
setUploading(false);
};
return (
<div>
<input type="file" onChange={handleChange} disabled={uploading} />
{file && <p>{file.name}</p>}
{uploading && <progress value={progress} max="100">{progress}%</progress>}
<button onClick={handleUpload} disabled={!file || uploading}>上传</button>
</div>
);
}10. 实时搜索
jsx
function SearchInput({ onSearch }) {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const debouncedSearch = useMemo(() => debounce(async (q) => {
if (!q) { setResults([]); return; }
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
const data = await response.json();
setResults(data);
} finally {
setLoading(false);
}
}, 300), []);
useEffect(() => {
debouncedSearch(query);
return () => debouncedSearch.cancel();
}, [query, debouncedSearch]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="搜索..." />
{loading && <Spinner />}
<ul>{results.map(r => <li key={r.id} onClick={() => onSearch(r)}>{r.title}</li>)}</ul>
</div>
);
}11. 虚拟键盘
jsx
function NumericKeyboard({ onInput, maxLength = 6 }) {
const [value, setValue] = useState('');
const handleClick = (num) => {
if (value.length < maxLength) {
const newValue = value + num;
setValue(newValue);
onInput(newValue);
}
};
const handleDelete = () => {
const newValue = value.slice(0, -1);
setValue(newValue);
onInput(newValue);
};
return (
<div>
<div style={{ display: 'flex', gap: '5px' }}>
{Array(maxLength).fill('').map((_, i) => (
<div key={i} style={{ width: '30px', height: '40px', border: '1px solid #ccc', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{value[i] || ''}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
{[1,2,3,4,5,6,7,8,9].map(n => <button key={n} onClick={() => handleClick(n)}>{n}</button>)}
<button onClick={() => setValue('')}>清除</button>
<button onClick={() => handleClick(0)}>0</button>
<button onClick={handleDelete}>删除</button>
</div>
</div>
);
}12. 水印组件
jsx
function Watermark({ text, children }) {
const containerRef = useRef();
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const canvas = document.createElement('canvas');
canvas.width = 200;
canvas.height = 200;
const ctx = canvas.getContext('2d');
ctx.rotate(-20 * Math.PI / 180);
ctx.font = '16px Arial';
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.textAlign = 'center';
ctx.fillText(text, 100, 100);
container.style.backgroundImage = `url(${canvas.toDataURL()})`;
container.style.backgroundRepeat = 'repeat';
}, [text]);
return <div ref={containerRef} style={{ position: 'relative', width: '100%', height: '100%' }}>{children}</div>;
}13. 倒计时
jsx
function Countdown({ targetDate, onComplete }) {
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft());
function calculateTimeLeft() {
const diff = new Date(targetDate) - new Date();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / 1000 / 60) % 60),
seconds: Math.floor((diff / 1000) % 60)
};
}
useEffect(() => {
const timer = setInterval(() => {
const newTime = calculateTimeLeft();
setTimeLeft(newTime);
if (Object.values(newTime).every(v => v === 0)) {
clearInterval(timer);
onComplete?.();
}
}, 1000);
return () => clearInterval(timer);
}, [targetDate]);
return <div>{timeLeft.days}天 {timeLeft.hours}时 {timeLeft.minutes}分 {timeLeft.seconds}秒</div>;
}14. 二维码生成
jsx
import QRCode from 'qrcode';
function QRCodeGenerator() {
const [text, setText] = useState('');
const [qrCode, setQRCode] = useState('');
const generateQR = async () => {
try {
const url = await QRCode.toDataURL(text);
setQRCode(url);
} catch (err) {
console.error(err);
}
};
return (
<div>
<input value={text} onChange={(e) => setText(e.target.value)} placeholder="输入文本" />
<button onClick={generateQR}>生成二维码</button>
{qrCode && <img src={qrCode} alt="QR Code" />}
</div>
);
}15. 打印功能
jsx
function PrintComponent({ content }) {
const handlePrint = () => {
const printWindow = window.open('', '', 'height=600,width=800');
printWindow.document.write('<html><head><title>打印</title>');
printWindow.document.write('<style>body{ font-family: Arial; }</style>');
printWindow.document.write('</head><body>');
printWindow.document.write(content);
printWindow.document.write('</body></html>');
printWindow.document.close();
printWindow.print();
};
return <button onClick={handlePrint}>打印</button>;
}16. 答题技巧总结
16.1 解题步骤
typescript
const steps = {
第一步: '理解需求 - 明确功能和技术要求',
第二步: '分析难点 - 识别性能瓶颈和边界情况',
第三步: '设计方案 - 选择技术方案',
第四步: '编码实现 - 核心代码',
第五步: '优化改进 - 性能和体验',
第六步: '测试验证 - 边界和异常'
};16.2 常见陷阱
typescript
const traps = {
内存泄漏: '忘记清理定时器、监听器、订阅',
性能: '不必要重渲染、大列表不优化',
边界: '空数据、网络错误、快速操作',
可访问性: '键盘、屏幕阅读器、焦点',
安全: 'XSS、CSRF、输入验证'
};17. 表格排序与筛选
jsx
function DataTable({ data: initialData, columns }) {
const [data, setData] = useState(initialData);
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
const [filters, setFilters] = useState({});
const handleSort = (key) => {
let direction = 'asc';
if (sortConfig.key === key && sortConfig.direction === 'asc') {
direction = 'desc';
}
const sorted = [...data].sort((a, b) => {
if (a[key] < b[key]) return direction === 'asc' ? -1 : 1;
if (a[key] > b[key]) return direction === 'asc' ? 1 : -1;
return 0;
});
setData(sorted);
setSortConfig({ key, direction });
};
const handleFilter = (key, value) => {
setFilters(prev => ({ ...prev, [key]: value }));
};
const filteredData = useMemo(() => {
return data.filter(row => {
return Object.keys(filters).every(key => {
if (!filters[key]) return true;
return String(row[key]).toLowerCase().includes(filters[key].toLowerCase());
});
});
}, [data, filters]);
return (
<div>
<div className="filters">
{columns.map(col => (
<input
key={col.key}
placeholder={`Filter ${col.label}`}
value={filters[col.key] || ''}
onChange={(e) => handleFilter(col.key, e.target.value)}
/>
))}
</div>
<table>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} onClick={() => handleSort(col.key)}>
{col.label}
{sortConfig.key === col.key && (
<span>{sortConfig.direction === 'asc' ? ' ↑' : ' ↓'}</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map(row => (
<tr key={row.id}>
{columns.map(col => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}18. 拖拽上传
jsx
function DragUpload({ onUpload }) {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState([]);
const fileInputRef = useRef(null);
const handleDragEnter = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
handleFiles(droppedFiles);
};
const handleFileSelect = (e) => {
const selectedFiles = Array.from(e.target.files);
handleFiles(selectedFiles);
};
const handleFiles = (newFiles) => {
setFiles(prev => [...prev, ...newFiles]);
onUpload(newFiles);
};
const removeFile = (index) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
return (
<div>
<div
className={`drop-zone ${isDragging ? 'dragging' : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<p>拖拽文件到此处或点击选择文件</p>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
{files.length > 0 && (
<div className="file-list">
{files.map((file, index) => (
<div key={index} className="file-item">
<span>{file.name}</span>
<span>{(file.size / 1024).toFixed(2)} KB</span>
<button onClick={() => removeFile(index)}>删除</button>
</div>
))}
</div>
)}
</div>
);
}19. 多选框组
jsx
function CheckboxGroup({ options, onChange }) {
const [selected, setSelected] = useState([]);
const handleToggle = (value) => {
const newSelected = selected.includes(value)
? selected.filter(v => v !== value)
: [...selected, value];
setSelected(newSelected);
onChange(newSelected);
};
const handleSelectAll = () => {
if (selected.length === options.length) {
setSelected([]);
onChange([]);
} else {
const allValues = options.map(opt => opt.value);
setSelected(allValues);
onChange(allValues);
}
};
const isAllSelected = selected.length === options.length;
const isIndeterminate = selected.length > 0 && selected.length < options.length;
return (
<div>
<label>
<input
type="checkbox"
checked={isAllSelected}
ref={(el) => {
if (el) el.indeterminate = isIndeterminate;
}}
onChange={handleSelectAll}
/>
全选
</label>
{options.map(option => (
<label key={option.value}>
<input
type="checkbox"
checked={selected.includes(option.value)}
onChange={() => handleToggle(option.value)}
/>
{option.label}
</label>
))}
</div>
);
}20. 树形选择器
jsx
function TreeSelect({ data, onChange }) {
const [expanded, setExpanded] = useState(new Set());
const [selected, setSelected] = useState([]);
const handleToggle = (id) => {
setExpanded(prev => {
const newExpanded = new Set(prev);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
return newExpanded;
});
};
const handleSelect = (node) => {
const newSelected = selected.includes(node.id)
? selected.filter(id => id !== node.id)
: [...selected, node.id];
setSelected(newSelected);
onChange(newSelected);
};
const renderNode = (node) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expanded.has(node.id);
const isSelected = selected.includes(node.id);
return (
<div key={node.id} className="tree-node">
<div className="tree-node-content">
{hasChildren && (
<button onClick={() => handleToggle(node.id)}>
{isExpanded ? '−' : '+'}
</button>
)}
<label>
<input
type="checkbox"
checked={isSelected}
onChange={() => handleSelect(node)}
/>
{node.label}
</label>
</div>
{hasChildren && isExpanded && (
<div className="tree-node-children">
{node.children.map(renderNode)}
</div>
)}
</div>
);
};
return <div className="tree-select">{data.map(renderNode)}</div>;
}21. 标签输入
jsx
function TagInput({ initialTags = [], onChange }) {
const [tags, setTags] = useState(initialTags);
const [input, setInput] = useState('');
const inputRef = useRef(null);
const handleKeyDown = (e) => {
if (e.key === 'Enter' && input.trim()) {
e.preventDefault();
addTag(input.trim());
} else if (e.key === 'Backspace' && !input && tags.length > 0) {
removeTag(tags.length - 1);
}
};
const addTag = (tag) => {
if (!tags.includes(tag)) {
const newTags = [...tags, tag];
setTags(newTags);
onChange(newTags);
setInput('');
}
};
const removeTag = (index) => {
const newTags = tags.filter((_, i) => i !== index);
setTags(newTags);
onChange(newTags);
};
return (
<div className="tag-input" onClick={() => inputRef.current?.focus()}>
{tags.map((tag, index) => (
<span key={index} className="tag">
{tag}
<button onClick={() => removeTag(index)}>×</button>
</span>
))}
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? '输入标签...' : ''}
/>
</div>
);
}22. 步骤条
jsx
function Steps({ steps, current, onChange }) {
return (
<div className="steps">
{steps.map((step, index) => (
<div
key={index}
className={`step ${index === current ? 'active' : ''} ${
index < current ? 'completed' : ''
}`}
onClick={() => index <= current && onChange(index)}
>
<div className="step-number">
{index < current ? '✓' : index + 1}
</div>
<div className="step-title">{step.title}</div>
{step.description && (
<div className="step-description">{step.description}</div>
)}
</div>
))}
</div>
);
}
function StepForm() {
const [current, setCurrent] = useState(0);
const [formData, setFormData] = useState({});
const steps = [
{ title: '基本信息', description: '填写个人信息' },
{ title: '详细信息', description: '填写详细资料' },
{ title: '确认提交', description: '确认信息无误' }
];
const handleNext = () => {
if (current < steps.length - 1) {
setCurrent(current + 1);
} else {
handleSubmit();
}
};
const handlePrev = () => {
if (current > 0) {
setCurrent(current - 1);
}
};
const handleSubmit = () => {
console.log('提交表单:', formData);
};
return (
<div>
<Steps steps={steps} current={current} onChange={setCurrent} />
<div className="step-content">
{current === 0 && <StepOne data={formData} onChange={setFormData} />}
{current === 1 && <StepTwo data={formData} onChange={setFormData} />}
{current === 2 && <StepThree data={formData} />}
</div>
<div className="step-actions">
{current > 0 && <button onClick={handlePrev}>上一步</button>}
<button onClick={handleNext}>
{current === steps.length - 1 ? '提交' : '下一步'}
</button>
</div>
</div>
);
}23. 评分组件
jsx
function Rating({ value = 0, max = 5, onChange, readonly = false }) {
const [hover, setHover] = useState(0);
const handleClick = (index) => {
if (!readonly) {
onChange(index);
}
};
return (
<div className="rating">
{Array.from({ length: max }, (_, index) => {
const starValue = index + 1;
const isFilled = starValue <= (hover || value);
return (
<span
key={index}
className={`star ${isFilled ? 'filled' : ''}`}
onClick={() => handleClick(starValue)}
onMouseEnter={() => !readonly && setHover(starValue)}
onMouseLeave={() => !readonly && setHover(0)}
>
{isFilled ? '★' : '☆'}
</span>
);
})}
<span className="rating-text">{value} / {max}</span>
</div>
);
}24. 进度条
jsx
function ProgressBar({ value, max = 100, showText = true, color = 'blue' }) {
const percentage = Math.min((value / max) * 100, 100);
return (
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${percentage}%`,
backgroundColor: color
}}
/>
{showText && (
<span className="progress-text">{percentage.toFixed(0)}%</span>
)}
</div>
);
}
function CircularProgress({ value, max = 100, size = 120, strokeWidth = 10 }) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const percentage = (value / max) * 100;
const offset = circumference - (percentage / 100) * circumference;
return (
<svg width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#e6e6e6"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#4caf50"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dy=".3em"
fontSize="20"
>
{percentage.toFixed(0)}%
</text>
</svg>
);
}25. 通知提示
jsx
function useToast() {
const [toasts, setToasts] = useState([]);
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = Date.now();
const toast = { id, message, type };
setToasts(prev => [...prev, toast]);
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, duration);
}
return id;
}, []);
const removeToast = useCallback((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
return { toasts, addToast, removeToast };
}
function ToastContainer() {
const { toasts, removeToast } = useToast();
return (
<div className="toast-container">
{toasts.map(toast => (
<div key={toast.id} className={`toast toast-${toast.type}`}>
<span>{toast.message}</span>
<button onClick={() => removeToast(toast.id)}>×</button>
</div>
))}
</div>
);
}
const ToastContext = createContext(null);
export function ToastProvider({ children }) {
const toast = useToast();
return (
<ToastContext.Provider value={toast}>
{children}
<ToastContainer />
</ToastContext.Provider>
);
}
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToastContext must be used within ToastProvider');
}
return context;
}26. 骨架屏
jsx
function Skeleton({ width, height, variant = 'text', animation = true }) {
const style = {
width,
height,
borderRadius: variant === 'circle' ? '50%' : variant === 'rect' ? '4px' : '4px'
};
return (
<div
className={`skeleton ${animation ? 'skeleton-pulse' : ''}`}
style={style}
/>
);
}
function CardSkeleton() {
return (
<div className="card-skeleton">
<Skeleton width="100%" height={200} variant="rect" />
<div style={{ padding: '16px' }}>
<Skeleton width="60%" height={24} />
<Skeleton width="80%" height={16} />
<Skeleton width="40%" height={16} />
</div>
</div>
);
}
function useSkeletonWhileLoading(isLoading, SkeletonComponent, count = 1) {
if (!isLoading) return null;
return Array.from({ length: count }, (_, i) => (
<SkeletonComponent key={i} />
));
}27. 回到顶部
jsx
function BackToTop({ threshold = 300 }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
setVisible(window.scrollY > threshold);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [threshold]);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
if (!visible) return null;
return (
<button className="back-to-top" onClick={scrollToTop}>
↑
</button>
);
}28. 复制到剪贴板
jsx
function useCopyToClipboard() {
const [copied, setCopied] = useState(false);
const copy = useCallback(async (text) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
return true;
} catch (error) {
console.error('Failed to copy:', error);
return false;
}
}, []);
return { copied, copy };
}
function CopyButton({ text }) {
const { copied, copy } = useCopyToClipboard();
return (
<button onClick={() => copy(text)}>
{copied ? '已复制' : '复制'}
</button>
);
}29. 颜色选择器
jsx
function ColorPicker({ value, onChange }) {
const [isOpen, setIsOpen] = useState(false);
const presetColors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A',
'#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'
];
return (
<div className="color-picker">
<div
className="color-preview"
style={{ backgroundColor: value }}
onClick={() => setIsOpen(!isOpen)}
/>
{isOpen && (
<div className="color-palette">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<div className="preset-colors">
{presetColors.map(color => (
<div
key={color}
className="preset-color"
style={{ backgroundColor: color }}
onClick={() => {
onChange(color);
setIsOpen(false);
}}
/>
))}
</div>
</div>
)}
</div>
);
}30. 面包屑导航
jsx
function Breadcrumb({ items, separator = '/' }) {
return (
<nav className="breadcrumb">
{items.map((item, index) => (
<span key={index}>
{index > 0 && <span className="separator">{separator}</span>}
{item.href ? (
<a href={item.href}>{item.label}</a>
) : (
<span className="current">{item.label}</span>
)}
</span>
))}
</nav>
);
}
function useBreadcrumb() {
const location = useLocation();
const items = useMemo(() => {
const paths = location.pathname.split('/').filter(Boolean);
return paths.map((path, index) => {
const href = '/' + paths.slice(0, index + 1).join('/');
const label = path.charAt(0).toUpperCase() + path.slice(1);
return { href, label };
});
}, [location]);
return items;
}31. 测试最佳实践
31.1 组件测试模板
jsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('ComponentName', () => {
// 基础渲染测试
it('should render correctly', () => {
render(<ComponentName />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
// 交互测试
it('should handle user interactions', async () => {
const handleClick = jest.fn();
render(<ComponentName onClick={handleClick} />);
const button = screen.getByRole('button');
await userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
// 异步测试
it('should handle async operations', async () => {
render(<ComponentName />);
await waitFor(() => {
expect(screen.getByText('Loaded')).toBeInTheDocument();
});
});
// 错误处理测试
it('should handle errors gracefully', async () => {
const error = new Error('Test error');
jest.spyOn(console, 'error').mockImplementation(() => {});
render(<ComponentName />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
console.error.mockRestore();
});
});31.2 Hook测试
jsx
import { renderHook, act } from '@testing-library/react';
describe('useCustomHook', () => {
it('should initialize with default value', () => {
const { result } = renderHook(() => useCustomHook());
expect(result.current.value).toBe(0);
});
it('should update value', () => {
const { result } = renderHook(() => useCustomHook());
act(() => {
result.current.setValue(10);
});
expect(result.current.value).toBe(10);
});
it('should cleanup on unmount', () => {
const cleanup = jest.fn();
const { unmount } = renderHook(() => {
useEffect(() => cleanup, []);
});
unmount();
expect(cleanup).toHaveBeenCalled();
});
});32. 总结
高频场景题的核心要点:
- 组件设计: 可控/非可控、Modal、无限滚动、表格、表单
- 自定义Hooks: useDebounce、usePrevious、useInterval、useCopyToClipboard
- 性能优化: 列表虚拟化、memoization、懒加载
- 数据获取: 重试、缓存、竞态处理
- 状态管理: 全局状态、状态同步
- 用户交互: 拖拽、上传、搜索、筛选
- UI组件: 步骤条、评分、进度条、通知
- 工具函数: 复制、打印、导出
- 测试: 组件测试、Hook测试
- 答题技巧: 分析、设计、实现、优化
掌握这些场景题是面试成功的关键,理解其背后的原理更为重要。