常见陷阱与最佳实践
本章汇总 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通过)