JS 防抖与节流
Category(分类): JavaScript Status: 已整理(2026)
本文保留原文的防抖、节流、重绘/回流、URL、DNS、TCP 和浏览器渲染主线,并把页面抓取产生的代码块格式标记、旧地址和粘连代码清理。防抖与节流的示例补充了
this、参数、cancel、flush、leading、trailing、maxWait、requestAnimationFrame和异步请求竞态;网络与渲染部分按现代 HTTP、HTTPS、HTTP/2、HTTP/3 和浏览器实现重新表述。
原文:JS 防抖与节流
一、为什么需要防抖和节流
输入联想、窗口调整、滚动、拖拽、鼠标移动等事件可能在很短时间内触发很多次。如果每次事件都执行昂贵的计算、布局读取、网络请求或日志上报,就可能造成:
- 重复请求和服务端压力;
- JavaScript 长任务和主线程卡顿;
- 频繁样式计算、布局和绘制;
- 用户输入还没稳定就不断展示中间结果。
防抖和节流都是调度策略,不是性能优化的万能开关。它们不能替代减少计算量、取消过期请求、避免布局抖动或使用虚拟列表。
二、防抖(debounce)
2.1 概念
防抖把连续触发的一段调用合并成一次:只有在最后一次调用之后持续等待指定时间,才执行函数。
调用: | | | | | | |
执行: * *
wait wait
适合:
- 搜索框输入停止后发起联想请求;
- 表单输入结束后校验;
- 窗口 resize 结束后重新计算布局;
- 自动保存或上报合并。
不适合:
- 需要持续反馈的拖拽、滚动动画;
- 不能丢掉中间状态的实时数据流;
- 只想限制最大频率的场景,此时应考虑节流。
2.2 基础防抖实现
function debounce(fn, wait = 250) {
let timerId = null
return function debounced(...args) {
const context = this
clearTimeout(timerId)
timerId = setTimeout(() => {
fn.apply(context, args)
}, wait)
}
}
const searchLater = debounce(value => {
console.log('search:', value)
}, 300)
searchLater('j')
searchLater('js')
searchLater('javascript') // 通常只在最后一次调用后执行
原文使用 fn.call(this, arguments),这会把整个 arguments 对象作为一个参数传入。现代写法使用 rest 参数收集参数,再用 apply 或展开语法转发:
const debounced = debounce(function (first, second) {
console.log(this.name, first, second)
}, 100)
debounced.call({ name: 'Ada' }, 1, 2)
2.3 leading、trailing、maxWait、cancel 和 flush
实际项目常需要控制第一次和最后一次是否执行,以及连续触发时的最长等待时间:
function debounce(
fn,
wait = 250,
{ leading = false, trailing = true, maxWait = Infinity } = {}
) {
let timerId = null
let lastArgs
let lastThis
let lastCallTime = 0
let lastInvokeTime = 0
let result
function invoke(time) {
const args = lastArgs
const context = lastThis
lastArgs = undefined
lastThis = undefined
lastInvokeTime = time
result = fn.apply(context, args)
return result
}
function shouldInvoke(time) {
if (lastCallTime === 0) return true
const sinceLastCall = time - lastCallTime
const sinceLastInvoke = time - lastInvokeTime
return sinceLastCall >= wait ||
sinceLastCall < 0 ||
(maxWait !== Infinity && sinceLastInvoke >= maxWait)
}
function trailingEdge(time) {
timerId = null
if (trailing && lastArgs) return invoke(time)
lastArgs = undefined
lastThis = undefined
return result
}
function timerExpired() {
const time = Date.now()
if (shouldInvoke(time)) {
return trailingEdge(time)
}
const remaining = wait - (time - lastCallTime)
timerId = setTimeout(timerExpired, remaining)
}
function debounced(...args) {
const time = Date.now()
const isInvoking = shouldInvoke(time)
lastArgs = args
lastThis = this
lastCallTime = time
if (!timerId) {
if (isInvoking && leading) {
invoke(time)
} else if (!leading) {
// 第一轮调用也应作为 maxWait 的起点,不能拿 epoch 时间 0 比较。
lastInvokeTime = time
}
timerId = setTimeout(timerExpired, wait)
} else if (isInvoking && maxWait !== Infinity) {
clearTimeout(timerId)
timerId = setTimeout(timerExpired, wait)
invoke(time)
}
return result
}
debounced.cancel = function cancel() {
if (timerId !== null) clearTimeout(timerId)
timerId = null
lastArgs = undefined
lastThis = undefined
lastCallTime = 0
lastInvokeTime = 0
}
debounced.flush = function flush() {
if (timerId === null) return result
return trailingEdge(Date.now())
}
return debounced
}
const saveDraft = debounce(
value => console.log('save:', value),
500,
{ trailing: true, maxWait: 3000 }
)
saveDraft('draft 1')
saveDraft('draft 2')
// saveDraft.flush() // 立即执行待处理的最后一次调用
// saveDraft.cancel() // 取消待处理调用
参数语义:
leading: true:一轮调用开始时立即执行;trailing: true:等待结束时执行最后一次调用,通常默认开启;maxWait:即使一直有新调用,也不允许无限推迟;cancel():清理定时器并丢弃待执行调用;flush():立即执行当前待执行的尾调用。
生产环境可以使用经过测试的库实现,例如 lodash 的 debounce/throttle,不要只复制一个未覆盖边界的面试版本。
三、节流(throttle)
3.1 概念
节流限制一段时间内的最大执行频率:无论事件触发多少次,都按时间窗口最多执行一次。
调用: | | | | | | | | | |
执行: * * *
wait wait
适合:
- 滚动监听和可见区域计算;
- 拖拽、鼠标移动中的持续反馈;
- 按固定频率上报位置或进度;
- 受控的 resize 计算。
提交按钮防重复点击也可以使用节流,但更可靠的方式是提交后禁用按钮、使用请求状态和服务端幂等键。节流本身不是防重复提交的安全保证。
3.2 带首尾控制的节流实现
function throttle(
fn,
wait = 100,
{ leading = true, trailing = true } = {}
) {
let timerId = null
let lastInvokeTime = 0
let lastArgs
let lastThis
let result
function invoke(time) {
lastInvokeTime = time
result = fn.apply(lastThis, lastArgs)
lastArgs = undefined
lastThis = undefined
return result
}
function later() {
const time = Date.now()
timerId = null
if (trailing && lastArgs) {
invoke(time)
} else if (!leading) {
lastInvokeTime = 0
}
}
function throttled(...args) {
const time = Date.now()
if (!lastInvokeTime && !leading) lastInvokeTime = time
const remaining = wait - (time - lastInvokeTime)
lastArgs = args
lastThis = this
if (!leading && !trailing) {
lastArgs = undefined
lastThis = undefined
return result
}
if (remaining <= 0 || remaining > wait) {
if (timerId !== null) {
clearTimeout(timerId)
timerId = null
}
invoke(time)
} else if (timerId === null && trailing) {
timerId = setTimeout(later, remaining)
}
return result
}
throttled.cancel = function cancel() {
if (timerId !== null) clearTimeout(timerId)
timerId = null
lastInvokeTime = 0
lastArgs = undefined
lastThis = undefined
}
throttled.flush = function flush() {
if (timerId === null) return result
clearTimeout(timerId)
later()
return result
}
return throttled
}
const onScroll = throttle(() => {
console.log(window.scrollY)
}, 100)
window.addEventListener('scroll', onScroll, { passive: true })
passive: true 告诉浏览器监听器不会调用 preventDefault(),有助于触摸和滚动场景的调度。如果监听器必须阻止默认滚动行为,就不能把它声明为 passive。
3.3 用 requestAnimationFrame 合并视觉更新
如果目标是每个渲染帧最多更新一次 DOM,requestAnimationFrame 通常比固定毫秒节流更贴合浏览器刷新节奏:
function frameThrottle(fn) {
let frameId = null
let lastArgs
let lastThis
function run() {
frameId = null
const args = lastArgs
const context = lastThis
lastArgs = undefined
lastThis = undefined
fn.apply(context, args)
}
function throttled(...args) {
lastArgs = args
lastThis = this
if (frameId === null) frameId = requestAnimationFrame(run)
}
throttled.cancel = function cancel() {
if (frameId !== null) cancelAnimationFrame(frameId)
frameId = null
lastArgs = undefined
lastThis = undefined
}
return throttled
}
const updatePosition = frameThrottle(event => {
// 只在下一帧使用最后一次事件更新视觉状态
console.log(event.clientX, event.clientY)
})
它仍然需要配合事件委托、减少布局读取和避免大范围 DOM 操作;requestAnimationFrame 不是让昂贵函数自动变快。
3.4 防抖/节流与异步请求
防抖只负责调用时机,不会自动取消已经发出的请求,也不会保证响应按输入顺序返回。可以配合 AbortController 和请求序号:
let controller = null
let requestSerial = 0
const search = debounce(async function search(keyword) {
controller?.abort()
controller = new AbortController()
const serial = ++requestSerial
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
signal: controller.signal
})
const data = await response.json()
if (serial === requestSerial) {
renderSuggestions(data)
}
} catch (error) {
if (error.name !== 'AbortError') {
reportSearchError(error)
}
}
}, 300)
function reportSearchError(error) {
console.error('search failed', error)
}
function renderSuggestions(data) {
console.log(data)
}
取消请求是资源管理,序号检查是结果顺序保护;二者解决的问题不同。
四、原文动画和图示
防抖与节流的历史演示图已下载到本地:

