React 防抖与节流:在高频 UI 交互中结合 requestAnimationFrame 的性能优化方案

各位同学,大家好!

欢迎来到今天这场关于“如何在 React 中拯救性能”的深度讲座。我是你们的讲师,一名在代码世界里摸爬滚打多年的资深工程师。今天我们不聊那些虚头巴脑的架构模式,也不聊怎么写高内聚低耦合,我们聊点更实在的——如何让你的 UI 像丝般顺滑,而不是像老牛拉破车。

假设你现在正在开发一个电商 App,或者一个复杂的后台管理系统。你有一个包含 1000 个项目的列表,或者一个巨大的图片画廊。当用户疯狂滚动鼠标滚轮,或者疯狂敲击键盘输入搜索词时,你的浏览器开始发烫,页面开始掉帧,甚至出现“卡顿”。

这时候,你是不是想大喊一声:“这破浏览器,是不是该扔了?”

别急,其实不是浏览器的问题,也不是你代码写得烂,而是你们之间缺乏沟通。高频 UI 交互是性能杀手,而防抖节流,以及更高级的 requestAnimationFrame (rAF),就是我们与浏览器沟通的“外交辞令”。

今天,我们就来一场深度剖析,把这三个家伙掰开了、揉碎了,揉进 React 的 Hook 里,让它们成为你代码里的“瑞士军刀”。


第一部分:浏览器渲染的“老鼠赛跑”

在讲防抖和节流之前,我们必须先搞清楚一个核心概念:浏览器到底是怎么工作的?

你可能会说:“它运行 JS,然后画出来。”
太天真了。浏览器的工作其实是一场老鼠赛跑

想象一下,屏幕的刷新率是 60Hz,也就是每秒钟刷新 60 次。这意味着,每 16.6 毫秒(1000ms / 60),浏览器就要给屏幕画一帧新的画面。

这 16.6 毫秒里发生了什么?这是一个非常紧张的时间窗口:

  1. JS 执行:你的 React 组件在疯狂计算,状态在更新,DOM 在重绘。
  2. 样式计算:浏览器计算元素该长什么样。
  3. 布局:浏览器计算元素在屏幕上的位置(重排)。
  4. 绘制:浏览器把颜色填进格子。
  5. 合成:浏览器把所有图层合成为最终的图像。

如果 JS 执行时间超过了 16.6 毫秒,这帧就没法按时画出来。浏览器怎么办?它只能等下一帧。这就导致了掉帧。

高频事件(如 mousemove, scroll, input)的特点是什么?触发频率极高
如果你在 mousemove 事件里写了一段复杂的逻辑,比如计算鼠标位置、更新状态、甚至重新渲染整个组件树,那么在短短几毫秒内,你可能会触发几十次甚至上百次这样的逻辑。结果就是,JS 跑死了,浏览器连画画的空隙都没有,只能眼睁睁看着 FPS(帧率)从 60 掉到 10,最后变成 1。

所以,我们的目标很明确:在保证用户体验的前提下,减少逻辑执行的频率,让 JS 能在 16.6ms 内完成,给浏览器喘息的机会。


第二部分:防抖 —— “电梯里的人”

这时候,防抖登场了。

防抖的核心思想是:“别急,等大家都停下来了再说。”

想象一下,你站在电梯里。电梯门要关了,这时候又有一个人跑过来按了开门键。电梯不会马上开,而是会等这个人进去,然后等他按关门键,电梯才会关。如果这人又进来了,再等。

在代码里,防抖是这样的:
如果你在 1 秒内连续触发了 10 次 input 事件,防抖函数会把你这 10 次触发“吞掉”,或者说是“延迟”处理。它会等待,直到这 1 秒内没有任何新的触发,它才会真正执行你想要的那一次逻辑。

适用场景:

  • 搜索框输入:用户每敲一个字,你就去请求后端 API,这太浪费了。防抖一下,用户打完字停顿 500ms,再请求。
  • 窗口大小调整:用户拖拽浏览器窗口边缘时,不需要每像素都重新计算布局,等停下来了再算。

