Skip to content

防抖/节流函数important

防抖和节流可视化指南

浏览器中的 scrollresizemousemoveinput 等事件,可能在很短时间内连续触发很多次。如果每次事件触发都立即执行复杂的业务函数,可能造成重复请求、页面卡顿或无意义的重复计算。

防抖和节流都用于限制函数的执行次数,但两者的思路不同:

  • 防抖(debounce):连续触发时重新计时,只在一轮连续操作的第一次或最后一次执行。
  • 节流(throttle):连续触发时按照指定的时间间隔执行,保证一段时间内最多执行一次。
text
防抖:你一直操作,我就一直等;你停下来,我再执行。
节流:你可以一直操作,但我最多每隔一段时间执行一次。

防抖函数

对于高频操作,防抖只识别一轮连续操作中的一次调用。它既可以执行第一次,也可以执行最后一次。

应用场景

  • 搜索框输入后的模糊匹配
  • 表单内容的延迟校验
  • 按钮防止重复点击
  • 页面尺寸变化后的重新计算
  • 轮播图切换

前置场景

页面上有一个可以连续点击的按钮:

html
<button id="btn">点击</button>

直接绑定点击事件:

js
const btn = document.getElementById('btn')

function btnClick(event) {
  console.log('点击了按钮', event)
}

btn.onclick = btnClick

每点击一次,btnClick 就会执行一次,此时没有防抖效果。

无防抖

第一步:返回代理函数

防抖函数不能直接执行 handle,而应该返回一个代理函数,由代理函数决定真正的处理函数是否执行:

js
function debounce(handle) {
  return function proxy() {
    handle()
  }
}

绑定事件时:

js
btn.onclick = debounce(btnClick)

可以理解为:

js
const proxy = debounce(btnClick)
btn.onclick = proxy

以后浏览器调用的是 proxyproxy 再根据防抖规则决定是否调用 handle

第二步:检查参数并设置默认值

handle 必须是一个函数,否则应该立即抛出错误。wait 表示等待时间,immediate 用来控制执行第一次还是最后一次:

js
function debounce(handle, wait = 300, immediate = false) {
  if (typeof handle !== 'function') {
    throw new TypeError('handle must be a function')
  }

  if (typeof wait === 'boolean') {
    immediate = wait
    wait = 300
  }

  if (!Number.isFinite(wait) || wait < 0) {
    wait = 300
  }

  if (typeof immediate !== 'boolean') {
    immediate = false
  }

  // ...
}

默认情况下,debounce(handle) 等价于:

js
debounce(handle, 300, false)

也支持把布尔值作为第二个参数:

js
debounce(handle, true) // 等价于 debounce(handle, 300, true)

原实现只通过 typeof wait === 'number' 判断等待时间,但 NaNInfinity 的类型也是 number。因此这里使用 Number.isFinite(wait),同时通过 wait < 0 排除负数。

第三步:使用闭包保存定时器

为了让多次调用共享同一个定时器,需要把 timer 定义在代理函数外面:

js
function debounce(handle, wait = 300, immediate = false) {
  // 参数处理省略
  let timer = null

  function proxy() {
    timer = setTimeout(() => {
      handle()
    }, wait)
  }

  return proxy
}

debounce 执行结束后,proxy 仍然引用着 timer,因此 timer 不会被销毁,这就是闭包在防抖函数中的作用。

如果把 timer 定义到 proxy 内部,每次调用都会创建一个全新的变量,不同调用之间无法互相取消,也就不能实现防抖。

第四步:传递参数和 this

代理函数需要把事件对象、业务参数以及调用时的 this 原样传递给 handle

js
function debounce(handle, wait = 300, immediate = false) {
  // 参数处理省略
  let timer = null

  function proxy(...args) {
    const context = this

    timer = setTimeout(() => {
      handle.apply(context, args)
    }, wait)
  }

  return proxy
}

handle.apply(context, args) 也可以写成 handle.call(context, ...args)

这里的 proxy 不能改成箭头函数,因为箭头函数没有自己的 this,无法根据调用方式取得事件目标或所属对象。

参数传递

第五步:实现尾部防抖

尾部防抖表示:连续触发期间不执行,停止触发 wait 毫秒后执行最后一次。

js
function debounce(handle, wait = 300, immediate = false) {
  // 参数处理省略
  let timer = null

  function proxy(...args) {
    const context = this

    if (timer !== null) {
      clearTimeout(timer)
    }

    timer = setTimeout(() => {
      timer = null
      handle.apply(context, args)
    }, wait)
  }

  return proxy
}

连续调用三次时,定时器的变化如下:

text
第 1 次调用:创建定时器 A
第 2 次调用:取消 A,创建定时器 B
第 3 次调用:取消 B,创建定时器 C

最终只有定时器 C 可以执行,因此使用的是最后一次调用的参数和 this

text
0ms      proxy('A'),创建定时器
100ms    proxy('B'),取消旧定时器,重新计时
200ms    proxy('C'),取消旧定时器,重新计时
500ms    执行 handle('C')

执行最后一次

试一试

Count is:

第六步:实现头部防抖

头部防抖表示:一轮连续操作中的第一次调用立即执行,后面的调用全部忽略。只有停止触发满 wait 毫秒,下一轮调用才可以再次立即执行。

js
function proxy(...args) {
  const context = this
  const shouldCallNow = immediate && timer === null

  if (timer !== null) {
    clearTimeout(timer)
  }

  timer = setTimeout(() => {
    timer = null

    if (!immediate) {
      handle.apply(context, args)
    }
  }, wait)

  if (shouldCallNow) {
    handle.apply(context, args)
  }
}

shouldCallNow 需要同时满足:

  • immediate === true,开启立即执行模式;
  • timer === null,当前没有处于等待状态的定时器。

这里使用严格判断 timer === null,而不是 !timer,因为代码真正想表达的是“定时器变量是否处于空闲状态”。

即使是立即执行模式,每次调用也要重新创建定时器。此时定时器不是用来延迟执行 handle,而是作为一把锁:

text
timer === null     没有锁,可以立即执行
timer !== null     已加锁,不能执行
定时器结束         timer 恢复为 null,解除锁定

执行顺序如下:

text
0ms      proxy('A'),立即执行 handle('A')
100ms    proxy('B'),不执行,重新计时
200ms    proxy('C'),不执行,重新计时
500ms    定时器结束,只把 timer 设置为 null
600ms    proxy('D'),新一轮开始,立即执行 handle('D')

定时器中必须使用 immediate 判断尾部是否执行,不能使用本次调用计算出来的 shouldCallNow。否则最后一次调用的 shouldCallNowfalse,定时器结束时可能错误地再执行一次 handle

debounce

试一试

Count is:

第七步:保存并返回执行结果

定义 result 保存最近一次执行结果:

js
let result

执行 handle 时更新它,并在代理函数中返回:

js
result = handle.apply(context, args)
return result

立即执行模式可以同步拿到本次结果:

js
const add = debounce((a, b) => a + b, 300, true)

add(1, 2) // 3
add(3, 4) // handle 没有再次执行,仍返回最近一次结果 3

尾部模式无法同步返回未来的执行结果,因为调用代理函数时,handle 还没有执行。

第八步:添加取消能力

函数在 JavaScript 中也是对象,因此可以给代理函数添加 cancel 方法:

js
proxy.cancel = function () {
  if (timer !== null) {
    clearTimeout(timer)
    timer = null
  }
}

组件卸载时可以同时移除监听并取消尚未执行的任务:

js
window.removeEventListener('resize', onResize)
onResize.cancel()

removeEventListener 只能阻止未来的事件调用,不能取消已经创建的定时器。

防抖完整代码

点击查看
js
/**
 * 防抖函数
 *
 * @param {Function} handle 需要执行的函数
 * @param {number} wait 等待时间,单位为毫秒
 * @param {boolean} immediate
 * false:停止触发 wait 毫秒后执行
 * true:第一次触发时立即执行
 */
function debounce(handle, wait = 300, immediate = false) {
  if (typeof handle !== 'function') {
    throw new TypeError('handle must be a function')
  }

  if (typeof wait === 'boolean') {
    immediate = wait
    wait = 300
  }

  if (!Number.isFinite(wait) || wait < 0) {
    wait = 300
  }

  if (typeof immediate !== 'boolean') {
    immediate = false
  }

  let timer = null
  let result

  function proxy(...args) {
    const context = this
    const shouldCallNow = immediate && timer === null

    if (timer !== null) {
      clearTimeout(timer)
    }

    timer = setTimeout(() => {
      timer = null

      if (!immediate) {
        result = handle.apply(context, args)
      }
    }, wait)

    if (shouldCallNow) {
      result = handle.apply(context, args)
    }

    return result
  }

  proxy.cancel = function () {
    if (timer !== null) {
      clearTimeout(timer)
      timer = null
    }
  }

  return proxy
}

节流函数

对于高频操作,节流按照指定频率减少函数执行次数。只要事件持续触发,处理函数仍会每隔一段时间执行一次。

