Skip to content

高频场景题汇总 - 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. 总结

高频场景题的核心要点:

  1. 组件设计: 可控/非可控、Modal、无限滚动、表格、表单
  2. 自定义Hooks: useDebounce、usePrevious、useInterval、useCopyToClipboard
  3. 性能优化: 列表虚拟化、memoization、懒加载
  4. 数据获取: 重试、缓存、竞态处理
  5. 状态管理: 全局状态、状态同步
  6. 用户交互: 拖拽、上传、搜索、筛选
  7. UI组件: 步骤条、评分、进度条、通知
  8. 工具函数: 复制、打印、导出
  9. 测试: 组件测试、Hook测试
  10. 答题技巧: 分析、设计、实现、优化

掌握这些场景题是面试成功的关键,理解其背后的原理更为重要。