React 中的防抖实现

在 React 中,我们不能直接在组件外部定义防抖函数,因为我们需要用到组件的状态。所以,我们需要写一个自定义的 Hook:useDebounce

这里有个坑:闭包陷阱
如果你在 useEffect 里直接用 setTimeout,当组件重新渲染时,定时器里的回调函数会捕获旧的 value。这会导致你的防抖失效,或者逻辑跑偏。

解决方法是用 useRef 来保存定时器的 ID,以及保存最新的回调函数。

让我们来写代码:

import { useState, useEffect, useRef } from 'react';

// 这是一个通用的防抖 Hook
const useDebounce = <T extends (...args: any[]) => any>(
  fn: T,
  delay: number
) => {
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  return (...args: Parameters<T>) => {
    // 1. 如果之前有定时器,先把它清除掉
    // 这就是“防抖”的核心:新的触发会覆盖旧的等待
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }

    // 2. 设置一个新的定时器
    timerRef.current = setTimeout(() => {
      fn(...args);
    }, delay);
  };
};

// 组件使用示例
const SearchComponent = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // 注意:这里我们使用 useDebounce 包装了 setResults
  // 只有当输入停止 500ms 后,才会真正执行 setResults
  const debouncedSetResults = useDebounce((newQuery: string) => {
    console.log('正在搜索...', newQuery);
    // 模拟 API 请求
    setResults([`Result for ${newQuery}`, `More results for ${newQuery}`]);
  }, 500);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setQuery(value);
    // 触发防抖后的更新
    debouncedSetResults(value);
  };

  return (
    <div>
      <input type="text" onChange={handleChange} placeholder="Type something..." />
      <ul>
        {results.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
};

看,这个 useDebounce Hook 很简单,但它解决了核心问题。它把“高频触发”转化为了“低频执行”。


第三部分:节流 —— “自动门”

防抖适合“停止后触发”,那节流适合什么呢?

节流的核心思想是:“别急,但我得让你知道我在干活。”

想象一下商场的自动门。它不会因为你站得近就频繁开关,也不会等你完全停下来才开。它的规则是:每隔 1 秒,我检查一下,如果有人,我就开一次。不管你这 1 秒内来了一百个人,还是只来了一半个人,我都只处理一次。

适用场景:

  • 滚动事件:用户疯狂滚动,我们不需要每像素都计算,我们只需要每 100ms 计算一次当前滚动到了哪里。
  • 鼠标移动:虽然 rAF 更好,但节流也是一种简单的优化。

React 中的节流实现

实现节流比防抖稍微复杂一点,因为我们需要记录“上一次执行的时间”。

import { useEffect, useRef } from 'react';

const useThrottle = <T extends (...args: any[]) => any>(
  fn: T,
  delay: number
) => {
  const lastRunRef = useRef(0);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  return (...args: Parameters<T>) => {
    const now = Date.now();
    const lastRun = lastRunRef.current;

    // 如果距离上次执行时间小于 delay,说明还在冷却期
    if (now - lastRun < delay) {
      // 这里有个选择:是忽略,还是强制执行?
      // 简单的节流通常是忽略
      return;
    }

    // 更新最后执行时间
    lastRunRef.current = now;
    fn(...args);
  };
};

// 组件使用示例
const ScrollListener = () => {
  const [scrollTop, setScrollTop] = useState(0);

  // 节流后的函数,每 200ms 最多执行一次
  const throttledSetScrollTop = useThrottle((val: number) => {
    setScrollTop(val);
  }, 200);

  useEffect(() => {
    const handleScroll = () => {
      // 即使滚动很快,这里每 200ms 最多触发一次
      throttledSetScrollTop(window.scrollY);
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, [throttledSetScrollTop]); // 依赖项

  return <div>Current Scroll: {scrollTop}</div>;
};

节流的缺点:
你会发现,如果你在 200ms 内滚动了很多次,中间的那几次触发是被“丢弃”的。用户可能会感觉“我怎么没反应?”或者“系统好像卡住了”。节流牺牲了部分响应性来换取性能。


第四部分:requestAnimationFrame —— 真正的“完美主义”

这时候,你可能会想:“防抖太慢,节流太生硬,有没有一个既能保证高性能,又能保证响应性的方案?”

有,那就是 requestAnimationFrame (rAF)

rAF 是浏览器专门为动画和 UI 交互优化的 API。它的工作原理是:告诉浏览器,“我有一帧要画,你准备好,等我信号。”

rAF 的工作机制非常智能。它会:

  1. 检查屏幕刷新率:如果你在 60Hz 的屏幕上,它就会每 16.6ms 唤醒你一次。
  2. 等待空闲:如果浏览器此时正在处理其他繁重的任务(比如 JS 计算),rAF 会等到浏览器空闲时才执行你的回调。
  3. 帧同步:它确保你的代码在每一帧的“合成”阶段之前执行,这样画出来的东西就是和屏幕刷新同步的。

为什么 rAF 优于 setTimeout 和节流?

  • setTimeout:它的时间是固定的(比如 16ms)。但如果 JS 执行花了 20ms,那么你的回调就会被推迟到下一帧,甚至下一帧,导致画面卡顿。
  • rAF:它不关心时间,它只关心“帧”。它保证你的逻辑在当前帧完成,或者下一帧的最早空闲时间完成。

React 中的 rAF 实现

写一个 useRaf Hook 需要一点技巧。我们需要处理“启动”和“停止”的逻辑。

import { useEffect, useRef } from 'react';

const useRaf = (callback: () => void) => {
  const animationFrameRef = useRef<number>();

  useEffect(() => {
    // 启动 rAF
    const tick = () => {
      callback();
      // 请求下一帧
      animationFrameRef.current = requestAnimationFrame(tick);
    };

    animationFrameRef.current = requestAnimationFrame(tick);

    // 清理函数:组件卸载时取消请求
    return () => {
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
    };
  }, [callback]); // 依赖项:如果 callback 变了,重启循环
};

// 组件使用示例
const ParallaxEffect = () => {
  const [offset, setOffset] = useState(0);

  useRaf(() => {
    // 在这里,我们每一帧都会执行
    // 比如我们可以根据鼠标位置计算偏移量
    // 这里为了演示,我们用一个简单的计数器模拟高频计算
    setOffset(prev => (prev + 1) % 100);

    // 实际场景中,这里会是:
    // const x = window.scrollY * 0.5;
    // setOffset(x);
  });

  return <div style={{ transform: `translateX(${offset}px)` }}>I am moving!</div>;
};

等等,上面的代码有点太简单了。如果我们想在 mousemove 时使用 rAF 怎么办?上面的代码会无限循环,每帧都执行。这会导致性能问题,因为 mousemove 触发频率远高于 60fps(可能每秒几百次)。

所以,我们需要一个结合了节流和 rAF 的版本。这叫 “基于时间的 rAF 节流”

import { useEffect, useRef } from 'react';

const useRafThrottle = (callback: () => void, delay: number = 1000 / 60) => {
  const lastTimeRef = useRef(0);
  const rafIdRef = useRef<number>();

  useEffect(() => {
    const loop = (time: number) => {
      if (time - lastTimeRef.current >= delay) {
        callback();
        lastTimeRef.current = time;
      }
      rafIdRef.current = requestAnimationFrame(loop);
    };

    rafIdRef.current = requestAnimationFrame(loop);

    return () => {
      if (rafIdRef.current) {
        cancelAnimationFrame(rafIdRef.current);
      }
    };
  }, [callback, delay]);
};

这个 useRafThrottle 就是神器。它利用 rAF 的性能优势,但通过时间戳控制了执行频率。它比 setTimeout 更平滑,比普通的 throttle 更精确。


第五部分:实战演练 —— 打造“丝般顺滑”的无限滚动组件

光说不练假把式。我们来做一个实际的场景:无限滚动

需求:一个包含 10000 张图片的列表。用户滚动到底部时,自动加载更多。如果逻辑写不好,滚动会卡成幻灯片。

错误示范(普通防抖/节流):
如果我们只用 throttle 监听 scroll,然后每 100ms 加载一次数据,这会瞬间发出去几百个请求,把后端搞崩。

正确示范(rAF + IntersectionObserver):
我们结合 requestAnimationFrameIntersectionObserver(虽然 IntersectionObserver 是原生 API,但它也是为了性能而生)。

  1. IntersectionObserver:负责检测“底部加载元素”是否进入了视口。这是最高效的检测方式。
  2. rAF:负责在数据加载过程中,平滑地更新 UI。
import { useState, useEffect, useRef } from 'react';

const InfiniteScrollList = () => {
  const [items, setItems] = useState<number[]>([]);
  const [loading, setLoading] = useState(false);
  const loadMoreTriggerRef = useRef<HTMLDivElement>(null);

  // 模拟数据加载
  const loadMoreData = async () => {
    setLoading(true);
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500));
    setItems(prev => [...prev, ...Array(20).fill(0).map((_, i) => prev.length + i)]);
    setLoading(false);
  };

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        // 当触发元素进入视口
        if (entries[0].isIntersecting && !loading) {
          loadMoreData();
        }
      },
      { threshold: 0.1 }
    );

    if (loadMoreTriggerRef.current) {
      observer.observe(loadMoreTriggerRef.current);
    }

    return () => {
      if (loadMoreTriggerRef.current) {
        observer.unobserve(loadMoreTriggerRef.current);
      }
    };
  }, [loading]);

  // 渲染列表
  return (
    <div>
      <ul>
        {items.map((item, index) => (
          <li key={index}>Item {item}</li>
        ))}
      </ul>
      <div ref={loadMoreTriggerRef} style={{ height: '20px', background: loading ? 'red' : 'green' }}>
        {loading ? 'Loading...' : 'End of list'}
      </div>
    </div>
  );
};

