深入 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。


延伸阅读


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

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

相关推荐
小J听不清2 小时前
CSS 字体样式全解析:字体类型 / 大小 / 粗细 / 样式
前端·javascript·css·html·css3
500佰2 小时前
pencil on claude 让设计师和程序员少吵架的一种可能
前端
Jane-lan2 小时前
NVM安装以及可能的坑
前端·node·nvm
幽络源小助理2 小时前
Typecho大前端新闻博客主题源码下载:资讯门户风格模板安装教程 | 幽络源
前端
简离2 小时前
Git 一次性清理已跟踪但应忽略文件
前端·git
清水寺小和尚2 小时前
# 告别魔法:带你彻底搞透 Agent Loop、Skills、Teams 与 MCP 协议
前端
小蜜蜂dry2 小时前
nestjs学习 - 管道(pipe)
前端·nestjs
梦鱼2 小时前
🖥️ 告别 Electron 托盘图标模糊:一套精准的 PNG 生成方案
前端·electron
张元清2 小时前
React Hooks 性能优化:如何避免不必要的重新渲染
前端·javascript·面试