普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月2日首页

防抖与节流:前端性能优化的两大利器

作者 ohyeah
2026年1月2日 13:50

在现代 Web 开发中,用户交互越来越频繁,而每一次交互都可能触发复杂的逻辑处理或网络请求。如果不加以控制,这些高频操作会带来严重的性能问题。为此,防抖(Debounce)节流(Throttle) 成为了前端开发中不可或缺的性能优化手段。

本文将结合一段实际代码和详细注释,深入浅出地讲解防抖与节流的核心思想、实现方式以及适用场景,并重点解析其中的关键逻辑。


一、为什么需要防抖和节流?

设想这样一个场景:用户在搜索框中输入关键词,每按一次键就发起一次 AJAX 请求获取搜索建议。如果用户快速输入“react”,那么会依次触发 rrereareacreact 五次请求。

  • 问题1:请求开销大
    每次请求都需要消耗带宽、服务器资源,甚至可能造成接口限流。
  • 问题2:用户体验差
    如果请求响应慢,旧的请求结果可能会覆盖新的输入内容,导致显示错乱。

因此,我们需要一种机制来减少不必要的执行次数,只保留关键的操作。这就是防抖和节流要解决的问题。

防抖:在一定时间内,只执行最后一次操作。
节流:每隔固定时间,最多执行一次操作。


二、防抖(Debounce)——“只认最后一次”

1. 核心思想

无论执行多少次,只执行最后一次。

就像王者荣耀中的“回城”技能:如果你在回城过程中被攻击,回城会被打断并重新计时。只有当你完整地等待一段时间后,回城才会真正生效。

2. 代码实现与闭包应用

// 高阶函数 参数或者返回值是函数 (返回值是函数 -> 闭包)
function debounce(fn, delay) {
  var id; // 自由变量,闭包保存
  return function(args) {
    if (id) clearTimeout(id); // 清除已有定时器,重新计时
    var that = this; // 保存 this 上下文
    id = setTimeout(function() {
      fn.call(that, args); // 延迟执行原函数 并绑定正确的this和参数
    }, delay);
    // 这样只有最后一次触发后等待delay毫秒后才会真正执行
  };
}

关键点解析:

  • 闭包的作用id 是一个自由变量,被返回的函数所引用,从而在多次调用之间保持状态。这使得每次触发都能访问并清除上一次的定时器。
  • clearTimeout(id) :确保只有最后一次触发后的 delay 时间才会真正执行函数。
  • this 和参数传递:通过 callapply 确保原函数在正确的上下文中执行,并传入正确的参数。

3. 使用示例

const inputb = document.getElementById('debounce');
let debounceAjax = debounce(ajax, 200);
inputb.addEventListener('keyup', function(e) {
  debounceAjax(e.target.value);
});

用户快速输入时,只有停止输入 200ms 后,才会发送最终的完整关键词请求,极大减少了无效请求。


三、节流(Throttle)——“冷却期内不执行,但最后补一次”

1. 核心思想

每隔一定时间,最多执行一次。

但注意:我们实现的是带尾随执行(trailing)的节流,即在冷却期结束后,如果期间有触发,会补一次执行

就像技能有 CD(冷却时间),但如果你在 CD 期间一直按技能,CD 结束后会自动释放一次。

2. 代码实现与“尾随执行”逻辑

function throttle(fn, delay) {
  let last, deferTimer; // last上一次执行事件  deferTimer延迟执行的定时器
  return function() {
    let that = this;  
    let _args = arguments; // 类数组对象 保存所有参数
    let now = +new Date(); // 拿到当前时间戳  +强制类型转换 毫秒数

    if (last && now < last + delay) {
      // 处于冷却期 上次执行时间存在 且当前时间还没到下次允许执行的时间
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function() {
        last = now;
        fn.apply(that, _args);
      }, delay);
    } else {
      // 已过冷却期,立即执行
      last = now;
      fn.apply(that, _args);
    }
  };
}

重点解析 if (last && now < last + delay) 分支:

  • 条件成立含义:已经执行过至少一次(last 存在),且当前时间距离上次执行不足 delay 毫秒 → 正处于冷却期。
  • 但不能忽略这次触发!因为这可能是用户最后一次有效操作(比如完整输入了“react”)。
  • 所以我们设置一个延迟定时器,计划在冷却期结束后执行。
  • clearTimeout(deferTimer) 的作用:用户可能在冷却期内多次触发,但我们只关心最后一次,所以每次都要清除旧的定时器,只保留最新的。

3. 为什么需要“尾随执行”?