这个例子展示了如何利用浏览器原生的能力来优化性能。但在更复杂的场景,比如拖拽排序,rAF 是必不可少的。


第六部分:高级话题 —— 拖拽与 rAF 的绝配

假设我们要做一个拖拽功能,拖拽一个方块,方块要跟随鼠标移动。如果我们在 mousemove 里直接更新 lefttop 样式,你会发现方块有“延迟感”,或者在某些低端机上卡顿。

为什么?因为 mousemove 的触发频率是不稳定的,而且频繁修改 DOM 样式会触发浏览器的重排。

优化方案:

  1. 使用 transform: translate(x, y):这比修改 top/left 性能好得多,因为它只触发重绘,不触发重排。
  2. 使用 useRaf:确保每一帧只更新一次位置。

代码示例:

const DraggableItem = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const isDraggingRef = useRef(false);
  const rafIdRef = useRef<number>();

  const handleMouseDown = (e: React.MouseEvent) => {
    isDraggingRef.current = true;
    // 开始拖拽时,取消之前的 RAF
    if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
  };

  useEffect(() => {
    const handleMouseMove = (e: MouseEvent) => {
      if (!isDraggingRef.current) return;

      // 使用 rAF 来节流更新
      rafIdRef.current = requestAnimationFrame(() => {
        // 计算新位置(相对于父容器)
        const newX = e.clientX; 
        const newY = e.clientY;
        setPosition({ x: newX, y: newY });
      });
    };

    const handleMouseUp = () => {
      isDraggingRef.current = false;
      // 停止拖拽后,清除 RAF
      if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
    };

    if (isDraggingRef.current) {
      window.addEventListener('mousemove', handleMouseMove);
      window.addEventListener('mouseup', handleMouseUp);
    }

    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
      window.removeEventListener('mouseup', handleMouseUp);
      if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
    };
  }, []);

  return (
    <div style={{ position: 'absolute', left: position.x, top: position.y, width: 50, height: 50, background: 'blue' }}>
      Drag Me
    </div>
  );
};

