文章中英模式
布鲁斯前端React面试题目 - useEffect的执行时机
深入了解React useEffect执行时机、执行顺序与常见问题。掌握useEffect在组件生命周期中的运作原理,清理函数的执行时机,以及依赖数组的设计,避免无限循环与跳过执行等常见错误。
文章中英模式

懒得看文章?那就来看视频吧
useEffect 的基本执行时机
useEffect 是React的一个核心Hook,用于处理有副作用的逻辑。其执行时机是React渲染流程中的关键点,理解它何时运行对于正确使用它非常重要。
基本执行时机如下:
- 1. 首次渲染后:组件挂载完成并出现在画面上后执行
- 2. 依赖项变更的渲染后:当指定的依赖项变更导致重新渲染后执行
- 3. 组件卸载前:清理函数(return的函数)会在组件卸载前执行
function ExampleComponent() {
const [count, setCount] = useState(0);
// 基本用法示范
useEffect(() => {
console.log('Effect 执行:组件挂载或count变更');
document.title = `计数: ${count}`;
// 清理函数
return () => {
console.log('清理执行:下一次effect执行前或卸载时');
};
}, [count]); // 依赖count变更
return (
<button onClick={() => setCount(c => c + 1)}>
增加 ({count})
</button>
);
}React 渲染周期与 useEffect 的关系
useEffect 总是在画面更新后才执行,这是它的关键特性:
React渲染流程
1. 执行组件函数,计算虚拟DOM
2. 更新实际DOM,画面重绘
3. 执行useEffect函数
这种设计有重要好处:
- 1. 不阻塞UI渲染,用户能更快看到画面
- 2. 可以安全访问已更新的DOM元素
// 简单示例
function App() {
const [name, setName] = useState('');
// 在DOM更新后执行
useEffect(() => {
// 此时画面已更新,用户已看到新内容
document.title = name || 'React App';
}, [name]);
return <input value={name} onChange={e => setName(e.target.value)} />;
}⚠️ 重要: useEffect 总是异步执行的,它会在浏览器完成绘制之后才触发,不会阻塞UI更新。
多个 useEffect 的执行顺序
当组件中有多个useEffect时,它们的执行顺序遵循以下规则:
- 1. 按照组件中声明的顺序,从上到下依次执行
- 2. 每个useEffect的清理函数在下一次该effect执行前运行
- 3. 卸载时,清理函数按照useEffect的相反顺序执行
function MultipleEffectsExample() {
// 以下效果将按顺序执行 (挂载后)
useEffect(() => {
console.log('第一个效果');
return () => console.log('清理第一个效果');
}, []);
useEffect(() => {
console.log('第二个效果');
return () => console.log('清理第二个效果');
}, []);
useEffect(() => {
console.log('第三个效果');
return () => console.log('清理第三个效果');
}, []);
// 组件卸载时的日志顺序:
// "清理第三个效果"
// "清理第二个效果"
// "清理第一个效果"
}清理函数的执行时机
清理函数(effect return的函数)的执行时机有两种情况:
- 1. 下一次effect执行前:当依赖项变更,重新执行effect前,会先执行上一次effect的清理函数
- 2. 组件卸载时:组件从DOM中移除前,会执行最后一次effect的清理函数
清理函数的主要目的是防止内存泄漏和取消不再需要的订阅或操作:
function WindowWidthObserver() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// 设置事件监听
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
// 清理函数 - 防止内存泄漏
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // 空依赖数组,只在挂载和卸载时执行
return <div>窗口宽度: {width}px</div>;
}清理函数在多次渲染中的执行顺序
首次渲染
组件渲染
执行 Effect A
依赖项变更
组件重新渲染
执行 Effect A 的清理函数
执行新的 Effect A
组件卸载
组件即将移除
执行 Effect A 的清理函数
组件从DOM移除
依赖数组与执行时机
依赖数组(第二个参数)决定了useEffect何时重新执行:
| 依赖数组 | 执行时机 | 使用场景 |
|---|---|---|
未提供 | 每次渲染后都执行 | 需要在每次渲染后同步某些内容 |
[] | 只在挂载后和卸载前执行 | 设置/清理只需执行一次的操作 |
[a, b] | 挂载后和a或b变更后执行 | 响应特定数据变化的副作用 |
function DependencyExample({ userId, filter }) {
// 1. 每次渲染后都执行 (没有依赖数组)
useEffect(() => {
console.log('组件已渲染');
});
// 2. 只在挂载和卸载时执行 (空依赖数组)
useEffect(() => {
console.log('组件已挂载');
return () => console.log('组件即将卸载');
}, []);
// 3. 当userId变更时执行 (指定依赖)
useEffect(() => {
console.log(`正在获取用户 ${userId} 的数据`);
fetchUserData(userId);
}, [userId]);
// 4. 当userId或filter变更时执行 (多个依赖)
useEffect(() => {
console.log(`正在为用户 ${userId} 应用过滤器 ${filter}`);
fetchFilteredData(userId, filter);
}, [userId, filter]);
}useEffect 常见错误及解决方案
1. 无限循环
最常见的错误之一是在useEffect中更新state,但未正确设置依赖数组:
// ❌ 错误示例 - 无限循环
function InfiniteLoopExample() {
const [count, setCount] = useState(0);
useEffect(() => {
// effect更新state,导致重新渲染,再次触发effect
setCount(count + 1);
}, [count]); // count变更时重新执行
return <div>{count}</div>;
}
// ✅ 修正方法1 - 使用函数式更新,移除依赖
function FixedExample1() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(c => c + 1); // 函数式更新不需要依赖count
}, []); // 只执行一次
return <div>{count}</div>;
}
// ✅ 修正方法2 - 条件控制
function FixedExample2() {
const [count, setCount] = useState(0);
const [hasIncremented, setHasIncremented] = useState(false);
useEffect(() => {
if (!hasIncremented) {
setCount(count + 1);
setHasIncremented(true);
}
}, [count, hasIncremented]);
return <div>{count}</div>;
}2. 遗漏依赖
另一个常见错误是没有包含effect中使用的所有变量作为依赖:
// ❌ 错误示例 - 遗漏依赖
function MissingDependencyExample({ productId, userId }) {
const [data, setData] = useState(null);
useEffect(() => {
// effect使用了productId,但依赖数组中缺少它
fetchProductData(productId, userId).then(setData);
}, [userId]); // 仅依赖userId
return <div>{/* 显示数据 */}</div>;
}
// ✅ 修正方法 - 包含所有依赖
function FixedDependencyExample({ productId, userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchProductData(productId, userId).then(setData);
}, [productId, userId]); // 包含所有依赖
return <div>{/* 显示数据 */}</div>;
}3. 副作用中的异步问题
在处理异步操作时,可能出现竞态条件(Race Condition)问题:
竞态条件问题示意图
用户快速输入搜索字符串:
问题:旧的请求结果覆盖了新的请求结果,导致UI显示与用户最新输入不符。
// ❌ 有潜在问题的示例 - 竞态条件
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query).then(data => {
// ⚠️ 这里没有检查 query 是否还是最新的
setResults(data);
});
}, [query]);
return <ResultsList results={results} />;
}使用 AbortController 解决竞态条件
AbortController 工作原理:
输入 "a" 时
建立 controller A
发送请求 A
输入 "ab" 时
执行清理函数
controller A.abort()
→ 请求 A 被取消 ✓
建立 controller B
发送请求 B
结果
只有请求 B 完成
显示 "ab" 的结果
→ 符合最新输入 ✓
优点:自动取消过时的请求,避免内存泄漏,并确保UI始终显示最新请求的结果。
// ✅ 方法:使用 AbortController(更干净)
function FixedSearchResults2({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then(data => {
setResults(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('获取失败:', err);
}
});
return () => {
controller.abort(); // 中止前一个请求
};
}, [query]);
return <ResultsList results={results} />;
}避免内存泄漏的 useEffect 处理
不正确的 useEffect 清理可能导致内存泄漏,特别是在处理订阅、计时器、事件监听器等资源时。以下是常见的内存泄漏情境与解决方案:
常见内存泄漏来源
- 未清理的事件监听器
- 未取消的计时器 (setTimeout/setInterval)
- 未关闭的网络连接或订阅
- 未释放的DOM引用
正确的清理模式如下:
// 计时器清理示例
useEffect(() => {
const timer = setTimeout(() => {
console.log('这是一个计时器');
}, 1000);
// 清理函数
return () => {
clearTimeout(timer); // 清理计时器
};
}, []);
// 事件监听器清理示例
useEffect(() => {
const handleResize = () => {
console.log('窗口大小改变');
};
window.addEventListener('resize', handleResize);
// 清理函数
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);内存泄漏的影响
未清理的组件: 即使组件已卸载,仍保持活动连接
组件已卸载
↓
监听器/计时器仍在运行
↓
内存无法释放
↓
可能导致应用崩溃
正确清理的组件: 组件卸载时释放所有资源
组件即将卸载
↓
执行清理函数
↓
释放所有资源
↓
内存正确回收
一个实用的内存泄漏检测技巧是在开发时添加警告日志:
function DataFetcher() {
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
// 防止在已卸载组件上设置状态
if (isMounted) {
setData(data);
}
});
return () => {
isMounted = false;
console.log('DataFetcher 已清理资源'); // 开发时的检查日志
};
}, []);
// ...
}💡 提示: 使用 React 18 的严格模式可以帮助发现潜在的内存泄漏问题,因为它会故意重复挂载和卸载组件,使清理问题更容易被发现。
🔥 常见面试题目
(一) useEffect的执行时机,useEffect的清理函数何时执行?它有什么用途?
解答: useEffect在每次渲染完成后(DOM已更新)执行。根据依赖数组设置:
- 没有依赖数组:每次渲染后执行
- 空依赖数组
[]:只在首次渲染后执行 - 有依赖的数组:首次渲染后和依赖变更时执行
useEffect执行流程
1. 元件渲染
2. 畫面更新
3. 執行useEffect
清理函数执行时机:
- 在下一次effect执行<strong>之前</strong>(依赖变更时)
- 在组件<strong>卸载时</strong>(从DOM移除前)
清理函数用途:
- 防止内存泄漏(取消订阅、清除计时器)
- 取消进行中的API请求
- 移除事件监听器
- 避免在已卸载组件上更新状态
useEffect(() => {
const timer = setTimeout(() => {...}, 1000);
return () => {
clearTimeout(timer); // 清理计时器
};
}, [dependency]);(二) 如何处理useEffect中的异步操作?
解答: 处理异步操作的关键点:
- 使用标记变量避免在已卸载组件上更新状态
- 妥善处理错误
- 处理竞态条件(多个请求时只使用最新结果)
function FixedSearchResults2({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then(data => {
setResults(data);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('获取失败:', err);
}
});
return () => {
controller.abort(); // 中止前一个请求
};
}, [query]);
return <ResultsList results={results} />;
}处理竞态条件
1. 用户搜索'A'
→ 发送请求A
2. 用户快速搜索'B'
→ 取消请求A
→ 发送请求B
3. 只显示B的结果
→ 避免旧数据覆盖
(三) 如何处理useEffect中的内存泄漏问题
解答: 防止内存泄漏的主要方法:
- 使用清理函数释放资源
- 取消未完成的异步操作
- 移除事件监听器和订阅
- 使用AbortController取消fetch请求
useEffect(() => {
// 1. Event listener
window.addEventListener('resize', handleResize);
// 2. Timer
const interval = setInterval(tick, 1000);
// 3. Use AbortController to cancel fetch
const controller = new AbortController();
fetch(url, { signal: controller.signal });
// Cleanup function
return () => {
window.removeEventListener('resize', handleResize);
clearInterval(interval);
controller.abort();
};
}, []);常见内存泄漏来源
未清理的计时器
setTimeout, setInterval
未移除的事件监听器
addEventListener
未取消的网络请求
fetch, axios
未取消的订阅
WebSocket, Observable
使用React 18严格模式可以帮助发现内存泄漏,因为它会重复挂载和卸载组件,使清理问题更容易被发现。