跳到主要内容
2026-08-14 · 一段与 batype 的对话(AI) · 由提问者公开

防抖和节流的本质区别?

回答由 AI 基于 batype 的笔记生成,不代表本人实时观点。

防抖和节流的本质区别?

本质区别是:防抖关注“最后一次”,节流关注“固定频率”。

  • 防抖(debounce):事件持续触发就持续重新计时,只有停止触发超过 wait 后才执行一次。它把一连串操作合并成最后一次。【1】
  • 节流(throttle):无论事件触发多频繁,在一个 wait 时间窗口内最多执行一次。它不会无限等待事件停止,而是限制执行上限。【1】【3】

可以这样理解:

维度防抖节流
连续触发时一直不执行、重新计时按固定间隔执行
关注点获取最终结果保持过程中的持续反馈
典型场景搜索联想、表单校验、提交按钮scroll、拖拽、resize、动画更新
高频事件永不停止时可能永远不执行仍会持续按频率执行

例如用户连续输入搜索词:

输入:a → ab → abc → abcd

防抖:停下来 300ms 后,只请求一次 "abcd"
节流:每 300ms 最多请求一次,输入过程中可能请求多次

我通常按业务目标选:

  • “等用户操作结束再做”:选防抖。
  • “操作过程中也要持续响应,但不能太频繁”:选节流。

一个容易忽略的点:防抖和节流都只是控制函数调用频率,不是请求并发控制。搜索接口这类场景,即使用了防抖,仍建议配合 AbortController 取消旧请求,或通过请求序号避免旧响应覆盖新结果。

站内引用
有个场景哈,AI对话框中AI回复问题,此时前端的渲染方式应该使用什么逻辑,怎么渲染

AI 对话的回复,尤其是流式输出(SSE/WebSocket),我建议用:

数据层实时累积 token,视图层用 requestAnimationFrame 按帧批量提交渲染。

不要每收到一个 token 就立刻 setState,也不要用普通防抖。前者可能导致高频 React render 和 Markdown 解析;后者会让用户一直看不到回复,直到流结束才一次性出现。

requestAnimationFrame 本质上是一种适合 UI 高频更新的“按帧节流”:浏览器准备重绘前再执行更新,能把同一帧内收到的多个 token 合并为一次渲染【1】。


推荐的整体流程

服务端 SSE / WebSocket 推送 token
        ↓
写入内存 buffer(立即,不丢内容)
        ↓
若当前没有 rAF 任务,则 requestAnimationFrame
        ↓
下一帧将 buffer 一次性写入 React state
        ↓
当前 AI 消息组件重新渲染
        ↓
流结束时强制 flush 剩余 buffer,并标记 done

关键是区分两个东西:

  1. 完整文本:必须实时累积,保证数据正确;
  2. React state / DOM 更新:不必每个 token 都更新,一帧一次足够。

React 示例

下面是一个简化但可直接用的 Hook:

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

export function useStreamMessage() {
  const [content, setContent] = useState('');

  // 保存完整内容,不依赖 React render
  const contentRef = useRef('');
  const frameIdRef = useRef<number | null>(null);

  const flush = useCallback(() => {
    frameIdRef.current = null;
    setContent(contentRef.current);
  }, []);

  // 每收到一段流式文本时调用
  const append = useCallback(
    (chunk: string) => {
      contentRef.current += chunk;

      // 同一帧内只安排一次 React 更新
      if (frameIdRef.current === null) {
        frameIdRef.current = requestAnimationFrame(flush);
      }
    },
    [flush],
  );

  // 流结束:立即同步最后残留内容
  const finish = useCallback(() => {
    if (frameIdRef.current !== null) {
      cancelAnimationFrame(frameIdRef.current);
      frameIdRef.current = null;
    }

    setContent(contentRef.current);
  }, []);

  const reset = useCallback(() => {
    if (frameIdRef.current !== null) {
      cancelAnimationFrame(frameIdRef.current);
      frameIdRef.current = null;
    }

    contentRef.current = '';
    setContent('');
  }, []);

  useEffect(() => {
    return () => {
      if (frameIdRef.current !== null) {
        cancelAnimationFrame(frameIdRef.current);
      }
    };
  }, []);

  return {
    content,
    append,
    finish,
    reset,
  };
}