在这个例子中,我们使用了 requestAnimationFrame 来确保拖拽的平滑度。即使 mousemove 触发了一万次,我们的更新逻辑也只会在浏览器准备好渲染下一帧的时候执行一次。这就像给数据流加了一个“过滤器”,只留下最精华的部分。


第七部分:React Hooks 的“深水区”

在前面实现这些 Hook 的时候,我们其实绕过了一些坑。现在,让我们深入一点,聊聊 React 的闭包和依赖。

1. 依赖数组陷阱

useEffect 中,我们的回调函数依赖了 callback

useEffect(() => {
  // ...
}, [callback]);

这意味着,每次 callback 变化(比如你在组件里重写了函数,或者函数引用变了),useEffect 都会重新挂载监听器,并启动新的 requestAnimationFrame 循环,旧的循环会被取消。

2. 闭包导致的状态陈旧

useRafThrottle 中,我们直接使用了 callback

const loop = (time: number) => {
  // 这里的 callback 是闭包捕获的
  if (time - lastTimeRef.current >= delay) {
    callback(); 
    lastTimeRef.current = time;
  }
  // ...
};

如果 callback 里面依赖了组件的 state(比如 const handleClick = () => console.log(count)),那么在 loop 函数里,count 可能永远是旧的值!

如何修复?
我们需要使用 useRef 来保存最新的 callback。

