技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

JS 防抖与节流

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/43-JS 防抖与节流
本文目录12 个章节
  1. 一、为什么需要防抖和节流
  2. 二、防抖(debounce)
  3. 三、节流(throttle)
  4. 四、原文动画和图示
  5. 五、重绘、回流与布局抖动
  6. 六、从 URL 到页面的现代网络过程
  7. 七、DNS 域名解析
  8. 八、TCP、TLS 与连接建立
  9. 九、浏览器渲染页面
  10. 十、场景选择表
  11. 十一、总结
  12. 参考资料

JS 防抖与节流

Category(分类): JavaScript Status: 已整理(2026)

本文保留原文的防抖、节流、重绘/回流、URL、DNS、TCP 和浏览器渲染主线,并把页面抓取产生的代码块格式标记、旧地址和粘连代码清理。防抖与节流的示例补充了 this、参数、cancelflushleadingtrailingmaxWaitrequestAnimationFrame 和异步请求竞态;网络与渲染部分按现代 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 leadingtrailingmaxWaitcancelflush

实际项目常需要控制第一次和最后一次是否执行,以及连续触发时的最长等待时间:

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`
})

实践建议:

  1. 尽量批量修改 class 或通过一次 style 更新完成变更;
  2. 先集中读取 offsetWidthgetBoundingClientRect() 等布局信息,再集中写入;
  3. 使用 DocumentFragment、一次性插入 DOM 或框架批量更新;
  4. 对动画优先使用 transformopacity,但仍需用 Performance 面板验证;
  5. 对滚动监听使用 passive、节流或 requestAnimationFrame,更复杂的可见性判断优先考虑 IntersectionObserver
  6. 通过浏览器 Performance、Rendering 和 Layers 工具确认真正的瓶颈,不要只凭属性名称判断成本。

六、从 URL 到页面的现代网络过程

“输入 URL 后必然经过 DNS、TCP 三次握手、HTTP 请求、TCP 四次挥手、渲染”是适合入门的历史简化图,但不应当当作每次请求的固定流程:缓存、连接复用、代理、Service Worker、HTTP/2、HTTP/3 和预连接都会改变路径。

从 URL 到页面的历史流程图

图片来源:原始图片

一个更现实的高层过程是:

  1. 浏览器解析 URL,确定协议、主机、端口、路径和查询参数;
  2. 检查浏览器缓存、Service Worker、HTTP 缓存和已有连接;
  3. 必要时执行代理解析、DNS 查询或读取本地缓存;
  4. 对 HTTPS 连接执行 TLS 握手;
  5. 复用或建立 HTTP/1.1、HTTP/2 或 HTTP/3 连接;
  6. 发送请求并接收响应,处理重定向、缓存、压缩和响应头;
  7. HTML、CSS、JavaScript、图片和字体等资源继续形成依赖请求;
  8. 浏览器解析并渲染文档,页面还会继续加载懒资源和执行脚本。

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 服务的缓存。

DNS 历史解析流程图

图片来源:原始图片

简化的递归解析过程:

  1. 客户端向配置的递归解析器发起请求;
  2. 如果递归解析器没有有效缓存,它可能向根服务器询问对应 TLD 的服务器;
  3. 再向 TLD 服务器询问域名的权威服务器;
  4. 向权威服务器查询 A、AAAA、CNAME 等记录;
  5. 递归解析器根据 TTL 缓存结果并返回客户端。

这里的 TTL 是 DNS 记录的缓存建议时间,不是“用户本地一定缓存这么久”的绝对保证。DNS 也可能返回多个地址,浏览器和操作系统还会进行地址选择、连接竞速和失败重试。

八、TCP、TLS 与连接建立

8.1 TCP 三次握手

对传统 TCP 连接,可简化为:

  1. 客户端发送 SYN,携带初始序列号 x,进入 SYN-SENT
  2. 服务端发送 SYN + ACK,确认号为 x + 1,并携带自己的序列号 y,进入 SYN-RECEIVED
  3. 客户端发送 ACK,确认号为 y + 1,双方进入可传输数据的状态。

TCP 三次握手历史图示

图片来源:原始图片

真实 TCP 还涉及重传、拥塞控制、窗口、SYN cookies、防火墙和连接状态,不能只靠三行报文判断整个网络质量。

8.2 TCP 四次挥手

TCP 两个方向可以独立关闭,因此常见的有序关闭过程是:

  1. 主动关闭方发送 FIN,表示自己不再发送数据;
  2. 对方发送 ACK,进入半关闭状态,仍可发送剩余数据;
  3. 对方发送自己的 FIN
  4. 主动关闭方发送 ACK 并进入 TIME-WAIT,等待足够时间避免旧报文影响后续连接。

TCP 四次挥手历史图示

图片来源:原始图片

HTTP/1.1 的持久连接、HTTP/2 多路复用、连接池和 TLS 会让一个页面的多个资源共享连接,因此观察浏览器 Network 面板时不应把每个资源都对应到一套握手/挥手。

8.3 HTTPS 和 HTTP/3

HTTPS 通常在 TCP 之上增加 TLS 握手,用于协商加密参数、验证证书和建立会话密钥。HTTP/2 支持多路复用,HTTP/3 则运行在 QUIC 之上;这些协议都让“URL → TCP → HTTP → 关闭 TCP”的老流程不再适用于所有情况。

九、浏览器渲染页面

现代浏览器的实现细节很多,下面是用于定位性能问题的简化路径:

  1. 解析 HTML,逐步构建 DOM;
  2. 解析 CSS,构建 CSSOM 和样式规则;
  3. DOM、CSSOM、脚本和资源状态共同影响可渲染内容;
  4. 计算样式并进行布局;
  5. 生成绘制指令,绘制背景、文字、边框和图片;
  6. 根据图层和合成策略提交到屏幕。

JavaScript 可以通过 DOM/CSSOM API 修改文档,因此脚本的执行位置、样式表加载、同步脚本、字体和图片都会影响首次渲染。HTML parser 遇到普通同步 script 时可能暂停解析并等待脚本执行;deferasync、模块和预加载会改变资源调度。

浏览器渲染历史流程图

图片来源:原始图片

可以用下面的模型理解样式修改:

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 和真实设备验证。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS