useEffect 完全指南:从原理到精通
目录
- useEffect 是什么
- 内部实现原理
- 基础语法与依赖数组
- 模拟生命周期
- 清理函数 (Cleanup)
- 依赖项最佳实践
- 你可能不需要 useEffect
- useEffect vs useLayoutEffect
- 复杂场景与陷阱
- React 19.2 新特性:useEffectEvent
- 常见错误与解决方案
- 最佳实践总结
1. useEffect 是什么
useEffect 是 React 的副作用 Hook,用于处理组件中的"副作用"操作——即那些影响组件外部世界的操作。
副作用包括:
- 数据获取 (API 调用)
- 订阅 (WebSocket、事件监听)
- 手动 DOM 操作
- 定时器 (setTimeout、setInterval)
- 日志记录
useEffect(() => {
// 副作用代码
return () => {
// 清理代码 (可选)
};
}, [dependencies]);
2. 内部实现原理
Fiber 架构中的 Effect
React 使用 Fiber 架构管理组件树,每个组件对应一个 Fiber 节点。useEffect 的工作原理:
组件渲染 → Fiber 节点更新 → 收集 Effect → 浏览器绑制 → 异步执行 Effect
核心数据结构
// Effect 对象结构
interface Effect {
tag: number; // 标记 Effect 类型和是否需要执行
create: () => void; // 我们传入的回调函数
destroy: () => void; // cleanup 函数
deps: any[] | null; // 依赖数组
next: Effect | null; // 链表指针,指向下一个 Effect
}
执行流程
初次挂载 (mountEffect):
- 创建新的 Hook 对象
- 调用
pushEffect() 创建 Effect 对象
- 设置
HookHasEffect 标记,表示需要执行
- Effect 存储在 Fiber 的
updateQueue 中
更新阶段 (updateEffect):
- 获取当前 Hook
- 比较新旧依赖数组 (
areHookInputsEqual)
- 如果依赖变化,设置
HookHasEffect 标记
- 如果依赖未变,不设置标记,Effect 不会执行
// 简化的依赖比较逻辑
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) return false;
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) continue;
return false;
}
return true;
}
Effect 执行时机
Render Phase (可中断)
↓
Commit Phase (不可中断)
├── Before Mutation (DOM 更新前)
├── Mutation (DOM 更新)
└── Layout (useLayoutEffect 执行)
↓
浏览器绑制
↓
Passive Effects (useEffect 异步执行)
关键点: useEffect 在浏览器绘制后异步执行,不会阻塞渲染。
3. 基础语法与依赖数组
三种依赖模式
// 1. 无依赖数组:每次渲染后都执行
useEffect(() => {
console.log('每次渲染后执行');
});
// 2. 空依赖数组:仅在挂载时执行一次
useEffect(() => {
console.log('仅挂载时执行');
}, []);
// 3. 有依赖数组:依赖变化时执行
useEffect(() => {
console.log(`count 变化了: ${count}`);
}, [count]);
依赖比较机制
React 使用 Object.is() 进行浅比较:
// 基本类型:值比较
Object.is(1, 1); // true
Object.is('a', 'a'); // true
// 引用类型:引用比较
Object.is({}, {}); // false (不同引用)
Object.is([], []); // false (不同引用)
const obj = { a: 1 };
Object.is(obj, obj); // true (同一引用)
陷阱: 每次渲染创建的新对象/数组/函数都是新引用!
// ❌ 错误:每次渲染 options 都是新对象,导致无限循环
useEffect(() => {
fetchData(options);
}, [options]); // options = { page: 1 } 每次都是新引用
// ✅ 正确:使用 useMemo 稳定引用
const options = useMemo(() => ({ page }), [page]);
useEffect(() => {
fetchData(options);
}, [options]);
4. 模拟生命周期
useEffect 可以模拟 Class 组件的生命周期方法:
componentDidMount
useEffect(() => {
// 组件挂载后执行
console.log('Component mounted');
}, []); // 空依赖数组 = 仅执行一次
componentDidUpdate
// 方式一:监听特定状态变化
useEffect(() => {
console.log('count updated:', count);
}, [count]);
// 方式二:跳过首次渲染,仅在更新时执行
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
console.log('Component updated (not first render)');
});
componentWillUnmount
useEffect(() => {
return () => {
// 组件卸载前执行
console.log('Component will unmount');
};
}, []);
完整生命周期模拟
function LifecycleDemo({ userId }) {
const [user, setUser] = useState(null);
const isFirstRender = useRef(true);
// componentDidMount
useEffect(() => {
console.log('Mounted');
return () => {
console.log('Unmounted'); // componentWillUnmount
};
}, []);
// componentDidUpdate (仅 userId 变化时)
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
console.log('userId changed:', userId);
}, [userId]);
// 数据获取
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <div>{user?.name}</div>;
}
5. 清理函数 (Cleanup)
为什么需要清理
清理函数用于防止内存泄漏和避免对已卸载组件进行状态更新。
清理函数执行时机
Effect 执行 → 返回 cleanup 函数
↓
下次 Effect 执行前 → 先执行上次的 cleanup
↓
组件卸载时 → 执行最后一次的 cleanup
常见清理场景
1. 事件监听器
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
2. 定时器
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
3. 订阅
useEffect(() => {
const subscription = dataSource.subscribe(handleData);
return () => subscription.unsubscribe();
}, [dataSource]);
4. 异步请求 (AbortController)
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/user/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== 'AbortError') {
setError(error);
}
}
}
fetchData();
return () => controller.abort();
}, [userId]);
5. 使用标志位处理异步
useEffect(() => {
let isCancelled = false;
async function fetchData() {
const data = await fetchUser(userId);
if (!isCancelled) {
setUser(data);
}
}
fetchData();
return () => {
isCancelled = true;
};
}, [userId]);
6. 依赖项最佳实践
规则一:诚实声明所有依赖
// ❌ 错误:遗漏依赖
useEffect(() => {
fetchData(userId, page); // userId 和 page 都被使用
}, [userId]); // 遗漏了 page
// ✅ 正确:声明所有依赖
useEffect(() => {
fetchData(userId, page);
}, [userId, page]);
规则二:使用函数式更新避免依赖
// ❌ 需要依赖 count
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 每次 count 变化都重新创建定时器
// ✅ 使用函数式更新,无需依赖 count
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 只创建一次定时器
规则三:使用 useCallback 稳定函数引用
// ❌ 每次渲染 handleClick 都是新函数
const handleClick = () => {
console.log(count);
};
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 每次渲染都重新绑定
// ✅ 使用 useCallback 稳定引用
const handleClick = useCallback(() => {
console.log(count);
}, [count]);
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]); // 仅 count 变化时重新绑定
规则四:使用 useMemo 稳定对象引用
// ❌ 每次渲染 config 都是新对象
const config = { theme, language };
useEffect(() => {
initializeApp(config);
}, [config]); // 每次渲染都执行
// ✅ 使用 useMemo
const config = useMemo(() => ({ theme, language }), [theme, language]);
useEffect(() => {
initializeApp(config);
}, [config]); // 仅 theme 或 language 变化时执行
规则五:将函数移入 Effect 内部
// ❌ 函数在外部,需要作为依赖
const fetchData = () => {
return fetch(`/api/user/${userId}`);
};
useEffect(() => {
fetchData().then(setUser);
}, [fetchData, userId]); // fetchData 每次都是新引用
// ✅ 将函数移入 Effect 内部
useEffect(() => {
const fetchData = () => {
return fetch(`/api/user/${userId}`);
};
fetchData().then(setUser);
}, [userId]); // 只依赖 userId
7. 你可能不需要 useEffect
React 官方文档强调:Effect 是 React 范式的"逃生舱",用于与外部系统同步。很多场景不需要 useEffect。
场景一:基于 props/state 计算派生数据
// ❌ 错误:使用 Effect 计算派生状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ 正确:直接在渲染时计算
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 直接计算
场景二:过滤/转换数据
// ❌ 错误:使用 Effect 过滤列表
const [items, setItems] = useState([]);
const [filter, setFilter] = useState('');
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(item => item.includes(filter)));
}, [items, filter]);
// ✅ 正确:直接计算,必要时用 useMemo
const filteredItems = useMemo(
() => items.filter(item => item.includes(filter)),
[items, filter]
);
场景三:响应用户事件
// ❌ 错误:使用 Effect 响应表单提交
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalytics('form_submitted');
setSubmitted(false);
}
}, [submitted]);
const handleSubmit = () => {
setSubmitted(true);
};
// ✅ 正确:直接在事件处理器中执行
const handleSubmit = () => {
submitForm();
sendAnalytics('form_submitted');
};
场景四:初始化应用
// ❌ 错误:在 Effect 中初始化
useEffect(() => {
initializeApp();
}, []);
// ✅ 正确:在模块顶层或应用入口初始化
// app.ts
if (typeof window !== 'undefined') {
initializeApp();
}
场景五:重置状态
// ❌ 错误:使用 Effect 重置状态
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
}
// ✅ 正确:使用 key 强制重新挂载
function ProfilePage({ userId }) {
return <Profile userId={userId} key={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState(''); // userId 变化时自动重置
}
何时需要 useEffect
- 与外部系统同步 (DOM、第三方库、WebSocket)
- 数据获取 (但推荐使用 React Query、SWR 等库)
- 设置订阅
- 发送分析日志 (页面访问,非用户操作)
8. useEffect vs useLayoutEffect
执行时机对比
Render → DOM 更新 → useLayoutEffect (同步) → 浏览器绘制 → useEffect (异步)
| 特性 |
useEffect |
useLayoutEffect |
| 执行时机 |
浏览器绘制后 |
浏览器绘制前 |
| 执行方式 |
异步 |
同步 |
| 阻塞渲染 |
否 |
是 |
| 性能影响 |
小 |
可能较大 |
何时使用 useLayoutEffect
1. 测量 DOM 元素
function Tooltip({ children, targetRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
// 使用 useLayoutEffect 避免闪烁
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 10,
left: rect.left,
});
}, [targetRef]);
return (
<div style={{ position: 'absolute', ...position }}>
{children}
</div>
);
}
2. 同步 DOM 变更
function AutoFocus({ children }) {
const ref = useRef(null);
useLayoutEffect(() => {
ref.current?.focus();
}, []);
return <div ref={ref} tabIndex={-1}>{children}</div>;
}
3. 防止视觉闪烁
function AnimatedComponent() {
const ref = useRef(null);
// 在绘制前设置初始位置,避免闪烁
useLayoutEffect(() => {
ref.current.style.transform = 'translateX(-100%)';
// 触发重排后设置动画
requestAnimationFrame(() => {
ref.current.style.transition = 'transform 0.3s';
ref.current.style.transform = 'translateX(0)';
});
}, []);
return <div ref={ref}>Animated</div>;
}
默认使用 useEffect
// ✅ 大多数情况使用 useEffect
useEffect(() => {
fetchData();
subscribeToEvents();
logAnalytics();
}, []);
// ⚠️ 仅在需要同步 DOM 操作时使用 useLayoutEffect
useLayoutEffect(() => {
measureElement();
updatePosition();
}, []);
9. 复杂场景与陷阱
陷阱一:无限循环
// ❌ 无限循环:Effect 中更新依赖
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // 更新 count → 触发 Effect → 更新 count...
}, [count]);
// ✅ 解决方案 1:使用函数式更新
useEffect(() => {
setCount(c => c + 1);
}, []); // 不依赖 count
// ✅ 解决方案 2:添加条件判断
useEffect(() => {
if (count < 10) {
setCount(count + 1);
}
}, [count]);
陷阱二:闭包陷阱 (Stale Closure)
// ❌ 闭包陷阱:count 永远是 0
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 永远打印 0
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖,闭包捕获初始值
// ✅ 解决方案 1:添加依赖
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 每次 count 变化重新创建定时器
// ✅ 解决方案 2:使用 ref 存储最新值
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current); // 始终是最新值
}, 1000);
return () => clearInterval(timer);
}, []);
陷阱三:竞态条件 (Race Condition)
// ❌ 竞态条件:快速切换 userId 可能导致显示错误数据
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
// ✅ 解决方案:使用标志位或 AbortController
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(user => {
if (!cancelled) {
setUser(user);
}
});
return () => {
cancelled = true;
};
}, [userId]);
陷阱四:对象/数组依赖
// ❌ 每次渲染都是新对象,导致 Effect 每次都执行
function Component({ items }) {
const config = { sortBy: 'name', items };
useEffect(() => {
processData(config);
}, [config]); // 每次都是新引用
}
// ✅ 解决方案 1:展开为基本类型
useEffect(() => {
processData({ sortBy: 'name', items });
}, [items]); // 只依赖 items
// ✅ 解决方案 2:使用 useMemo
const config = useMemo(() => ({ sortBy: 'name', items }), [items]);
useEffect(() => {
processData(config);
}, [config]);
// ✅ 解决方案 3:使用 JSON.stringify (谨慎使用)
useEffect(() => {
processData(config);
}, [JSON.stringify(config)]);
陷阱五:StrictMode 双重执行
React 18+ 的 StrictMode 会在开发环境中双重执行 Effect,用于检测副作用是否正确清理。
// ❌ 不幂等的 Effect
useEffect(() => {
items.push(newItem); // 双重执行会添加两次
}, []);
// ✅ 幂等的 Effect
useEffect(() => {
if (!items.includes(newItem)) {
items.push(newItem);
}
}, []);
// ✅ 或者使用 cleanup 确保正确清理
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);
10. React 19.2 新特性:useEffectEvent
React 19.2 引入了 useEffectEvent,解决了 Effect 中读取最新 props/state 而不触发重新执行的问题。
问题场景
// ❌ 问题:onTick 变化会导致定时器重建
function Timer({ onTick, interval }) {
useEffect(() => {
const id = setInterval(() => {
onTick(); // 需要最新的 onTick
}, interval);
return () => clearInterval(id);
}, [onTick, interval]); // onTick 变化会重建定时器
}
useEffectEvent 解决方案
import { useEffectEvent } from 'react';
function Timer({ onTick, interval }) {
// useEffectEvent 创建一个稳定的函数,但总是读取最新的 props
const tick = useEffectEvent(() => {
onTick(); // 总是调用最新的 onTick
});
useEffect(() => {
const id = setInterval(tick, interval);
return () => clearInterval(id);
}, [interval]); // 不需要依赖 onTick
}
useEffectEvent 特点
- 返回稳定的函数引用
- 函数内部总是读取最新的 props/state
- 不需要作为 Effect 的依赖
- 只能在 Effect 内部调用
使用场景
// 场景:日志记录需要最新的 props,但不应触发 Effect 重新执行
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
logConnection(roomId, theme); // 读取最新的 theme
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on('connected', onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // 只在 roomId 变化时重连,theme 变化不会重连
}
11. 常见错误与解决方案
错误 1:async 函数作为 Effect 回调
// ❌ 错误:async 函数返回 Promise,不是 cleanup 函数
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// ✅ 正确:在内部定义 async 函数
useEffect(() => {
async function fetchData() {
const data = await fetch('/api/data');
setData(data);
}
fetchData();
}, []);
// ✅ 或使用 IIFE
useEffect(() => {
(async () => {
const data = await fetchData();
setData(data);
})();
}, []);
错误 2:遗漏依赖
// ❌ ESLint 警告:React Hook useEffect has a missing dependency
useEffect(() => {
fetchUser(userId);
}, []); // 遗漏 userId
// ✅ 添加所有依赖
useEffect(() => {
fetchUser(userId);
}, [userId]);
错误 3:在卸载后更新状态
// ❌ 警告:Can't perform a React state update on an unmounted component
useEffect(() => {
fetchData().then(data => {
setData(data); // 组件可能已卸载
});
}, []);
// ✅ 使用标志位检查
useEffect(() => {
let mounted = true;
fetchData().then(data => {
if (mounted) {
setData(data);
}
});
return () => {
mounted = false;
};
}, []);
错误 4:Effect 执行顺序假设
// ❌ 错误假设:Effect 按声明顺序执行
useEffect(() => {
console.log('Effect 1');
}, []);
useEffect(() => {
console.log('Effect 2');
}, []);
// 实际上:子组件的 Effect 先于父组件执行
// 同一组件内的 Effect 按声明顺序执行
错误 5:过度使用 Effect
// ❌ 过度使用:用 Effect 同步状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ 直接计算
const fullName = `${firstName} ${lastName}`;
12. 最佳实践总结
核心原则
-
Effect 是逃生舱 - 仅用于与外部系统同步
-
诚实声明依赖 - 不要欺骗 React 关于依赖
-
保持 Effect 幂等 - 多次执行应产生相同结果
-
始终清理副作用 - 防止内存泄漏
依赖项检查清单
| 依赖类型 |
处理方式 |
| 基本类型 (string, number, boolean) |
直接添加到依赖数组 |
| 函数 |
使用 useCallback 或移入 Effect 内部 |
| 对象/数组 |
使用 useMemo 或展开为基本类型 |
| Ref |
不需要添加 (ref.current 是可变的) |
| setState 函数 |
不需要添加 (React 保证稳定) |
代码模板
数据获取
function useData(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchData() {
try {
setLoading(true);
const response = await fetch(url, { signal: controller.signal });
const json = await response.json();
setData(json);
setError(null);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}
fetchData();
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
事件监听
function useEventListener(eventName, handler, element = window) {
const savedHandler = useRef(handler);
useLayoutEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const eventListener = (event) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
return () => element.removeEventListener(eventName, eventListener);
}, [eventName, element]);
}
定时器
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
参考资源
文档基于 React 19.2 及 2025-2026 年最新实践整理,部分内容参考自 React 官方文档、jser.dev 等来源并进行了改写。