const useRafThrottle = (callback: () => void, delay: number = 1000 / 60) => {
  const lastTimeRef = useRef(0);
  const rafIdRef = useRef<number>();

  // 使用 ref 保存最新的 callback
  const callbackRef = useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    const loop = (time: number) => {
      if (time - lastTimeRef.current >= delay) {
        // 使用 ref.current 调用,确保拿到最新值
        callbackRef.current();
        lastTimeRef.current = time;
      }
      rafIdRef.current = requestAnimationFrame(loop);
    };

    rafIdRef.current = requestAnimationFrame(loop);

    return () => {
      if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
    };
  }, [delay]);
};

这招叫 “Ref 替代 State”,是 React Hooks 性能优化的核心技巧之一。记住它!


第八部分:性能优化的“奥义”与总结

好了,同学们,今天的讲座即将接近尾声。我们回顾一下今天的内容:

  1. 浏览器渲染周期:16.6ms 的生死时速,重排与重绘的代价。
  2. 防抖:适合“停止后触发”,如搜索框。用 setTimeout 实现。
  3. 节流:适合“固定频率触发”,如滚动。用时间戳实现。
  4. requestAnimationFrame:动画和 UI 交互的终极武器。它帧同步,不浪费,比 setTimeout 更平滑。

为什么 rAF 是最佳选择?
因为它是浏览器原生的 API,它知道你的屏幕刷新率。它不会像 setTimeout(16ms) 那样产生抖动,也不会像普通节流那样打断用户的操作流。

React 中的最佳实践:

  • 对于搜索输入,使用 useDebounce
  • 对于滚动监听,使用 useRafThrottle
  • 对于拖拽和动画,必须使用 useRaf
  • 永远记得在组件卸载时清理定时器和 RAF,否则你的组件会变成内存泄漏的僵尸。

最后的忠告:
不要为了优化而优化。如果你的组件只有几个元素,性能开销微乎其微,那就别折腾了。过早的优化是万恶之源。但是,如果你的组件有几百个元素,或者正在处理 3D 图形、大数据可视化,那么请务必使用这些技术。

记住,优秀的代码不仅仅是能跑通,更是要优雅、高效、流畅。当你看到用户在操作你的应用时,流畅得像在飞,而你的浏览器风扇却很安静,那就是你作为程序员的最高荣耀。

好了,今天的讲座就到这里。下课!记得把你们的 useDebounceuseRaf Hook 整理好,以后写项目的时候,直接复制粘贴,让性能飞起来!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注