应用场景

  • 页面滚动位置计算
  • mousemove、拖拽事件
  • 页面尺寸变化过程中的计算
  • 高频上报或埋点
  • 滚动加载和元素位置检测

前置场景

页面滚动时直接读取滚动位置:

js
function handleScroll() {
  console.log(document.documentElement.scrollTop)
}

window.addEventListener('scroll', handleScroll)

滚动事件会被高频触发,handleScroll 也会随之执行很多次。

scroll

原节流函数存在的问题

原实现已经能够达到“第一次立即执行、时间窗口结束后补充执行一次”的节流效果,但还存在以下问题:

  1. wait 只判断了 undefined,没有处理 NaNInfinity、负数和字符串。
  2. 只需要时间戳时使用了 new Date()Date.now() 更直接。
  3. 尾部定时器保存的是时间窗口内第一次被限制调用的参数,而不是最后一次参数。
  4. 定时器回调已经开始执行时,再调用 clearTimeout(timer) 没有意义。
  5. !timer 不如 timer === null 语义明确。
  6. 没有返回执行结果,也没有提供取消方法。

其中最重要的是尾部参数问题:

text
0ms      proxy('A'),立即执行 A
100ms    proxy('B'),创建尾部定时器,闭包保存 B
200ms    proxy('C'),已经存在定时器,调用被忽略
300ms    proxy('D'),已经存在定时器,调用被忽略
400ms    定时器执行 B

通常更符合预期的行为是 400ms 时执行最后一次调用的 D,而不是第一次被限制的 B

第一步:检查参数并定义状态

js
function throttle(handle, wait = 400) {
  if (typeof handle !== 'function') {
    throw new TypeError('handle must be a function')
  }

  if (!Number.isFinite(wait) || wait < 0) {
    wait = 400
  }

  let timer = null
  let lastInvokeTime = 0
  let lastArgs = null
  let lastContext = null
  let result

  // ...
}

各变量的作用:

  • timer:保存当前的尾部定时器,同一时间窗口内最多创建一个。
  • lastInvokeTime:记录上一次真正执行 handle 的时间。
  • lastArgs:记录最近一次调用代理函数时传入的参数。
  • lastContext:记录最近一次调用代理函数时的 this
  • result:保存最近一次执行 handle 得到的结果。

第二步:抽离真正的执行逻辑

立即执行和尾部执行最终都需要调用 handle,因此把公共逻辑提取为 invoke

js
function invoke(time) {
  const args = lastArgs
  const context = lastContext

  lastInvokeTime = time
  lastArgs = null
  lastContext = null

  result = handle.apply(context, args)

  return result
}

先使用局部变量保存参数和 this,再清理闭包中的引用,最后执行真正的处理函数。

lastInvokeTime 在执行 handle 之前更新,记录的是本次开始执行的时间。如果 handle 内部再次调用节流函数,新调用也能读取到已经更新的状态。

第三步:保存最新参数并计算剩余时间

js
function proxy(...args) {
  const now = Date.now()
  const elapsed = now - lastInvokeTime
  const remaining = wait - elapsed

  lastArgs = args
  lastContext = this

  // ...
}

三个时间变量分别表示:

js
now // 当前时间
elapsed // 距离上一次真正执行过去了多久
remaining // 距离下一次允许执行还剩多久

假设 wait = 400,上一次执行时间是 1000,当前时间是 1150

js
elapsed = 1150 - 1000 // 150
remaining = 400 - 150 // 250

说明还需要等待 250ms

每次调用都更新 lastArgslastContext。即使已经存在尾部定时器,后续调用仍然会更新它们,定时器执行时读取的就是最后一次调用的数据。

第四步:判断是否立即执行

js
if (
  lastInvokeTime === 0 ||
  remaining <= 0 ||
  remaining > wait
) {
  if (timer !== null) {
    clearTimeout(timer)
    timer = null
  }

  return invoke(now)
}

立即执行有三种情况:

  • lastInvokeTime === 0:第一次调用。
  • remaining <= 0:距离上次执行已经达到或超过 wait
  • remaining > wait:系统时间发生回拨。

Date.now() 使用系统时间。如果系统时间被调整到更早的时间,可能出现 now < lastInvokeTime,进而让 remaining > wait。把这种情况视为可以立即执行,可以避免函数长时间不能执行。

立即执行之前需要取消尚未运行的尾部定时器,否则可能先立即执行一次,旧定时器随后又执行一次。

第五步:安排尾部执行

如果还没有达到下一次执行时间,就只创建一个尾部定时器:

js
if (timer === null) {
  timer = setTimeout(trailingInvoke, remaining)
}

连续调用期间:

text
第 1 次受限制调用:创建定时器,保存参数 B
第 2 次受限制调用:不创建定时器,只把参数更新为 C
第 3 次受限制调用:不创建定时器,只把参数更新为 D
定时器到期:使用最新参数 D 执行

这也是节流和防抖的关键区别:

  • 防抖每次调用都取消旧定时器并重新计时。
  • 节流已经存在定时器时不会重新计时,只更新最后一次参数和 this

第六步:实现尾部执行函数

js
function trailingInvoke() {
  timer = null

  if (lastArgs !== null) {
    invoke(Date.now())
  }
}

定时器触发后先恢复空闲状态。这里不需要再调用 clearTimeout(timer),因为定时器已经到期并开始执行。

即使调用时没有参数,args 也是空数组 [],而不是 null,所以 lastArgs !== null 可以准确区分“没有待执行调用”和“有一次无参数调用”。

第七步:返回结果并添加取消能力

代理函数最后返回最近一次执行结果:

js
return result

第一次调用立即执行,因此能够同步拿到结果:

js
const add = throttle((a, b) => a + b, 400)

add(1, 2) // 3
add(10, 20) // 时间窗口内不执行,仍然返回 3

取消方法需要清理定时器和全部等待状态:

js
proxy.cancel = function () {
  if (timer !== null) {
    clearTimeout(timer)
  }

  timer = null
  lastInvokeTime = 0
  lastArgs = null
  lastContext = null
}

lastInvokeTime 恢复为 0 后,下一次调用会被重新视为第一次调用并立即执行。

改进版节流执行过程

假设 const fn = throttle(handle, 400),连续调用:

text
0ms      fn('A'),第一次调用,立即执行 A
100ms    fn('B'),创建尾部定时器,记录 B
200ms    fn('C'),不创建新定时器,参数更新为 C
300ms    fn('D'),不创建新定时器,参数更新为 D
400ms    定时器触发,执行 D
500ms    fn('E'),距离上次执行不足 400ms,安排尾部执行
800ms    定时器触发,执行 E

最终真正执行的是:

text
0ms      handle('A')
400ms    handle('D')
800ms    handle('E')

throttle

试一试

ScrollTop with throttle is:

ScrollTop without throttle is:

节流完整代码

点击查看
js
/**
 * 节流函数
 *
 * 第一次调用立即执行;
 * wait 时间内的调用不会立即执行;
 * 时间窗口结束后,使用最后一次调用的参数补充执行。
 *
 * @param {Function} handle 需要执行的函数
 * @param {number} wait 执行间隔,单位为毫秒
 */
function throttle(handle, wait = 400) {
  if (typeof handle !== 'function') {
    throw new TypeError('handle must be a function')
  }

  if (!Number.isFinite(wait) || wait < 0) {
    wait = 400
  }

  let timer = null
  let lastInvokeTime = 0
  let lastArgs = null
  let lastContext = null
  let result

  function invoke(time) {
    const args = lastArgs
    const context = lastContext

    lastInvokeTime = time
    lastArgs = null
    lastContext = null

    result = handle.apply(context, args)

    return result
  }

  function trailingInvoke() {
    timer = null

    if (lastArgs !== null) {
      invoke(Date.now())
    }
  }

  function proxy(...args) {
    const now = Date.now()
    const elapsed = now - lastInvokeTime
    const remaining = wait - elapsed

    lastArgs = args
    lastContext = this

    if (
      lastInvokeTime === 0 ||
      remaining <= 0 ||
      remaining > wait
    ) {
      if (timer !== null) {
        clearTimeout(timer)
        timer = null
      }

      return invoke(now)
    }

    if (timer === null) {
      timer = setTimeout(trailingInvoke, remaining)
    }

    return result
  }

  proxy.cancel = function () {
    if (timer !== null) {
      clearTimeout(timer)
    }

    timer = null
    lastInvokeTime = 0
    lastArgs = null
    lastContext = null
  }

  return proxy
}

防抖和节流的区别

对比项防抖节流
核心目标连续操作只识别一次按固定频率减少执行次数
是否重新计时每次调用都重新计时已有定时器时不重新计时
持续触发时尾部模式可能一直不执行会按照时间间隔持续执行
常见场景搜索联想、表单校验、防重复点击滚动、拖拽、鼠标移动、位置计算
尾部参数使用最后一次调用参数使用当前时间窗口最后一次调用参数

选择时可以先问两个问题:

  1. 是否必须等用户停止操作后再执行?如果是,使用防抖。
  2. 用户持续操作时是否仍要定期得到结果?如果是,使用节流。