Skip to content
常见陷阱与最佳实践

常见陷阱与最佳实践

本章汇总 React 开发中最常见的问题和对抗模式。每个陷阱都附有错误示例和正确做法。


陷阱速查表

陷阱 严重度 章节
useEffect 死循环 🔴 高 §1
闭包陷阱(stale closure) 🔴 高 §2
状态更新后立即读取旧值 🟡 中 §3
key 使用索引 🟡 中 §4
Context 重渲染风暴 🟡 中 §5
缺少清理函数 🟡 中 §6
派生状态重复计算 🟢 低 §7
标签缺少 htmlFor / 语义属性 🟢 低 §8
StrictMode 下双重 effect 🟢 低 §9
ref 作为 effect 依赖 🟢 低 §10

§1 useEffect 死循环

// ❌ 无限循环:setState → 渲染 → useEffect → setState → ...
function Buggy() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setCount(count + 1);  // count 是依赖,变化触发 effect
  }, [count]);            // 对,但这个逻辑就是错的
}

// ❌ 更隐蔽的:对象/数组作为依赖
function Buggy() {
  const [data, setData] = useState({});
  useEffect(() => {
    const params = { page: 1 };  // 每次渲染都是新对象
    fetchData(params).then(setData);
  }, [params]);  // 死循环!params 每帧都是新引用
}

// ✅ 解决:用 useMemo 稳定引用
const params = useMemo(() => ({ page: 1 }), []);
useEffect(() => {
  fetchData(params).then(setData);
}, [params]);

💡 最佳实践:如果 React 提示 “maximum update depth exceeded”,第一步检查所有 useEffect 的依赖数组和其中的 setState。


§2 闭包陷阱(Stale Closure)

function Counter() {
  const [count, setCount] = useState(0);

  // ❌ 闭包捕获了创建时的 count(0),不会更新
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);  // 永远打印 0
    }, 1000);
    return () => clearInterval(timer);
  }, []);  // 空依赖 = 闭包冻结在首次渲染

  // ✅ 方案一:使用函数式更新
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(c => c + 1);  // 不依赖外部 count
    }, 1000);
    return () => clearInterval(timer);
  }, []);

  // ✅ 方案二:把 count 加入依赖(每次重建定时器)
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count);
    }, 1000);
    return () => clearInterval(timer);
  }, [count]);

  // ✅ 方案三:用 ref 保存最新值
  const countRef = useRef(count);
  countRef.current = count;
  useEffect(() => {
    const timer = setInterval(() => {
      console.log(countRef.current);  // 始终读到最新值
    }, 1000);
    return () => clearInterval(timer);
  }, []);
}

§3 setState 后立即读取仍是旧值

// ❌
const [count, setCount] = useState(0);
function handleClick() {
  setCount(5);
  console.log(count);  // 仍然是 0!setState 是异步的
}

// ✅ 需要新值做后续操作 → 用 useEffect
useEffect(() => {
  console.log('count 已更新:', count);
}, [count]);

// ✅ 或在事件处理中直接计算
function handleClick() {
  const nextCount = count + 1;
  setCount(nextCount);
  doSomethingWith(nextCount);  // 直接用计算出的新值
}

// ✅ 连续更新 → 函数式写法
setCount(c => c + 1);  // 每次基于最新值

§4 Key 使用索引

// ❌ 排序/增删列表时状态错位
{todos.map((todo, index) => (
  <TodoItem key={index} {...todo} />  // 删除第一个 → 第二个变成第一个
))}

// ✅ 稳定的唯一标识
{todos.map(todo => (
  <TodoItem key={todo.id} {...todo} />
))}

// Key 的特殊用途:强制重新挂载
<Input key={userId} />  // userId 变化 → Input 卸载再挂载(清空状态)

§5 Context 重渲染风暴

// ❌ 所有消费者都重渲染
const AppContext = createContext();
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  // 两个不相关的状态放在同一个 Context → theme 变化触发 user 相关的组件重渲染
  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      <Main />
    </AppContext.Provider>
  );
}

// ✅ 拆分 Context
<UserContext.Provider value={{ user, setUser }}>
  <ThemeContext.Provider value={{ theme, setTheme }}>
    <Main />
  </ThemeContext.Provider>
</UserContext.Provider>

// ✅ 或用 Zustand(天然支持选择器)
const user = useUserStore(s => s.user);    // 只在 user 变化时重渲染

§6 缺少清理函数

// ❌ 组件卸载后定时器仍在运行
useEffect(() => {
  setInterval(() => tick(), 1000);
}, []);