图片来源:原始图片。

图片来源:原始图片。
五、重绘、回流与布局抖动
5.1 术语说明
“回流(reflow)”是社区常用词,现代浏览器内部通常谈 style recalculation、layout、paint 和 compositing 等阶段。不同引擎实现不完全相同,但可以用下面的开发者模型理解:
- 样式计算:根据 DOM、CSS 和继承关系计算样式;
- 布局(layout):计算元素的几何位置和尺寸,旧资料常称回流/重排;
- 绘制(paint):把文字、边框、背景等绘制成图形;
- 合成(composite):把不同图层合成为最终画面。
改变颜色可能只需要重新绘制;改变宽高、字体、内容或 DOM 结构通常会触发布局,并可能继续绘制和合成。但“任何回流必定触发重绘”“重绘一定比布局便宜”是过度绝对化的说法,实际成本取决于元素、图层、浏览器和页面规模。

图片来源:原始图片。
5.2 典型布局抖动
在同一个循环中交替写入样式、读取布局信息,浏览器可能被迫频繁同步计算布局:
for (const item of document.querySelectorAll('.item')) {
item.style.width = `${item.offsetWidth + 10}px` // 读布局后写布局
}
更好的方式是先读取,再统一写入,或使用 CSS class:
const items = [...document.querySelectorAll('.item')]
const widths = items.map(item => item.offsetWidth)
items.forEach((item, index) => {
item.style.width = `${widths[index] + 10}px`
})
实践建议:
- 尽量批量修改 class 或通过一次 style 更新完成变更;
- 先集中读取
offsetWidth、getBoundingClientRect()等布局信息,再集中写入; - 使用
DocumentFragment、一次性插入 DOM 或框架批量更新; - 对动画优先使用
transform和opacity,但仍需用 Performance 面板验证; - 对滚动监听使用
passive、节流或requestAnimationFrame,更复杂的可见性判断优先考虑IntersectionObserver; - 通过浏览器 Performance、Rendering 和 Layers 工具确认真正的瓶颈,不要只凭属性名称判断成本。
六、从 URL 到页面的现代网络过程
“输入 URL 后必然经过 DNS、TCP 三次握手、HTTP 请求、TCP 四次挥手、渲染”是适合入门的历史简化图,但不应当当作每次请求的固定流程:缓存、连接复用、代理、Service Worker、HTTP/2、HTTP/3 和预连接都会改变路径。

图片来源:原始图片。
一个更现实的高层过程是:
- 浏览器解析 URL,确定协议、主机、端口、路径和查询参数;
- 检查浏览器缓存、Service Worker、HTTP 缓存和已有连接;
- 必要时执行代理解析、DNS 查询或读取本地缓存;
- 对 HTTPS 连接执行 TLS 握手;
- 复用或建立 HTTP/1.1、HTTP/2 或 HTTP/3 连接;
- 发送请求并接收响应,处理重定向、缓存、压缩和响应头;
- HTML、CSS、JavaScript、图片和字体等资源继续形成依赖请求;
- 浏览器解析并渲染文档,页面还会继续加载懒资源和执行脚本。
HTTP/3 使用 QUIC(基于 UDP),不经过传统 TCP 三次握手;HTTP/2/HTTP/1.1 可能复用已有 TCP/TLS 连接。因此“每个 URL 都经历一次 TCP 三次握手和四次挥手”是不正确的。
七、DNS 域名解析
DNS 将域名映射到 DNS 记录,例如 A/AAAA 记录返回 IPv4/IPv6 地址。实际解析通常会经过多个缓存层:
- 浏览器或应用缓存;
- 操作系统缓存和 hosts 文件;
- 本地递归解析器缓存;
- 根 DNS、顶级域(TLD)DNS 和权威 DNS;
- 企业代理、运营商或公共 DNS 服务的缓存。

