终于搞懂:防抖和节流

一、缓冲

缓冲,这是一个非常普遍的概念,凡是使某种事物进行减慢或减弱变化过程都可以叫缓冲。网页交互中也需要缓冲,也就是 防抖(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均已实现完毕,我们还可以向它们添加更多参数来扩展其功能,比如用另外一个参数表示是否需要立即执行,等等。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容

  • 有些情况下会产生问题: 向后台发送数据,用户频繁触发,对服务器造成压力 一些浏览器事件:window.onresi...
    bounsail阅读 1,039评论 0 1
  • 防抖和节流都是为了解决短时间内大量触发某函数而导致的性能问题,比如触发频率过高导致的响应速度跟不上触发频率,出现延...
    _SweetHeart阅读 381评论 0 1
  • 防抖和节流的区别,防抖是把多次操作合并成一个触发执行,节流相当于开水龙头,水大了关小,一段时间执行一次 函数防抖(...
    coderXuan阅读 377评论 0 1
  • Ps: 比如搜索框,用户在输入的时候使用change事件去调用搜索,如果用户每一次输入都去搜索的话,那得消耗多大的...
    燕自浩阅读 5,957评论 0 1
  • 今天青石的票圈出镜率最高的,莫过于张艺谋的新片终于定档了。 一张满溢着水墨风的海报一次次的出现在票圈里,也就是老谋...
    青石电影阅读 10,307评论 1 2