// ❌ 组件卸载后 setState(内存泄漏 + React 警告)
useEffect(() => {
  fetchData().then(setData);  // Promise 完成时组件可能已卸载
}, []);

// ✅ 正确清理
useEffect(() => {
  const id = setInterval(() => tick(), 1000);
  return () => clearInterval(id);
}, []);

useEffect(() => {
  let cancelled = false;
  fetchData().then(data => {
    if (!cancelled) setData(data);
  });
  return () => { cancelled = true; };
}, []);

💡 最佳实践:StrictMode(开发模式)会刻意执行两次 effect(挂载 → 卸载 → 挂载),帮你发现缺少清理的场景。如果你看到两次日志,说明组件缺少清理——这正是 StrictMode 的设计目的。


§7 派生状态 — 冗余的 useState + useEffect

// ❌ 反模式:用 state + effect 同步 props
function Bad({ firstName, lastName }) {
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);
  return <p>{fullName}</p>;
}

// ✅ 直接在渲染中计算(派生状态)
function Good({ firstName, lastName }) {
  const fullName = `${firstName} ${lastName}`;
  return <p>{fullName}</p>;
}

// ✅ 如果计算重,用 useMemo
const fullName = useMemo(
  () => expensiveCombine(firstName, lastName),
  [firstName, lastName]
);

§8 可访问性(Accessibility)

// ❌ div 做按钮
<div onClick={handleClick}>提交</div>

// ✅ button 元素(自动获得键盘焦点、Enter/Space 触发、role 语义)
<button onClick={handleClick}>提交</button>

// ❌ input 缺少 label 关联
<input type="text" />

// ✅ label 关联 input(htmlFor = for)
<label htmlFor="email">邮箱</label>
<input id="email" type="text" />

// ✅ 隐式关联
<label>邮箱<input type="text" /></label>

§9 StrictMode 双重调用迷惑

// 开发模式下这段效果会渲染两次
function Counter() {
  const [count, setCount] = useState(0);
  // 构造函数、Effect、State 初始化函数都会双重执行
  // 这是正常的!帮助发现副作用问题
  return <button onClick={() => setCount(c => c + 1)}>
    点击次数{count}
  </button>;
}

// 生产模式不受影响

§10 ref.current 作为 Effect 依赖

// ❌ effect 不追 ref.current 变化
useEffect(() => {
  console.log(ref.current);  // ref 变化不触发 effect
}, [ref.current]);  // 这个依赖"看起来"有用,但实际无用

// ✅ ref 的值在 effect 中读取,但不放入依赖
useEffect(() => {
  const node = ref.current;
  if (node) {
    node.addEventListener('scroll', handler);
    return () => node.removeEventListener('scroll', handler);
  }
  // ref.current 不需要在依赖中
}, []);  // 只在挂载时执行一次

最佳实践清单

组件设计

  • 组件名 PascalCase,函数组件优先
  • 每个组件只做一件事(单一职责)
  • 超过 150 行考虑拆分
  • 提取共享逻辑到自定义 Hook 而非 HOC
  • 优先使用组合(children、slots)而非继承

状态管理

  • 状态尽量放在使用它的最近公共祖先
  • 服务端数据用 React Query,客户端数据用 Zustand
  • Context 按职责拆分(避免一个大 Context 管理所有)
  • 避开派生状态 — 能在渲染中计算的不要用 state

性能

  • 只有在性能确实是问题时才加 memo/useMemo/useCallback
  • 列表 key 使用稳定唯一标识
  • 大列表 (>200 项) 使用虚拟滚动
  • 路由级别做代码分割

副作用

  • 所有副作用在 useEffect/event handler 中(不在组件体中)
  • 定时器、订阅、fetch 必须清理
  • useEffect 依赖数组不能"骗" ESLint — 确实不需要的依赖才忽略
  • 避免 useEffect 中 setState 导致死循环

可访问性

  • 交互元素用真正的 button/input/a,而不是 div
  • 表单 input 关联 label
  • 图片有 alt 描述

安全性

  • 用户输入不做 dangerouslySetInnerHTML
  • URL 参数不入渲染而不做转义
  • 敏感数据不在组件中硬编码

生产检查清单

上线前自检:

  • StrictMode 包裹根组件
  • 无控制台错误/警告
  • 使用 Error Boundary 捕获渲染错误
  • 关键业务路径有集成测试
  • 代码分割已验证(检查 Network 面板 chunk 大小)
  • 环境变量正确配置(无硬编码的 API URL)
  • package.json 无废弃依赖
  • 已检查 ESLint 输出(npm run lint 通过)