组件中使用:

function AssistantMessage() {
  const { content, append, finish } = useStreamMessage();

  async function askAI(question: string) {
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ question }),
    });

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    if (!reader) return;

    while (true) {
      const { value, done } = await reader.read();

      if (done) {
        finish();
        break;
      }

      append(decoder.decode(value, { stream: true }));
    }
  }

  return (
    <div>
      <button onClick={() => askAI('解释一下 Fiber')}>
        发送
      </button>

      <article>{content}</article>
    </div>
  );
}

为什么不用防抖

假设模型持续每 20ms 推一个 token,而你使用 debounce(fn, 300)

token 持续到达
→ 计时器持续被重置
→ 300ms 内没停过
→ fn 一直不执行
→ 用户看不到逐字输出

这和聊天产品的预期相反。

防抖适合“输入停止后才处理”的事,例如搜索建议请求、表单校验;AI 回复是一个持续进行的过程,需要持续反馈。


为什么不直接用固定 100ms 节流

也能用,很多项目确实这样做:

const updateView = throttle(() => {
  setContent(contentRef.current);
}, 100);

但我更倾向于 UI 文本流优先用 requestAnimationFrame

  • 它和浏览器重绘周期对齐,在下一次绘制前执行【1】;
  • 同一帧的多个 token 自动合并;
  • 页面不可见时通常会被降频或暂停,避免后台标签页无意义地更新【1】;
  • 不需要武断地选 50ms、100ms 这样的时间间隔。

setTimeout 更适合一般延时任务;涉及连续视觉更新时,requestAnimationFrame 更合适【1】。


长回复还要处理 Markdown

真正容易卡的往往不是 setState,而是每帧都重新解析整段 Markdown

例如回复已经有几万字,每来一点内容都执行:

<ReactMarkdown>{content}</ReactMarkdown>

解析成本会越来越高。我的建议是分层处理:

短回复:
  rAF 更新文本 + 直接 Markdown 渲染,通常足够。

长回复 / 代码块很多:
  1. 正在流式生成的最后一条消息,降低 Markdown 解析频率;
  2. 流结束后再做完整 Markdown、代码高亮、数学公式等重渲染;
  3. 历史消息使用 React.memo;
  4. 消息量很大时使用虚拟列表。

一个实用做法是:流式过程中先渲染纯文本或轻量 Markdown;收到 done 后再启用完整的代码高亮、Mermaid、LaTex 等重处理。


自动滚动也不能无脑执行

每一帧都 scrollTo 会影响用户阅读历史消息。应当仅在用户本来就在底部附近时自动跟随:

function isNearBottom(container: HTMLElement, threshold = 80) {
  const distance =
    container.scrollHeight - container.scrollTop - container.clientHeight;

  return distance < threshold;
}
useEffect(() => {
  const el = messageListRef.current;
  if (!el || !isNearBottom(el)) return;

  el.scrollTop = el.scrollHeight;
}, [content]);

更完整的产品逻辑是:

  • 用户停留在底部:AI 输出时自动滚动;
  • 用户手动上滑看历史:停止自动滚动;
  • 有新内容但未跟随:显示“回到底部 / 有新消息”的按钮。

最终选择

任务建议
接收 token立即写入 ref / buffer
更新流式消息 UIrequestAnimationFrame 批量更新
流结束立即 flush 最后内容
搜索框发请求防抖
限制接口请求频率节流、队列或并发控制
Markdown/代码高亮流式期间轻量处理,结束后完整处理
聊天记录很多React.memo + 虚拟列表
自动滚动仅用户位于底部时跟随

一句话概括:AI 流式回复不是“防抖后渲染”,而是“内容实时收集、渲染按帧合并、结束时立即落盘”。

站内引用