终于搞懂:防抖和节流

1,172 阅读7分钟

最近在复习,想通过费曼学习法来检验自己的学习程度,于是斗胆在此发文,若蒙指点,幸甚至哉!

文中所有代码均可直接在控制台运行。

一、缓冲

缓冲,这是一个非常普遍的概念,凡是使某种事物进行减慢或减弱变化过程都可以叫缓冲。网页交互中也需要缓冲,也就是 防抖(debounce)节流(throttle) —— 让高频事件的反应慢一点,虽然是老生常谈,但也不容忽视。

Lodash 中已早有实现。

  • debounce:一个事件被触发后,立即设定一个小延时。如果在延时期间事件再次被触发,则销毁前一个延时并重新开始延时;否则,开始执行处理函数。简而言之,就是不断地重新设置计时器举例:电梯门,若干秒内无人进入,则自动关门。
  • throttle:在事件连续触发的过程中,每隔一段时间执行一次处理函数。举例:文章自动保存,在编辑过程中每隔几分钟自动保存一次。

二、Debounce

1. debounce基本实现

  • 需求:鼠标在页面上移动,当移动停止 2 秒后,提示“鼠标移动了”。
  • 实现:根据前面的定义,在鼠标移动时设置新的计时器即可:
let timer = null;
function setTimer(){
    timer && clearTimeout(timer);
    timer = setTimeout(function () {
    	console.log("鼠标移动了");
    }, 2000)
}
window.onmousemove = setTimer;

2. 封装

  • 需求:由于以上代码使用频繁,需要封装成一个便捷的工具函数,参数为事件处理函数和等待时长。
  • 实现:实参不一定会严格按照形参来传,注意防御
/**
 * debounce函数 V1.0
 * @param {function} fn 事件处理函数,默认为一个空函数
 * @param {number} delay 延时时间,单位为毫秒
 * @return {function} resetTimer 返回一个可以重新设定计时器的函数
 */
function debounce(fn=function(){}, delay=0){
    let timer = null;
    function resetTimer() {
        if(timer) {clearTimeout(timer)}
        // 要保证参数delay是自然数
        const delayTime = isNaN(parseInt(delay)) ? 
            0 : Math.abs(parseInt(delay));
        timer = setTimeout(fn, delayTime);
    }
    return resetTimer;
}

function onMove() { console.log("鼠标移动了") }
window.onmousemove = debounce(onMove, 2000);

3. 执行环境:this

在事件处理函数中经常需要访问this对象。 上面的debounce函数被用在了window.onmousemove上,所以onMove里的this肯定指的是window。现在我们把debounce用在别的对象上,看看this的指向会不会变化:

// ...debounce函数 V1.0
//假设有个ID为menu的元素
const oBtn = document.getElementById("menu"); 
function onBtnHover() {console.log(this)};
oBtn.onmousemove = debounce(onBtnHover, 1000);
// 结果:window对象

出问题了,onBtnHover里的 this 并不是oBtn,问题在debounce函数的setTimeout里:

setTimeout(fn, delayTime); //相当于
function callback(){
    fn();
}
setTimeout(callback, delayTime);

这里的 fn 并没有与任何对象绑定,所以它属于window对象,因此this指向window。

4. 解决this指向问题

ES5: 一个函数在被调用时,会自动取得两个特殊变量:this和arguments。在全局环境调用函数时,this代表window对象;而当函数被作为其他某个对象的方法而调用时,this就代表那个对象。

简而言之: ES5的函数中,this是执行时函数所在的那个对象。

ES6: this是函数定义所在的那个对象。

在下面的代码中,debounce为oBtn.onmousemove绑定了事件处理函数resetTimer。根据ES5中this的性质,该函数运行时,它的执行环境必然是调用者:

function debounce(fn=function(){}, delay=0){
    function resetTimer() {
       console.log("resetTimer执行环境:", this);
    }
    return resetTimer;
}
const oBtn = document.getElementById("menu");
function onBtnHover() {console.log(this)};
oBtn.onmousemove = debounce(onBtnHover, 1000);
// 结果:执行环境是oBtn

因此,应该在resetTimer函数里将 this 保存下来,然后在setTimeout中使用call、apply函数修改this指向。debounce函数可以这样修改:

/**
 * debounce函数 V2.0
 * @param {function} fn 事件处理函数,默认为一个空函数
 * @param {number} delay 延时时间,单位为毫秒
 * @return {function} resetTimer 返回一个可以重新设定计时器的函数
 */
function debounce(fn=function(){}, delay=0){
    let timer = null;
    function resetTimer() {
        const context = this;  /**** 修改:保存执行环境 ****/ 
        if(timer) {clearTimeout(timer)}
        const delayTime = isNaN(parseInt(delay)) ? 
            0 : Math.abs(parseInt(delay));
        timer = setTimeout(
            function(){fn.apply(context)},  /**** 修改:应用执行环境 ****/ 
            delayTime
        );
    }
    return resetTimer;
}

const oBtn = document.getElementById("menu");
function onBtnHover() {console.log("鼠标移动了,执行环境是:", this)};
oBtn.onmousemove = debounce(onBtnHover, 1000);
// 结果:执行环境是 oBtn

5. 参数传递

上面的代码保证了this指向正确,但是一旦试图为onBtnHover传参,就会影响到this指向。

  • 需求:onBtnHover函数打印的是为其传递的字符串
  • 实现:用一个匿名函数调用onBtnHover并传参