图片来源:原始图片。
简化的递归解析过程:
- 客户端向配置的递归解析器发起请求;
- 如果递归解析器没有有效缓存,它可能向根服务器询问对应 TLD 的服务器;
- 再向 TLD 服务器询问域名的权威服务器;
- 向权威服务器查询 A、AAAA、CNAME 等记录;
- 递归解析器根据 TTL 缓存结果并返回客户端。
这里的 TTL 是 DNS 记录的缓存建议时间,不是“用户本地一定缓存这么久”的绝对保证。DNS 也可能返回多个地址,浏览器和操作系统还会进行地址选择、连接竞速和失败重试。
八、TCP、TLS 与连接建立
8.1 TCP 三次握手
对传统 TCP 连接,可简化为:
- 客户端发送
SYN,携带初始序列号x,进入SYN-SENT; - 服务端发送
SYN + ACK,确认号为x + 1,并携带自己的序列号y,进入SYN-RECEIVED; - 客户端发送
ACK,确认号为y + 1,双方进入可传输数据的状态。

图片来源:原始图片。
真实 TCP 还涉及重传、拥塞控制、窗口、SYN cookies、防火墙和连接状态,不能只靠三行报文判断整个网络质量。
8.2 TCP 四次挥手
TCP 两个方向可以独立关闭,因此常见的有序关闭过程是:
- 主动关闭方发送
FIN,表示自己不再发送数据; - 对方发送
ACK,进入半关闭状态,仍可发送剩余数据; - 对方发送自己的
FIN; - 主动关闭方发送
ACK并进入TIME-WAIT,等待足够时间避免旧报文影响后续连接。

图片来源:原始图片。
HTTP/1.1 的持久连接、HTTP/2 多路复用、连接池和 TLS 会让一个页面的多个资源共享连接,因此观察浏览器 Network 面板时不应把每个资源都对应到一套握手/挥手。
8.3 HTTPS 和 HTTP/3
HTTPS 通常在 TCP 之上增加 TLS 握手,用于协商加密参数、验证证书和建立会话密钥。HTTP/2 支持多路复用,HTTP/3 则运行在 QUIC 之上;这些协议都让“URL → TCP → HTTP → 关闭 TCP”的老流程不再适用于所有情况。
九、浏览器渲染页面
现代浏览器的实现细节很多,下面是用于定位性能问题的简化路径:
- 解析 HTML,逐步构建 DOM;
- 解析 CSS,构建 CSSOM 和样式规则;
- DOM、CSSOM、脚本和资源状态共同影响可渲染内容;
- 计算样式并进行布局;
- 生成绘制指令,绘制背景、文字、边框和图片;
- 根据图层和合成策略提交到屏幕。
JavaScript 可以通过 DOM/CSSOM API 修改文档,因此脚本的执行位置、样式表加载、同步脚本、字体和图片都会影响首次渲染。HTML parser 遇到普通同步 script 时可能暂停解析并等待脚本执行;defer、async、模块和预加载会改变资源调度。

图片来源:原始图片。
可以用下面的模型理解样式修改:
const panel = document.querySelector('.panel')
if (panel) {
panel.classList.add('active') // 可能触发样式重新计算和后续布局/绘制
panel.style.transform = 'translateX(10px)' // 常用于合成动画,但仍需验证实际图层
}
不要把“GPU 加速”当作任意元素加 transform 就一定更快;创建过多图层会增加内存和合成成本。最终应使用真实设备和 Performance 面板验证。
十、场景选择表
| 场景 | 选择 | 说明 |
|---|---|---|
| 输入停止后搜索 | 防抖 | 减少请求;配合取消和结果序号 |
| 滚动中持续计算 | 节流或 requestAnimationFrame | 按时间或帧率限制更新 |
| resize 结束后重算 | 防抖 | 只关心最终尺寸时使用 |
| 拖拽视觉反馈 | requestAnimationFrame 节流 | 让更新贴近绘制帧 |
| 提交按钮防重复 | 请求状态/幂等键 | 节流只能降低频率,不能代替服务端保护 |
| 元素是否进入视口 | IntersectionObserver | 避免手动高频计算滚动位置 |
| 监听元素尺寸 | ResizeObserver | 比手动 resize 监听更直接 |
| 触摸滚动监听 | passive listener | 监听器不调用 preventDefault 时使用 |
十一、总结
- 防抖合并一轮调用,节流限制最大调用频率;
- 要转发参数和
this,使用apply/展开参数,不要把arguments当作唯一参数传入; - 防抖和节流不负责取消网络请求,也不保证异步响应顺序;
- 视觉更新通常优先考虑
requestAnimationFrame,滚动可配合 passive listener; - “回流/重绘”是社区常用简化词,实际还涉及样式计算、布局、绘制和合成;
- URL 访问可能命中缓存、复用连接、经过 TLS、HTTP/2 或 HTTP/3,不存在固定的单一路径;
- DNS 有多级缓存,TCP 四次挥手也不等于每个 HTTP 资源都会单独发生;
- 网络和渲染性能应通过浏览器 DevTools、Performance、Network、Lighthouse 和真实设备验证。