深入 useEffect:为什么 cleanup 总比 setup 先跑?顺手手写节流防抖 Hook

深入 useEffect:为什么 cleanup 总比 setup 先跑?顺手手写节流防抖 Hook

适合人群:有 React 基础、用过 useEffect 但没深究过原理的中级前端工程师


前言

你有没有遇到过这种情况:在 useEffect 里做了某些操作,切换了一下状态,发现上一次的副作用还没清干净,新的副作用就跑起来了,导致状态混乱、内存泄漏、甚至控制台打印一堆奇怪的东西?

React 其实已经帮你想好了------每次重新执行 useEffect 之前,会先执行上一次的 return(cleanup) ,再执行新的 setup。

但你知道这是为什么吗?React 源码里是怎么做到的?

这篇文章从源码角度解释 cleanup 的执行顺序,顺带手写两个有用的自定义 Hook:useDebounceuseThrottle


一、useEffect 执行顺序:先 cleanup,再 setup

先看一段代码:

scss 复制代码
useEffect(() => {
  console.log('setup: subscribed to', userId);
  const unsub = subscribe(userId);

  return () => {
    console.log('cleanup: unsubscribed from', userId);
    unsub();
  };
}, [userId]);

userId1 变成 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。


延伸阅读


如果这篇文章对你有帮助,欢迎点赞收藏 🙌

有问题欢迎在评论区交流,我会尽量回复!

相关推荐
于慨18 小时前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz18 小时前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
从前慢丶18 小时前
前端交互规范(Web 端)
前端
CHU72903518 小时前
便捷约玩,沉浸推理:线上剧本杀APP功能版块设计详解
前端·小程序
GISer_Jing18 小时前
Page-agent MCP结构
前端·人工智能
王霸天18 小时前
💥别再抄网上的Scale缩放代码了!50行源码教你写一个永不翻车的大屏适配
前端·vue.js·数据可视化
小领航18 小时前
用 Three.js + Vue 3 打造炫酷的 3D 行政地图可视化组件
前端·github
@大迁世界18 小时前
2026年React大洗牌:React Hooks 将迎来重大升级
前端·javascript·react.js·前端框架·ecmascript
PieroPc18 小时前
一个功能强大的 Web 端标签设计和打印工具,支持服务器端直接打印到局域网打印机。Fastapi + html
前端·html·fastapi
悟空瞎说19 小时前
深入 Vue3 响应式:为什么有的要加.value,有的不用?从设计到源码彻底讲透
前端·vue.js