// ...debounce函数 V2.0
const oBtn = document.getElementById("menu");
function onBtnHover(text) {console.log(text, this)};
oBtn.onmousemove = debounce(
    function(){onBtnHover("鼠标移动了,执行环境是:")}, 
    1000
);
// 结果:执行环境是 window
  • 原因: 为了传参,我们用一个匿名函数来调用onBtnHover,因此apply(context)只是修改了这个匿名函数的this,而onBtnHover的this仍然未被修改。
  • 证明:在这个匿名函数内打印一下this
// ...debounce函数 V2.0
const oBtn = document.getElementById("menu");
function onBtnHover(text) {console.log(text, this)};
oBtn.onmousemove = debounce(
    function(){
        console.log("匿名函数执行环境:", this); /*** 修改 ***/
        onBtnHover("鼠标移动了,执行环境是:");
    }, 
    1000
);
// 匿名函数执行环境:oBtn
// 鼠标移动了,执行环境是:window
  • 解决方法1: 在匿名函数里使用call、apply修改onBtnHover的this,并传参
// ...debounce函数 V2.0
const oBtn = document.getElementById("menu");
function onBtnHover(text) {console.log(text, this)};
oBtn.onmousemove = debounce(
    function(){
        console.log("匿名函数执行环境:", this); 
        onBtnHover.call(this, "鼠标移动了,执行环境是:"); /*** 修改 ***/
    }, 
    1000
);
// 匿名函数执行环境:oBtn
// 鼠标移动了,执行环境是:oBtn

**注意:**以上代码中如果使用apply,则参数必须以数组的形式传递:onBtnHover.apply(this, ["鼠标移动了,执行环境是:"])

call()方法与 apply()方法的作用相同,它们的区别仅在于接收参数的方式不同。前者需要将参数列举出来,后者则需要将参数放到数组里。

  • 解决方法2:直接将onBtnHover定义为oBtn的一个方法
// ...debounce函数 V2.0
const oBtn = document.getElementById("menu");
oBtn.onBtnHover = function(text) {console.log(text, this)};  /*** 修改 ***/
oBtn.onmousemove = debounce(
    function(){
        console.log("匿名函数执行环境:", this);
        oBtn.onBtnHover("鼠标移动了,执行环境是:"); /*** 修改 ***/
    }, 
    1000
);
// 匿名函数执行环境:oBtn指向的元素
// 鼠标移动了,执行环境是:oBtn指向的元素

6. Debounce:总结

Debounce是在绑定事件处理函数时使用的,它会为事件绑定一个不断重新设定计时器的函数。使用上面的debounce函数 V2.0 时需要向其传递真正的事件处理函数以及延时时间。 如果需要给事件处理函数传参,则要用上述的两种方式来控制this指向。

三、Throttle

节流与防抖的差别仅仅是控制方式不同,所以我们可以借鉴debounce的实现思路。

1. throttle基本实现

  • 需求:在鼠标移动的过程中,每隔1秒打印一次“鼠标在移动”
  • 实现:鼠标刚移动就会设定一个计时器,同时设定一个标记,表明在计时期间若鼠标再次移动,则什么都不做,计时结束后执行处理函数,同时清除标记。这样一来,当再次移动鼠标时,新的计时器和标记就会被设定……
/**
 * throttle函数 V1.0
 * @param {function} fn 事件处理函数,默认为一个空函数
 * @param {number} delay 延时时间,单位为毫秒
 * @return {function} setNewTimer 返回一个可以按时间间隔设定计时器的函数
 */
function throttle(fn = function(){}, delay = 0){
    let isTiming = false; // 是否正在计时
    let timer = null;
    function setNewTimer(){
        if(isTiming) {return};
        
        //开始设置计时器和标记
        isTiming = true; // 设定标记,阻止下次触发
        const delayTime = isNaN(parseInt(delay)) ? 
            0 : Math.abs(parseInt(delay));
        let context = this;
        if(timer){clearTimeout(timer)}; //清除已有计时器
        timer = setTimeout(function(){
            fn.apply(context);
            isTiming = false;
        }, delayTime);
    }
    return setNewTimer;
}

function onMove(){console.log( "鼠标在移动" )};
window.onmousemove = throttle(onMove, 1000);

2. 传参与this指向

  • 需求:鼠标在指定的元素上移动时每隔1秒打印一次“鼠标在移动”,并且保证this指向该元素。
  • 实现:使用前面提到的两种方式即可
  • 方案1:在匿名函数里使用call、apply修改onBtnHover的this,并传参
// throttle函数 V1.0
const oBtn = document.getElementById("menu");
function onBtnHover(text){console.log( text, this )};
oBtn.onmousemove = throttle(
    function(){
        console.log("匿名函数执行环境:", this);
        onBtnHover.call(this, "鼠标在移动,执行环境:")
}, 1000);
  • 方案2:直接将onBtnHover定义为oBtn的一个方法
// throttle函数 V1.0
const oBtn = document.getElementById("menu");
oBtn.onBtnHover = function(text){console.log( text, this )};
oBtn.onmousemove = throttle(
    function(){
        console.log("匿名函数执行环境:", this);
        oBtn.onBtnHover("鼠标在移动,执行环境:")
}, 1000);

四、总结

至此,debounce和throttle均已实现完毕,我们还可以向它们添加更多参数来扩展其功能,比如用另外一个参数表示是否需要立即执行,等等。