核心原因:避免丢失最后一次有效操作。

假设用户想搜 “react”,在 200ms 内快速打完,而节流 delay = 500ms:

  • 简单节流(无尾随)

    • r(0ms)→ 立即执行
    • re(100ms)→ 被忽略
    • rea(150ms)→ 被忽略
    • react(200ms)→ 被忽略
      → 用户停止输入 但永远不会发送'react' 搜索框显示的是r的结果 而不是用户真正想搜的react
  • 带尾随的节流

    • r(0ms)→ 立即执行(last = 0
    • re(100ms)→ 冷却期,设 timer(600ms 执行)
    • rea(150ms)→ 更新 timer(650ms)
    • react(200ms)→ 更新 timer(700ms)
      → 用户停止输入后,在 700ms 自动执行 ajax('react')结果正确

4. 什么时候不需要尾随?

按钮防连点:用户点击“提交”按钮,你希望 2 秒内只能点一次。
这种情况下,不需要在 2 秒后自动再提交一次!此时应使用无尾随的简单节流


四、防抖 vs 节流:如何选择?

特性 防抖(Debounce) 节流(Throttle)
执行时机 停止触发后 delay ms 执行 每隔 delay ms 最多执行一次
是否保证最后一次 ✅ 是 ✅(带尾随时)
典型场景 搜索建议、窗口 resize 滚动加载、鼠标移动、按钮点击(防连点)
类比 回城技能(被打断重计时) 技能 CD(冷却后可再放)
  • 搜索建议 → 用防抖:用户输入是连续的,我们只关心最终结果。
  • 滚动加载 → 用节流:用户持续滚动,我们需要定期检查是否到底部,不能等到停止滚动才加载。

五、总结

防抖和节流虽然都是用于限制函数执行频率,但它们的触发逻辑和适用场景截然不同

  • 防抖强调“只执行最后一次”,适用于用户意图明确、操作连续的场景,如搜索、表单校验。
  • 节流强调“定期执行”,适用于高频但需周期性响应的场景,如滚动、拖拽、游戏帧更新。

而我们在实现节流时,特别加入了尾随执行(trailing) 机制,这是为了兼顾性能与用户体验——既避免了过度请求,又确保不会丢失用户的最终操作。

正如注释中所说:
“核心原因:避免丢失最后一次有效操作。”

通过合理运用闭包、定时器和上下文绑定,我们不仅实现了功能,还保证了代码的健壮性和可复用性。这些技巧,正是前端工程师在性能优化道路上的必备武器。


小提示:在实际项目中,Lodash 等工具库已提供了成熟的 debouncethrottle 实现,支持更多选项(如 leadingtrailing 开关)。但理解其底层原理,才能在复杂场景中灵活应对。

希望本文能帮助你更清晰地掌握防抖与节流的本质。欢迎在评论区分享你的使用经验!

昨天以前首页

深入理解 React 中的 useRef:不只是获取 DOM 元素

作者 ohyeah
2026年1月1日 20:05

在 React 函数组件的世界中,useRef 是一个常被提及但又容易被误解的 Hook。很多初学者第一次接触它时,往往只把它当作“获取 DOM 元素”的工具;而随着使用深入,又可能会疑惑:为什么不能用 useRef 替代 useState 来避免不必要的重渲染?本文将结合两个典型示例,系统梳理 useRef 的核心机制、使用场景与常见误区,帮助你真正掌握这个“默默奉献”的 Hook。


一、useRef 的基本能力:持久化引用对象

useRef 的本质是创建一个可变且持久化的引用对象。它的返回值是一个普通 JavaScript 对象,结构为 { current: initialValue }。关键在于:

  • 每次组件重新渲染时,useRef 返回的是同一个对象引用
  • 修改 .current 属性不会触发组件重新渲染
  • 它不参与 React 的响应式更新机制,因此被称为“非响应式存储”。

这与 useState 形成鲜明对比:useState 的状态变更会触发 UI 更新,而 useRef 的变更则“静默”发生。


二、场景一:获取 DOM 元素并操作

最常见的 useRef 用法是绑定到 JSX 元素上,从而在组件逻辑中直接操作 DOM。

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

export default function App() {
  const [count, setCount] = useState(0);
  const inputRef = useRef(null);

  useEffect(() => {
    // 组件挂载后,inputRef.current 指向真实的 <input> 元素
    inputRef.current.focus();
  }, []);

  return (
    <>
      <input ref={inputRef} />
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

在这个例子中:

  • 初始渲染时,inputRef.currentnull,因为 DOM 尚未生成;
  • React 在完成 DOM 挂载后,会自动将对应的 DOM 节点赋值给 ref.current
  • useEffect(依赖项为空数组)在首次挂载后执行,此时 inputRef.current 已是有效的 <input> 元素,调用 .focus() 实现自动聚焦。

值得注意的是:即使 inputRef.currentnull 变为 DOM 节点,组件也不会重新渲染。这正是 useRef 的设计初衷——提供一种不干扰 React 渲染流程的方式来访问或存储数据。


三、场景二:存储可变值以避免状态重置

除了操作 DOM,useRef 还非常适合用于在多次渲染之间持久化存储可变值,尤其是在处理副作用(如定时器)时。

考虑以下错误写法:

// ❌ 错误:使用普通变量存储定时器 ID
let intervalId = null;

function start() {
  intervalId = setInterval(() => {
    console.log('tick~~~');
  }, 1000);
}

function stop() {
  clearInterval(intervalId); // 可能为 null!
}

问题在于:每当 count 状态更新,整个函数组件会重新执行,let intervalId = null 会被再次初始化,导致之前保存的定时器 ID 丢失。结果是:

  • 多次点击“开始”会创建多个定时器;
  • “停止”按钮无法清除旧的定时器;
  • 造成内存泄漏逻辑混乱

正确的做法是使用 useRef

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

export default function App() {
  const intervalId = useRef(null);
  const [count, setCount] = useState(0);

  function start() {
    intervalId.current = setInterval(() => {
      console.log('tick~~~');
    }, 1000);
  }

  function stop() {
    clearInterval(intervalId.current);
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      {count}
      <button onClick={() => setCount(count + 1)}>count ++</button>
    </>
  );
}

这里的关键在于:

  • useRef(null) 在组件整个生命周期内始终返回同一个对象
  • 即使 count 更新导致组件重新渲染,intervalId.current 依然保留着上次设置的定时器 ID;
  • 因此 clearInterval 能正确清除目标定时器,避免资源泄露。

四、useRef 与 useState 的核心区别

虽然 useRefuseState 都能“存值”,但它们的设计目标截然不同:

特性 useState useRef
是否触发重渲染 ✅ 是 ❌ 否
值变更是否被 React 跟踪 ✅ 是(通过调度更新) ❌ 否
适用场景 驱动 UI 变化的状态 存储不需触发 UI 更新的可变数据
初始值 每次调用 useState(initial) 仅在首次生效 useRef(initial) 的初始值也仅在首次生效,但后续 .current 可任意修改

常见误区:能否用 useRef 替代 useState?

不能。

假设你试图用 useRef 存储计数器值以“避免重渲染”:

const countRef = useRef(0);
// ...
<button onClick={() => countRef.current++}>+1</button>
<p>{countRef.current}</p>

你会发现:点击按钮后,页面上的数字不会更新!因为 React 并不知道 countRef.current 发生了变化,自然不会重新执行渲染逻辑。

结论

  • 如果你需要同时存储值并更新 UI,必须使用 useState
  • 如果你只需要在不触发重渲染的前提下保存中间状态或引用(如定时器 ID、前一次的 props、滚动位置等),才应使用 useRef

五、useRef 的典型应用场景总结

  1. 访问 DOM 节点
    如聚焦输入框、测量元素尺寸、触发动画等。

  2. 持久化存储可变值

    • 定时器/延时器 ID(setInterval / setTimeout
    • WebSocket 实例
    • 第三方库的实例(如地图、图表对象)
    • 上一次的 props 或 state(用于对比)
  3. 跨渲染保持状态而不触发更新
    例如记录组件是否已挂载(在异步回调中判断是否还能安全 setState)。


结语

useRef 虽然名字里有 “ref”,但它远不止是“获取 DOM 的工具”。它是一个轻量级、非响应式的持久化容器,在需要“记住某些东西但又不想打扰 React 渲染流程”时大显身手。

正确使用 useRef,能让你的组件更高效、更健壮;而误用它(如试图替代状态管理),则会导致 UI 不更新或逻辑错乱。理解其“非响应式”和“引用持久化”的两大特性,是掌握这一 Hook 的关键。

在实际开发中,当你遇到以下情况时,不妨想想 useRef

  • “我需要在组件里保存一个值,但它变了不需要刷新页面。”
  • “我想在挂载后操作某个 DOM 元素。”
  • “我的定时器怎么关不掉了?是不是 ID 丢了?”

答案,往往就在 useRef 之中。

❌
❌