JavaScript 防抖与节流进阶:解决 this 绑定、返回值及异步处理问题

作者:青柠时间:2026-10-23 23:49:07浏览:336

本文深入探讨 JavaScript 中防抖和节流的进阶实现。针对常见痛点,详细解析如何处理 ES6 Class 中的 this 绑定问题,解决函数返回值缺失导致的同步/异步调用异常,并对比两种支持异步函数的实现思路。此外,还介绍了利用 Proxy 保留函数附加属性的技巧,提供了一套更健壮的实用方案。

场景与背景

场景

网络上关于防抖和节流的教程已不少见,为何还要重新撰写?实际上,在实际开发中,作者发现现有的实现存在若干隐蔽问题,经过多次调试与修正后,决定将这些遇到的坑及解决方案分享出来。

防抖与节流的必要性

为什么要用防抖和节流?

当某些函数触发或调用的频率过高时,我们需要手动限制其执行频率。以常见的滚动条监听事件为例,若未进行防抖处理,且每次函数执行耗时超过了触发间隔,页面将会出现明显的卡顿现象。

防抖函数演进

演进

初始实现

首先,我们实现一个基础的防抖函数。


function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action(...args)
  }, delay)
 }
}

进行测试验证。


// 使用 Promise 简单封装 setTimeout,下同
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
;(async () => {
 let num = 0
 const add = () => ++num

 add()
 add()
 console.log(num) // 2

 const fn = debounce(10, add)
 fn()
 fn()
 console.log(num) // 2
 await wait(20)
 console.log(num) // 3
})()

从结果来看,基本功能已实现。包装后的函数 fn 被调用了两次,但并未立即执行,而是等待设定的时间间隔结束后,最终只执行了一次。

处理 this 绑定问题

this 怎么办?

然而,上述实现存在一个致命缺陷:未处理 this 指向。在原生事件处理中或许不明显,但在使用 ES6 class 等对 this 敏感的场景下,必然会遇到 this 指向错误的问题。

例如,使用 class 声明一个计数器。


class Counter {
 constructor() {
  this.i = 0
 }
 add() {
  this.i++
 }
}

我们可能希望在 constructor 中为实例添加新的属性 fn。


class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add)
 }
 add() {
  this.i++
 }
}

但遗憾的是,此处的 this 绑定存在问题,尝试执行以下代码。


const counter = new Counter()
counter.fn() // Cannot read property 'i' of undefined

程序会抛出异常 Cannot read property 'i' of undefined,根本原因在于 this 未正确绑定。我们可以手动通过 this .bind(this) 来解决。


class Counter {
 constructor() {
  this.i = 0
  this.fn = debounce(10, this.add.bind(this))
 }
 add() {
  this.i++
 }
}

但更优雅的方式是修改 debounce 函数,使其能够自动绑定 this。


function debounce(delay, action) {
 let tId
 return function(...args) {
  if (tId) clearTimeout(tId)
  tId = setTimeout(() => {
   action.apply(this, args)
  }, delay)
 }
}

修改后,代码将按预期正常运行。


;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add)
  }
  add() {
   this.i++
  }
 }

 const counter = new Counter()
 counter.add()
 counter.add()
 console.log(counter.i) // 2

 counter.fn()
 counter.fn()
 console.log(counter.i) // 2
 await wait(20)
 console.log(counter.i) // 3
})()

处理返回值与异步支持

返回值呢?

你是否注意到,目前使用 debounce 包装的函数均无返回值,仅具备副作用。然而,实际场景中仍可能需要返回值。例如:输入停止后,通过 Ajax 请求后台数据以判断是否存在重复数据。

将 debounce 修改为缓存上一次执行结果并支持初始结果参数的实现。


function debounce(delay, action, init = undefined) {
 let flag
 let result = init
 return function(...args) {
  if (flag) clearTimeout(flag)
  flag = setTimeout(() => {
   result = action.apply(this, args)
  }, delay)
  return result
 }
}

调用代码变为。


;(async () => {
 class Counter {
  constructor() {
   this.i = 0
   this.fn = debounce(10, this.add, 0)
  }
  add() {
   return ++this.i
  }
 }

 const counter = new Counter()

 console.log(counter.add()) // 1
 console.log(counter.add()) // 2

 console.log(counter.fn()) // 0
 console.log(counter.fn()) // 0
 await wait(20)
 console.log(counter.fn()) // 3
})()

看起来完美?然而,未考虑异步函数会导致严重失败!

尝试以下测试代码。


