关于防抖函数的思考
防抖概念
本质:是优化高频率执行代码的一种手段。
防抖: n 秒后在执行该事件,若在 n 秒内被重复触发,则重新计时。
好处:能够保证用户在频繁触发某些事件的时候,不会频繁的执行回调,只会被执行一次。
一个经典的比喻:
想象每天上班大厦底下的电梯。把电梯完成一次运送,类比为一次函数的执行和响应。
电梯第一个人进来后,等待15秒。如果过程中又有人进来,15秒等待重新计时,直到15秒后开始运送,这就是防抖策略(debounce)。
用于测试的HTML结构
实现效果:鼠标在盒子上移动时,盒子中央打印出数字。
//未实现防抖时的测试代码
const container = document.querySelector('#container')
let count = 0
function move(e) {
container.innerHTML = count++
console.log(this)
console.log(e)
}
container.addEventListener('mousemove', move)
未实现防抖时对应的页面效果如下:
//实现防抖后的测试代码
const container = document.querySelector('#container')
let count = 0
function move(e) {
container.innerHTML = count++
console.log(this)
console.log(e)
}
const test = debounce(move, 500, true)
container.addEventListener('mousemove', test)
const btn = document.querySelector('button')
btn.onclick = function () {
test.cancel()
}
实现防抖后对应的页面效果如下:
接下来记录我一步步思考完善的过程。
v1.0 简单实现一个防抖(非立即执行版本)
function debounce(func, delay) {
let timeout
return function () {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(func, delay)
}
}
问题探讨:发现打印出来的this是window,打印出来的e是undefined。实际想要得到的是div#container和mouseEvent。出现这种情况的原因:在container的鼠标移动事件调用debounce函数时,在传递给形参func的实参move里打印了this与e。注意move是在定时器setTimeout里,定时器里的this在非严格模式下指向的是window对象,而window对象里的e自然是undefined。解决办法是在return的function里保存this与arguments,通过apply改变func的this指向同时把保存的参数传递给func。
v2.0 解决了this指向和event对象的问题。
function debounce(func, delay) {
let timeout
return function () {
const context = this,
args = arguments
if (timeout) clearTimeout(timeout)
timeout = setTimeout(function () {
func.apply(context, args)
}, delay)
}
}
问题探讨:发现第一次不能立即执行,需要等到delay秒以后才会执行第一次。
v3.0 立即执行版本
function debounce(func, delay) {
let timeout
return function () {
const context = this,
args = arguments,
callNow = !timeout
if (timeout) clearTimeout(timeout)
timeout = setTimeout(function () {
timeout = null
}, delay)
if (callNow) func.apply(context, args)
}
}
Q:为什么利用callNow = !timeout来判断?而不是用callNow = true,然后在定时器内将callNow设置为false?
首先解答为什么不能用布尔值来判断。因为定时器是异步任务,在delay时间段内,callNow始终为true,这就会导致func在delay时间段内会一直触发,直到时间到达delay,callNow变成false才会停止执行func。
再回到为什么可以利用callNow = !timeout来判断的问题上。在首次触发mousemove事件时,'let timeout'执行,此时timeout为undefined;callNow对timeout取反为true;因为此时timeout为undefined,跳过清除定时器操作;把定时器赋值给timeout,注意此时timeout保存的值是1(第一个定时器的id),但是定时器是异步任务,里面的'timeout = null'尚未执行;接下来判断callNow为true,执行func函数,达到了立即执行的效果。在delay秒内第二次移动鼠标,此时timeout保存的值为1,callNow取反为false;清除上一个id为1的定时器;timeout保存值2(id为2的定时器),判断callNow为false,不执行func;反之如果等到delay秒后第二次移动鼠标,此时异步任务已执行,timeout变为null,callNow取反为true,就会执行func。注意点:这里利用了闭包,timeout是可以被访问的。
问题探讨:可以通过传入一个参数来判断实际业务需求是要立即执行还是非立即执行。
v4.0 立即执行与非立即执行结合版本(immediate为true时立即执行,反之非立即执行)
function debounce(func, delay, immediate) {
let timeout
return function () {
const context = this,
args = arguments
if (timeout) clearTimeout(timeout)
if (immediate) {
const callNow = !timeout
timeout = setTimeout(function () {
timeout = null
}, delay)
if (callNow) func.apply(context, args)
} else {
timeout = setTimeout(function () {
func.apply(context, args)
}, delay)
}
}
}
问题探讨:继续完善,如果需要获得func函数的返回值该怎么办呢?那就需要把func的执行结果保存为一个result变量return出来。由此又引出了一个问题,setTimeout是一个异步任务,return时获得的是undefined,只有在立即执行的情况下会获得返回值(immediate为true时)。
v5.0 包含返回值的版本
function debounce(func, delay, immediate) {
let result, timeout
return function () {
const context = this,
args = arguments
if (timeout) clearTimeout(timeout)
if (immediate) {
const callNow = !timeout
timeout = setTimeout(function () {
timeout = null
}, delay)
if (callNow) result = func.apply(context, args)
} else {
timeout = setTimeout(function () {
func.apply(context, args)
}, delay)
}
return result
}
}
问题探讨:当delay设置时间过长时(比如30秒甚至更长),我只有等到delay时间过后才能再次触发,如果可以把取消防抖绑定在一个按钮上,点击之后可以立即执行代码。需要考虑的问题是:可以把这个功能做成是debounce的一个cancel方法,因为函数也是一个对象。具体实现思路应该是把原先return出来的函数用一个变量debounced保存,然后再定义debounced.cancel,赋值为一个函数。
v6.0 包含取消功能的版本
function debounce(func, delay, immediate) {
let timeout, result
const debounced = function () {
const context = this,
args = arguments
if (timeout) clearTimeout(timeout)
if (immediate) {
const callNow = !timeout
timeout = setTimeout(function () {
timeout = null
}, delay)
if (callNow) result = func.apply(context, args)
} else {
timeout = setTimeout(function () {
func.apply(context, args)
}, delay)
}
return result
}
debounced.cancel = function () {
if (timeout) clearTimeout(timeout)
//需要注意,这里的目的并不是为了避免内存泄漏!而是为了让取消后鼠标再次移入盒子能立即执行代码。如果不置空,取消过后再移入,是不会立即执行打印数字的操作的。
timeout = null
}
return debounced
}
v7.0 ES6箭头函数版本(省略了this指向与参数对象的版本)
function debounce(func, delay, immediate) {
let timeout, result
//注意下面的函数声明不能改成箭头函数,否则this会指向window
const debounced = function () {
if (timeout) clearTimeout(timeout)
if (immediate) {
const callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, delay)
if (callNow) result = func.apply(this, arguments)
} else {
timeout = setTimeout(() => {
func.apply(this, arguments)
}, delay)
}
return result
}
debounced.cancel = () => {
if (timeout) clearTimeout(timeout)
timeout = null
}
return debounced
}
作者:GreyJiangy
来源:https://juejin.cn/post/7093466427805401118