深入 useEffect:为什么 cleanup 总比 setup 先跑?顺手手写节流防抖 Hook
适合人群:有 React 基础、用过 useEffect 但没深究过原理的中级前端工程师
前言
你有没有遇到过这种情况:在 useEffect 里做了某些操作,切换了一下状态,发现上一次的副作用还没清干净,新的副作用就跑起来了,导致状态混乱、内存泄漏、甚至控制台打印一堆奇怪的东西?
React 其实已经帮你想好了------每次重新执行 useEffect 之前,会先执行上一次的 return(cleanup) ,再执行新的 setup。
但你知道这是为什么吗?React 源码里是怎么做到的?
这篇文章从源码角度解释 cleanup 的执行顺序,顺带手写两个有用的自定义 Hook:useDebounce 和 useThrottle。
一、useEffect 执行顺序:先 cleanup,再 setup
先看一段代码:
scss
useEffect(() => {
console.log('setup: subscribed to', userId);
const unsub = subscribe(userId);
return () => {
console.log('cleanup: unsubscribed from', userId);
unsub();
};
}, [userId]);
当 userId 从 1 变成 2 时,控制台会打印:
vbnet
cleanup: unsubscribed from 1
setup: subscribed to 2
先清理旧的,再建立新的。很合理,但这是怎么实现的?
二、React 源码解析:commitPassiveEffects 的两次 Pass
在 React 的 Reconciler 阶段,useEffect 的执行由 commitPassiveEffects 函数负责。
以下基于 React 18 源码简化说明,实际函数名可能在不同版本中略有差异。
整个流程分两个 Pass(遍历) 执行:
Pass 1:全部 cleanup(flushPassiveEffectsImpl 中的 commitPassiveUnmountEffects)
scss
// 伪代码,来自 React 源码 commitPassiveEffects
function commitPassiveEffects(root, lanes) {
// 第一遍:执行所有 effect 的 destroy(即 return 返回的函数)
commitPassiveUnmountEffects(root.current);
// 第二遍:执行所有 effect 的 create(即 effect 函数体)
commitPassiveMountEffects(root, root.current);
}
React 会先遍历整棵 Fiber 树 ,把所有需要更新的 effect 的 destroy 函数(也就是 return () => {} 那部分)全部执行完毕,然后才开始第二遍,挨个执行新的 effect。
为什么要分两遍?
假设组件树里有两个组件 A 和 B,它们都订阅了同一个全局事件总线:
- A 的 cleanup 里取消订阅
- B 的 setup 里重新订阅
如果不分两遍、交叉执行,可能出现:
css
A.setup → A.cleanup → B.setup → B.cleanup
↑ 顺序错乱!B还没cleanup,A就setup了
而 React 分两遍执行,保证了:
less
Pass 1: A.cleanup → B.cleanup(全部清理干净)
Pass 2: A.setup → B.setup (全部重新建立)
这样消除了副作用之间的相互干扰,也避免了内存泄漏(旧的订阅/定时器/监听器全部清理后,才建立新的)。
一句话总结:cleanup 执行在前,是 React 为了保证副作用的幂等性和无冲突性而设计的。
三、手写 useDebounce:利用 cleanup 天然实现防抖
防抖的核心:在最后一次触发后等待一段时间再执行。
如果我们把 useEffect 的 cleanup 特性用上,实现非常优雅:
typescript
import { useEffect, useState } from 'react';
function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// value 或 delay 变化时,设置一个定时器
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// cleanup:在下一次 effect 执行前,清除上一个定时器
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
使用示例
ini
function SearchBox() {
const [input, setInput] = useState('');
const debouncedInput = useDebounce(input, 300);
useEffect(() => {
if (debouncedInput) {
console.log('发起搜索请求:', debouncedInput);
// fetchSearchResults(debouncedInput);
}
}, [debouncedInput]);
return (
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="搜索..."
/>
);
}
为什么这么简洁?
因为 cleanup 天然就是防抖的实现机制:
- 用户连续输入时,
value每次变化都会触发新的useEffect - 每次
useEffect执行前,先执行 cleanup,把上一个setTimeout清掉 - 只有用户停止输入超过
delay毫秒,最后那个setTimeout才会真正执行
不需要 useRef 存 timer,不需要手动管理,useEffect 的生命周期已经帮你做好了。
四、手写 useThrottle:两种实现对比
节流的核心:在固定时间内,最多执行一次。
节流有两种常见实现思路,各有优缺点。
实现一:timer 判断法(用 useRef 标记是否在冷却中)
ini
import { useRef, useCallback } from 'react';
function useThrottle<T extends (...args: any[]) => any>(
fn: T,
delay: number = 500
): T {
const isThrottling = useRef(false);
const throttledFn = useCallback(
(...args: Parameters<T>) => {
if (isThrottling.current) return; // 冷却中,直接忽略
isThrottling.current = true;
fn(...args);
setTimeout(() => {
isThrottling.current = false; // 冷却结束
}, delay);
},
[fn, delay]
);
return throttledFn as T;
}
使用示例:
javascript
function ScrollTracker() {
const handleScroll = useThrottle(() => {
console.log('滚动位置:', window.scrollY);
}, 200);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
return <div style={{ height: '2000px' }}>滚动我</div>;
}
优点: 实现简单直观,逻辑清晰
缺点: 第一次执行后进入冷却,最后一次触发可能被丢弃(比如滚动到底部的那次事件可能被节流掉)
实现二:计算时间法(通过时间戳判断是否满足间隔)
ini
import { useRef, useCallback } from 'react';
function useThrottle<T extends (...args: any[]) => any>(
fn: T,
delay: number = 500
): T {
const lastCalledAt = useRef<number>(0);
const throttledFn = useCallback(
(...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCalledAt.current < delay) return; // 还在冷却期
lastCalledAt.current = now;
fn(...args);
},
[fn, delay]
);
return throttledFn as T;
}
优点: 不需要 setTimeout,纯同步判断,性能更好;每次调用都能精确判断是否过了间隔
缺点: 同样会丢弃冷却期内的最后一次触发;实现上没有「调用结束后再执行一次」的能力
两种实现的对比
| 特性 | timer 判断法 | 计算时间法 |
|---|---|---|
| 实现复杂度 | 低 | 低 |
| 性能开销 | 有 setTimeout | 无(纯时间戳) |
| 最后一次触发 | 可能丢失 | 可能丢失 |
| 精确度 | 受 setTimeout 精度影响 | Date.now() 毫秒精度 |
| 适用场景 | 按钮防连击、API限流 | 滚动/mousemove 高频事件 |
Tips: 如果需要"最后一次触发也要执行"的语义(比如停止滚动后触发一次),可以结合 timer 在冷却结束时再调用一次 fn,这就是 leading + trailing 模式,lodash 的
_.throttle就是这么做的。
五、总结
| 知识点 | 核心结论 |
|---|---|
| useEffect cleanup 顺序 | React 分两遍:先全部 cleanup,再全部 setup,防止副作用冲突 |
| useDebounce | 利用 cleanup 天然清 timer,极简实现 |
| useThrottle(timer法) | 简单直观,适合低频场景 |
| useThrottle(时间法) | 无 setTimeout 开销,适合高频事件 |
useEffect 的 cleanup 机制不只是"清理垃圾",它是 React 副作用系统的核心保障。 理解了这一点,你就能更自信地封装自定义 Hook,也能更快定位副作用相关的 bug。
延伸阅读
- 📘 React 官方文档 - useEffect 同步副作用
- 📘 React 官方文档 - Removing Effect Dependencies
- 🔬 React 源码:commitPassiveEffects(GitHub)
- 📝 Dan Abramov:A Complete Guide to useEffect(经典长文,强烈推荐)
- 🛠️ ahooks 源码:useDebounce 实现(生产级实现参考)
如果这篇文章对你有帮助,欢迎点赞收藏 🙌
有问题欢迎在评论区交流,我会尽量回复!