防抖和节流的本质区别?
回答由 AI 基于 batype 的笔记生成,不代表本人实时观点。
本质区别是:防抖关注“最后一次”,节流关注“固定频率”。
- 防抖(debounce):事件持续触发就持续重新计时,只有停止触发超过
wait后才执行一次。它把一连串操作合并成最后一次。【1】 - 节流(throttle):无论事件触发多频繁,在一个
wait时间窗口内最多执行一次。它不会无限等待事件停止,而是限制执行上限。【1】【3】
可以这样理解:
| 维度 | 防抖 | 节流 |
|---|---|---|
| 连续触发时 | 一直不执行、重新计时 | 按固定间隔执行 |
| 关注点 | 获取最终结果 | 保持过程中的持续反馈 |
| 典型场景 | 搜索联想、表单校验、提交按钮 | scroll、拖拽、resize、动画更新 |
| 高频事件永不停止时 | 可能永远不执行 | 仍会持续按频率执行 |
例如用户连续输入搜索词:
输入:a → ab → abc → abcd
防抖:停下来 300ms 后,只请求一次 "abcd"
节流:每 300ms 最多请求一次,输入过程中可能请求多次
我通常按业务目标选:
- “等用户操作结束再做”:选防抖。
- “操作过程中也要持续响应,但不能太频繁”:选节流。
一个容易忽略的点:防抖和节流都只是控制函数调用频率,不是请求并发控制。搜索接口这类场景,即使用了防抖,仍建议配合 AbortController 取消旧请求,或通过请求序号避免旧响应覆盖新结果。
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
关键是区分两个东西:
- 完整文本:必须实时累积,保证数据正确;
- 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 |
| 更新流式消息 UI | requestAnimationFrame 批量更新 |
| 流结束 | 立即 flush 最后内容 |
| 搜索框发请求 | 防抖 |
| 限制接口请求频率 | 节流、队列或并发控制 |
| Markdown/代码高亮 | 流式期间轻量处理,结束后完整处理 |
| 聊天记录很多 | React.memo + 虚拟列表 |
| 自动滚动 | 仅用户位于底部时跟随 |
一句话概括:AI 流式回复不是“防抖后渲染”,而是“内容实时收集、渲染按帧合并、结束时立即落盘”。