Javascript之防抖和节流

685 阅读6分钟

开头的废话

首先,本人也不是什么大佬,只是有感兴趣的会自己写写画画。所以,大佬们有什么建议可以互相交流,但求轻喷,哈哈哈!

很惭愧,工作也有几个年头了。刚工作时,在CSDN也写过几篇学习笔记的博客,后来对博客也就没啥兴趣了。个人的观念是学习是自己的事情,不必开着博客到处展览。但换了公司和现在的领导交流之后,才知道自己的想法有些错误。写博客的目的在于分享和交流,甚至能够判断自己的技术方向是否有偏离,如果能结交一些志同道合的朋友,那就是更大的收获了(本人性格外冷内逗,欢迎交流)。

废话一大堆,下面开始一本正经的聊聊我写的bug,咳咳咳(严肃)!

1、节流函数

节流函数是指在高频率的调用过程中,需要稀释函数或方法的执行频率。在指定时间内,只调用一次指定的函数或方法。

简单版

var timer = null;
function throttled(func, params, delay) {
    if(timer == null) {
        timer = setTimeout(() => {
            func(params);
            timer = null;
        }, delay)
    }
}
// 调用
document.body.addEventListener('mousemove', function(event) {
    throttled((e) => {
        console.log(`事件类型:${e.type},发生时间:${e.timeStamp}`);
    }, event, 1000);
});
// 输出结果
// 事件类型:mousemove,发生时间:479.9099999945611
// 事件类型:mousemove,发生时间:1480.7299999520183
// 事件类型:mousemove,发生时间:2481.4400000032037

从执行结果来看,每秒输出一次事件的信息,这也达到了我们的目的。当然,这只是测试,这里日志输出的逻辑可以替换成任何实际有意义的逻辑。但仔细想想,这个节流函数有许多缺点:

  1. 我们定义了一个全局变量timer。假设这段代码是引用的第三方库,而我在这个脚本后面的代码中,也定义了timer全局变量,并且设置为初始值为1。那么,在执行throttled函数的时候,timer永远也不为空,于是随后的逻辑也就不会再执行。
  2. 函数throttled只接受第二个参数作为func函数的参数。那么,如果我们需要传入多个参数,上面的代码也无法完成。
  3. 假设在首次调用函数throttled时,通过setTimeout设置了定时器,直到定时器执行之前,timer都不再为空。那么,定时器的回调函数取到的func参数和params参数永远都是首次调用throttled函数所设置的参数。如果需要更新参数,这种写法也是无法办到的。

闭包版

为了解决上面的三个问题,我们可以考虑使用闭包。

function throttle(delay) {
    var timer, lastFunc, lastParams;
    return function(func) {
        lastFunc = func;
        lastParams = Array.prototype.slice.call(arguments, 1);
        if(timer == null) {
            timer = setTimeout(() => {
                lastFunc.apply(this, lastParams);
                timer = null;
            }, delay)
        }
    }
}
// 调用
var throttled = throttle(1000);
document.body.addEventListener('mousemove', function (event) {
    throttled((type, timeStamp, x, y) => {
        console.log(`事件类型:${type},发生时间:${timeStamp},坐标:${x},${y}`);
    }, event.type, event.timeStamp, event.x, event.y);
});
// 执行结果
// 事件类型:mousemove,发生时间:1154.0700000477955,坐标:194,151
// 事件类型:mousemove,发生时间:2155.3149999817833,坐标:274,208

从上面的执行结果来看,在简单版中遇到的三个问题,这里统统得到了解决:

  1. 全局变量被删除了,取而代之的是函数内的局部变量。
  2. 支持可变的参数,通过arguments变量获得实际的参数。arguments是类数组对象,通过slice方法将其转换成真正的数组,将这个数组传入apply方法,调用func函数。
  3. 创建lastFunclastParams这两个额外的局部变量,用来缓存最新的参数。定时器的回调函数执行时,就可以使用最新的参数。

this指针

在上面的代码中,我修改了func函数的调用方式,采用func.apply(this, lastParams)的方式来调用。这么做的原因是为了修改调用func函数的this指针。让我们来测试两种调用方式的区别,如下是一段简化的代码:

function throttle(func, delay) {
    var timer;
    return function() {
        if(timer == null) {
            timer = setTimeout(() => {
                // func.apply(this);
                // func();
                timer = null;
            }, delay)
        }
    }
}

var throttled = throttle(function () {
    console.log(this);
}, 1000);
document.body.addEventListener('mousemove', throttled);

首先,解开func()的注释,输出结果如下:

Window {parent: Window, postMessage: ƒ, blur: ƒ, focus: ƒ, close: ƒ, …}

然后,解开func.apply(this)的注释,输出结果如下:

<body>...</body>

假设不使用节流函数,事件回调中的this是什么呢?

document.body.addEventListener('mousemove', function () {
    console.log(this);
});
// 执行结果
// <body>...</body>

显然,我们更希望this的指向是第二种结果,因为我们不会希望函数放到节流函数中以后,this的指向就变成了window

这里先回顾一下this指针指向的规律:

  1. 全局函数的this指向window对象(严格模式下,全局函数的this指向undefined)。
  2. 对象的方法指向调用的对象。
  3. 匿名函数具有全局性。也就是说,匿名函数的this也通常指向window
  4. 箭头函数没有自己的this指向,它的this指向是从它的上层作用域继承过来的。

当然,applycall都可以改变this的指向,而上述只是在不改变this指向的常见情况。

当调用func()这段代码时,这是匿名函数且没有改变它的this指向。因此,它指向全局对象。而调用func.apply(this)修改了this指向之后,新的this指向取决于throttle所返回的throttled闭包函数是如何执行的。而根据结果猜测,这个闭包函数被当作是body对象的方法执行,意味着this指向被修改成了body对象。

指定this指向

在上一节,我们说明了为什么使用apply修改this指针。但这还不够,假设节流函数调用的是某个对象的方法呢?

function throttle(delay) {
    var timer, lastFunc, lastParams;
    return function(func) {
        lastFunc = func;
        lastParams = Array.prototype.slice.call(arguments, 1);
        if(timer == null) {
            timer = setTimeout(() => {
                lastFunc.apply(this, lastParams);
                timer = null;
            }, delay)
        }
    }
}
// 调用
var obj = {
    name: 'obj',
    say: function() {
        console.log(this.name);
    }
}
var name = 'window';
var throttled = throttle(1000);
document.body.addEventListener('mousemove', function (event) {
    throttled(obj.say);
});
// 执行结果
// "window"

很明显,因为this的指向错误,导致输出结果也错误了。那么,增加一个参数传入this值。

function throttle(delay) {
    var timer, lastFunc, lastThisArg, lastParams;
    return function(func, thisArg) {
        lastFunc = func;
        lastThisArg = thisArg;
        lastParams = Array.prototype.slice.call(arguments, 2);
        if(timer == null) {
            timer = setTimeout(() => {
                lastFunc.apply(lastThisArg || this, lastParams);
                timer = null;
            }, delay)
        }
    }
}
// 调用
var obj = {
    name: 'obj',
    say: function() {
        console.log(this.name);
    }
}
var name = 'window';
var throttled = throttle(1000);
document.body.addEventListener('mousemove', function (event) {
    throttled(obj.say, obj);
});
// 执行结果
// "obj"

于是,节流函数就写好了。

2、防抖函数

防抖函数是指某个事件发生之后的指定时间段内,没有再发生该事件,防抖函数将会执行一次。否则,重新计算时间。

有了节流函数的铺垫,防抖函数也非常好写,这里不再做过多的解释,代码如下。

function debounce(delay) {
    var timer = null;
    return function(func, thisArg) {
        var args = Array.prototype.slice.call(arguments, 2);
        if(timer) {
            window.clearTimeout(timer);
        }
        timer = setTimeout(()=> {
            func.apply(thisArg || this, args);
            timer = null;
        }, delay);
    }
}
// 调用
var debounced = debounce(1000);
document.body.addEventListener('mousemove', function (event) {
    debounced((type, timeStamp, x, y) => {
        console.log(`事件类型:${type},发生时间:${timeStamp},坐标:${x},${y}`);
    }, undefined, event.type, event.timeStamp, event.x, event.y);
});