;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 const fn = debounce(10, get, 0)
 fn(3).then(i => console.log(i)) // fn(...).then is not a function
 fn(4).then(i => console.log(i))
 await wait(20)
 fn(5).then(i => console.log(i))
})()

程序会抛出异常 fn(...).then is not a function,因为包装后的函数是同步的,第一次返回的值并非 Promise 类型。

除非我们修改默认值。


;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, new Promise(resolve => resolve(0)))
 fn(3).then(i => console.log(i)) // 0
 fn(4).then(i => console.log(i)) // 0
 await wait(20)
 fn(5).then(i => console.log(i)) // 4
})()

支持有返回值的异步函数。

支持异步有两种思路。

  1. 将异步函数包装为同步函数。
  2. 将包装后的函数异步化。

第一种思路的实现。


function debounce(delay, action, init = undefined) {
 let flag
 let result = init
 return function(...args) {
  if (flag) clearTimeout(flag)
  flag = setTimeout(() => {
   const temp = action.apply(this, args)
   if (temp instanceof Promise) {
    temp.then(res => (result = res))
   } else {
    result = temp
   }
  }, delay)
  return result
 }
}

调用方式与同步函数完全一致,当然,它支持异步函数。


;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, 0)
 console.log(fn(3)) // 0
 console.log(fn(4)) // 0
 await wait(20)
 console.log(fn(5)) // 4
})()

第二种思路的实现。


const debounce = (delay, action, init = undefined) => {
 let flag
 let result = init
 return function(...args) {
  return new Promise(resolve => {
   if (flag) clearTimeout(flag)
   flag = setTimeout(() => {
    result = action.apply(this, args)
    resolve(result)
   }, delay)
   setTimeout(() => {
    resolve(result)
   }, delay)
  })
 }
}

调用方式支持异步风格。


;(async () => {
 const get = async i => i

 console.log(await get(1))
 console.log(await get(2))
 // 注意,修改默认值为 Promise
 const fn = debounce(10, get, 0)
 fn(3).then(i => console.log(i)) // 0
 fn(4).then(i => console.log(i)) // 4
 await wait(20)
 fn(5).then(i => console.log(i)) // 5
})()

可以看到,第一种思路的问题是返回值永远返回旧的返回值;第二种思路的主要问题是将同步函数也包装成了异步。权衡利弊后,作者认为第二种思路更为正确,毕竟使用场景本身不太可能是必须同步的操作。此外,原本 setTimeout 也是异步的,只是在不需要返回值时未被察觉。

保留原函数属性

避免原函数信息丢失。

后来有人提出一个问题:如果函数携带其他信息,例如类似 jQuery 的 $,它既是函数,又包含其他属性,使用 debounce 后这些属性会丢失。

起初作者想到复制函数上所有可遍历属性,但随即想起 ES6 的 Proxy 特性——这非常巧妙。使用 Proxy 解决此问题异常简单,因为除调用函数外,其他所有操作仍指向原函数。


const debounce = (delay, action, init = undefined) => {
 let flag
 let result = init
 return new Proxy(action, {
  apply(target, thisArg, args) {
   return new Promise(resolve => {
    if (flag) clearTimeout(flag)
    flag = setTimeout(() => {
     resolve((result = Reflect.apply(target, thisArg, args)))
    }, delay)
    setTimeout(() => {
     resolve(result)
    }, delay)
   })
  },
 })
}

进行测试验证。


;(async () => {
 const get = async i => i
 get.rx = 'rx'

 console.log(get.rx) // rx
 const fn = debounce(10, get, 0)
 console.log(fn.rx) // rx
})()

节流函数实现

实现节流。

基于上述思路,实现一个节流函数 throttle。


/**
 * 函数节流
 * 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
 * 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
 * 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
 * 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
 * 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
 *
 * @param {Number} delay 最小间隔时间,单位为 ms
 * @param {Function} action 真正需要执行的操作
 * @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
 */
const throttle = (delay, action) => {
 let last = 0
 let result
 return new Proxy(action, {
  apply(target, thisArg, args) {
   return new Promise(resolve => {
    const curr = Date.now()
    if (curr - last > delay) {
     result = Reflect.apply(target, thisArg, args)
     last = curr
     resolve(result)
     return
    }
    resolve(result)
   })
  },
 })
}

总结

总结。

实际上,这里的防抖和节流仍是简单实现,其他功能如取消防抖或强制刷新缓存尚未实现。当然,对于作者而言,当前功能已足够,并已将其放入公共函数库 rx-util 中。

以上就是 JavaScript 防抖和节流遇见的奇怪问题及解决的详细内容,更多关于 JavaScript 防抖和节流的资料请关注本站其它相关文章!

作者最新文章