跟着underscore学防抖

77 阅读4分钟

学习路径:juejin.cn/post/684490…

防抖-在事件触发后n秒后执行回调函数,如果n秒内再次触发,则重新计算时间。 可以理解为在英雄联盟/王者荣耀的回程键,每次按键都会重新计时

前言

在前端开发中会遇到一些频繁的事件触发,比如:

  1. window 的 resize、scroll
  2. mousedown、mousemove
  3. keyup、keydown
    ……

为此,我们举个示例代码来了解事件如何频繁的触发:

我们写个 index.html 文件:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">

<head>
    <meta charset="utf-8">
    <meta http-equiv="x-ua-compatible" content="IE=edge, chrome=1">
    <title>debounce</title>
    <style>
        #container{
            width: 100%; height: 200px; line-height: 200px; text-align: center; color: #fff; background-color: #444; font-size: 30px;
        }
    </style>
</head>

<body>
    <div id="container"></div>
    <script src="debounce.js"></script>
</body>

</html>复制代码

debounce.js 文件的代码如下:

var count = 1;
var container = document.getElementById('container');

function getUserAction() {
    container.innerHTML = count++;
};


container.onmousemove = getUserAction;复制代码

代码

第一版:限定时间内不可重复触发

function debounce (fn,wait) {
    let timeout
    return function () {
        // 如果不满足wait的话,定时器会被清除掉,注意,这里timeout不会置为null,还是本身返回的数字
        clearTimeout(timeout)
        timeout = setTimeout(fn,wait)
    }
}

现在我们要使用它

container.onmousemove = debounce(getUserAction, 1000);

注意:此处timeout为什么要放在function外面呢?

因为闭包,可以让所有每一次触发的事件处理函数跟上一次的事件处理函数做到一个类似于人类交流之间的通信,因为有了这个共享的工具,这一次的事件处理函数就可以根据这个共享的工具去知道它的上一次是不是已经有处理逻辑被放到异步队列里头等待执行了。这一点很重要,在我对闭包一知半解的时候我是不理解为什么timeout的值每次是共享的

this

如果我们在 getUserAction 函数中 console.log(this),在不使用 debounce 函数的时候,this 的值为:

<div id="container"></div>

但是如果使用我们的 debounce 函数,this 就会指向 Window 对象!

所以我们需要将 this 指向正确的对象。

我们修改下代码:

// 第二版
// 2. 修改this的指向
function debounce (fn, wait) {
    let timeout
    return function () {
        // 注意,是在这里声明,不是在外层函数debounce里面,因为debounce执行的时候,默认也是window调用,或者改为用箭头函数也是可以的
        let _this = this
        clearTimeout(timeout)
        timeout = setTimeout(fn.apply(_this),wait)
    }
} 
// 第二版 箭头函数的版本
function debounce (fn, wait) {
    let timeout
    return function () {
        clearTimeout(timeout)
        timeout = setTimeout(() => {
            fn.apply(this)
        },wait)
    }
} 

现在 this 已经可以正确指向了

arguments

JavaScript 在事件处理函数中会提供事件对象 event,我们修改下 getUserAction 函数:

function getUserAction(e) {
    console.log(e);
    container.innerHTML = count++;
};

如果我们不使用 debounce 函数,这里会打印 MouseEvent 对象,直接用debounce函数,会打印undefined,所以在这里,我们传递一下arguments就可以实现了

function debounce (fn, wait) {
    let timeout
    return function () {
        let _this = this
        let arg = arguments
        clearTimeout(timeout)
        timeout = setTimeout(fn.apply(_this, arg),wait)
    }
} 

返回值

// 4. 增加返回值
注意:此处的result不同步,underscore里面也是同样的
function debounce (fn, wait) {
    let timeout,result
    return function () {
        let _this = this
        let arg = arguments
        clearTimeout(timeout)
        timeout = setTimeout(function () {
            result = fn.apply(_this, arg)
            console.log('afterresult--', result)
        },wait)
        console.log('beforeresult--', result)
        return result
    }
} 

立即执行

希望立即执行,在过n秒停止触发触发后再执行。而不是要等待n秒后,才能执行。

// 5. 立即执行
function debounce (fn, wait, immediate) {
    var timeout,result
    return function () {
        var _this = this
        var args = arguments
        // 如果存在timeout,直接停止计时器
        if (timeout) clearTimeout(timeout)
        if (immediate) {
            // 此处保留timeout的状态,防止被下面函数干扰
            var callNow = !timeout
            timeout = setTimeout(function () {
                // 此处只做定时器,不执行,underscore里面也是这么做的
                timeout = null
            }, wait)
            // 第一次,并且是没有timeout的时候,执行函数
            if (callNow) result = fn.apply(_this, args)
        } else {
            timeout = setTimeout(function () {
                result = fn.apply(_this, args)
            }, wait)
        }
        return result
    }
} 

为什么里头要判断一次timeout是否为空?

那是因为,这一次的事件处理函数如果不判断它的上一次有没有已经被放到异步队列当中了的话,直接执行下面的延时操作,结果是又有一个同样的处理逻辑被放入异步队列当中,因此触发的时候就要去根据timeout判断任务队列中它有没有任务已经在里头等待了,有我们就清除它,因为我们要的结果是最后只能执行一次处理逻辑。

希望有一个取消按钮,点击取消后,停止等待,可以理解触发。

// // 6. 增加取消按钮
function debounce (fn, wait, immediate) {
    var timeout,result
    var debounced = function () {
        var _this = this
        var args = arguments
        // 如果存在timeout,直接停止计时器
        if (timeout) clearTimeout(timeout)
        if (immediate) {
            // 此处保留timeout的状态,防止被下面函数干扰
            var callNow = !timeout
            timeout = setTimeout(function () {
                // 此处只做定时器,不执行,underscore里面也是这么做的
                timeout = null
            }, wait)
            // 第一次,并且是没有timeout的时候,执行函数
            if (callNow) result = fn.apply(_this, args)
        } else {
            timeout = setTimeout(function () {
                result = fn.apply(_this, args)
            }, wait)
        }
        return result
    }
    debounced.cancel = function () {
        console.log('debouncecancel-----', timeout)
        // 此处清除timeout,并置为null
        clearTimeout(timeout)
        timeout = null
    }
  

此处需要配合添加取消按钮,代码如下:

var setUseAction = debounce(getUserAction, 10000, true);

container.onmousemove = setUseAction;
// 此处去触发取消
document.getElementById('cancel').addEventListener('click', function () {
    setUseAction.cancel()
})

至此我们已经完整实现了一个 underscore 中的 debounce 函数

总结

  1. 防抖-在事件触发后n秒后执行回调函数,如果n秒内再次触发,则重新计算时间

  2. timeout的位置很重要(通过闭包共享数据)

  3. 清除定时器很重要(清除无效的定时器任务,只保留最后一次执行逻辑)