Skip to content

JavaScript专题之跟着underscore学防抖 #22

@mqyqingfeng

Description

@mqyqingfeng
Owner

前言

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

  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;

我们来看看效果:

debounce

从左边滑到右边就触发了 165 次 getUserAction 函数!

因为这个例子很简单,所以浏览器完全反应的过来,可是如果是复杂的回调函数或是 ajax 请求呢?假设 1 秒触发了 60 次,每个回调就必须在 1000 / 60 = 16.67ms 内完成,否则就会有卡顿出现。

为了解决这个问题,一般有两种解决方案:

  1. debounce 防抖
  2. throttle 节流

防抖

今天重点讲讲防抖的实现。

防抖的原理就是:你尽管触发事件,但是我一定在事件触发 n 秒后才执行,如果你在一个事件触发的 n 秒内又触发了这个事件,那我就以新的事件的时间为准,n 秒后才执行,总之,就是要等你触发完事件 n 秒内不再触发事件,我才执行,真是任性呐!

第一版

根据这段表述,我们可以写第一版的代码:

// 第一版
function debounce(func, wait) {
    var timeout;
    return function () {
        clearTimeout(timeout)
        timeout = setTimeout(func, wait);
    }
}

如果我们要使用它,以最一开始的例子为例:

container.onmousemove = debounce(getUserAction, 1000);

现在随你怎么移动,反正你移动完 1000ms 内不再触发,我才执行事件。看看使用效果:

debounce 第一版

顿时就从 165 次降低成了 1 次!

棒棒哒,我们接着完善它。

this

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

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

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

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

我们修改下代码:

// 第二版
function debounce(func, wait) {
    var timeout;

    return function () {
        var context = this;

        clearTimeout(timeout)
        timeout = setTimeout(function(){
            func.apply(context)
        }, wait);
    }
}

现在 this 已经可以正确指向了。让我们看下个问题:

event 对象

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

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

如果我们不使用 debouce 函数,这里会打印 MouseEvent 对象,如图所示:

MouseEvent

但是在我们实现的 debounce 函数中,却只会打印 undefined!

所以我们再修改一下代码:

// 第三版
function debounce(func, wait) {
    var timeout;

    return function () {
        var context = this;
        var args = arguments;

        clearTimeout(timeout)
        timeout = setTimeout(function(){
            func.apply(context, args)
        }, wait);
    }
}

到此为止,我们修复了两个小问题:

  1. this 指向
  2. event 对象

立刻执行

这个时候,代码已经很是完善了,但是为了让这个函数更加完善,我们接下来思考一个新的需求。

这个需求就是:

我不希望非要等到事件停止触发后才执行,我希望立刻执行函数,然后等到停止触发 n 秒后,才可以重新触发执行。

想想这个需求也是很有道理的嘛,那我们加个 immediate 参数判断是否是立刻执行。

// 第四版
function debounce(func, wait, immediate) {

    var timeout;

    return function () {
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
            // 如果已经执行过,不再执行
            var callNow = !timeout;
            timeout = setTimeout(function(){
                timeout = null;
            }, wait)
            if (callNow) func.apply(context, args)
        }
        else {
            timeout = setTimeout(function(){
                func.apply(context, args)
            }, wait);
        }
    }
}

再来看看使用效果:

debounce 第四版

返回值

此时注意一点,就是 getUserAction 函数可能是有返回值的,所以我们也要返回函数的执行结果,但是当 immediate 为 false 的时候,因为使用了 setTimeout ,我们将 func.apply(context, args) 的返回值赋给变量,最后再 return 的时候,值将会一直是 undefined,所以我们只在 immediate 为 true 的时候返回函数的执行结果。

// 第五版
function debounce(func, wait, immediate) {

    var timeout, result;

    return function () {
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
            // 如果已经执行过,不再执行
            var callNow = !timeout;
            timeout = setTimeout(function(){
                timeout = null;
            }, wait)
            if (callNow) result = func.apply(context, args)
        }
        else {
            timeout = setTimeout(function(){
                func.apply(context, args)
            }, wait);
        }
        return result;
    }
}

取消

最后我们再思考一个小需求,我希望能取消 debounce 函数,比如说我 debounce 的时间间隔是 10 秒钟,immediate 为 true,这样的话,我只有等 10 秒后才能重新触发事件,现在我希望有一个按钮,点击后,取消防抖,这样我再去触发,就可以又立刻执行啦,是不是很开心?

为了这个需求,我们写最后一版的代码:

// 第六版
function debounce(func, wait, immediate) {

    var timeout, result;

    var debounced = function () {
        var context = this;
        var args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
            // 如果已经执行过,不再执行
            var callNow = !timeout;
            timeout = setTimeout(function(){
                timeout = null;
            }, wait)
            if (callNow) result = func.apply(context, args)
        }
        else {
            timeout = setTimeout(function(){
                func.apply(context, args)
            }, wait);
        }
        return result;
    };

    debounced.cancel = function() {
        clearTimeout(timeout);
        timeout = null;
    };

    return debounced;
}

那么该如何使用这个 cancel 函数呢?依然是以上面的 demo 为例:

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

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

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

container.onmousemove = setUseAction;

document.getElementById("button").addEventListener('click', function(){
    setUseAction.cancel();
})

演示效果如下:

debounce-cancel

至此我们已经完整实现了一个 underscore 中的 debounce 函数,恭喜,撒花!

演示代码

相关的代码可以在 Github 博客仓库 中找到

专题系列

JavaScript专题系列目录地址:https://github.com/mqyqingfeng/Blog

JavaScript专题系列预计写二十篇左右,主要研究日常开发中一些功能点的实现,比如防抖、节流、去重、类型判断、拷贝、最值、扁平、柯里、递归、乱序、排序等,特点是研(chao)究(xi) underscore 和 jQuery 的实现方式。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

Activity

wcflmy

wcflmy commented on Jun 6, 2017

@wcflmy

建议将第四版和第五版换个顺序,在第四版中,由于func始终是异步执行的,return result返回一直是undefined,只有在第五版中immediate参数为true的情况下,result才会取到结果,所以建议换个顺序更加严谨一些。

mqyqingfeng

mqyqingfeng commented on Jun 6, 2017

@mqyqingfeng
OwnerAuthor

@wcflmy 确实存在这个问题,非常感谢指出~ o( ̄▽ ̄)d

xxxgitone

xxxgitone commented on Jun 9, 2017

@xxxgitone

谢谢您的文章,最近想梳理自己的知识,但是却不知道从哪里入手好,跟着您的文章走真的是事半功倍啊,很多以前的疑点豁然开朗,再次谢谢!

mqyqingfeng

mqyqingfeng commented on Jun 9, 2017

@mqyqingfeng
OwnerAuthor

@xxxgitone 我也在梳理自己的知识,与你共勉哈~

hujiulong

hujiulong commented on Jun 20, 2017

@hujiulong

写得很好,在做动画时也经常用到这种方式,防止在一帧时间中(大概16ms)渲染多次。

function debounce(func) {
    var t;
    return function () {
        cancelAnimationFrame(t)
        t = requestAnimationFrame(func);
    }
}
mqyqingfeng

mqyqingfeng commented on Jun 20, 2017

@mqyqingfeng
OwnerAuthor

@hujiulong requestAnimationFrame 确实是神器呐~

allen3xiaokai

allen3xiaokai commented on Jul 13, 2017

@allen3xiaokai

学习了 点赞, 一路学到这里 收获颇丰

YeaseonZhang

YeaseonZhang commented on Aug 1, 2017

@YeaseonZhang

第五版有一点不解,为什么要return result

 if (callNow) func.apply(context, args)

直接执行不可以么,望解答,谢谢

mqyqingfeng

mqyqingfeng commented on Aug 3, 2017

@mqyqingfeng
OwnerAuthor

@YeaseonZhang 直接执行当然可以呀,之所以 return result ,是考虑到 func 这个函数,可能有返回值,尽管这个功能,我们在实际的开发中基本用不到……但是作为一个工具库,underscore 考虑得会更齐全一点~

xietao91

xietao91 commented on Aug 13, 2017

@xietao91

跟着大神涨姿势了,我看了一两个小时才算看明白😂

271 remaining items

DefeatLaziness

DefeatLaziness commented on Mar 24, 2022

@DefeatLaziness

@DefeatLaziness 可以再多花点时间理解,如果还是不行,可以暂时跳过,过一段时间再来看。

好咧,谢谢大佬

Kento97

Kento97 commented on Mar 28, 2022

@Kento97

定时器的回调函数为什么不用箭头函数呢

Marszht

Marszht commented on Apr 17, 2022

@Marszht

关于为什么"return 之前的语句只执行一次",因为debounce返回的是一个无名函数,之后重复执行的是这个无名函数。return 上面的"timeout"变量由于被该函数所使用,构成了闭包

我知道这个执行过程,我问的是这样的原因,既然触发调用了这个防抖函数为什么不执行return之前的语句? var timeout var immediate = true function debounce(func, wait,immediate ) { // 这里如果有语句一定执行 return function () { if (timeout) { clearTimeout(timeout) } if (immediate) { var callNow = !timeout timeout = setTimeout(() => { timeout = null }, wait) if (callNow) func.apply(this, arguments) immediate = false } else { timeout = setTimeout(() => { func.apply(this, arguments) }, wait) } } } debounce(getUserAction, 1000)() debounce(getUserAction, 1000)() debounce(getUserAction, 1000)() 那为什么这样return之前的语句就一定执行呢???大佬能一起解惑吗

我理解的是,当我们addEventListener 的时候注册的事件的 debounce 返回的function 而不是debounce, 返回的函数形成了闭包,所以返回的函数里面能访问该作用域里面的timeout.
而你下面的代码时每次都是执行debounce 所以returen 之前的函数每次都会打印。

ghost
xi2And33

xi2And33 commented on Jul 7, 2022

@xi2And33

立即执行的代码,可以这样写吗

function debounce(fn, delay, immediate) {
  var timer = null;
  var isFirst = immediate;
  return function () {
    var context = this;
    var args = arguments;

    if (isFirst) {
      isFirst = false;
      fn.apply(context, args);
    } else {
      clearTimeout(timer);
      timer = setTimeout(function () {
        fn.apply(context, args);
      }, delay);
    }
  };
}

我觉得没有问题

leslie555

leslie555 commented on Jul 8, 2022

@leslie555

写得很好,在做动画时也经常用到这种方式,防止在一帧时间中(大概16ms)渲染多次。

function debounce(func) {
    var t;
    return function () {
        cancelAnimationFrame(t)
        t = requestAnimationFrame(func);
    }
}

这个函数名叫throttle比较好吧,raf 也是根据帧率来的,高帧率屏幕时间会更短

YuFengjie97

YuFengjie97 commented on Oct 10, 2022

@YuFengjie97

事件里的函数如果有返回值是不是没什么用啊?怎么接收?

Yang-y-good

Yang-y-good commented on Oct 12, 2022

@Yang-y-good

立即执行的代码,可以这样写吗

function debounce(fn, delay, immediate) {
  var timer = null;
  var isFirst = immediate;
  return function () {
    var context = this;
    var args = arguments;

    if (isFirst) {
      isFirst = false;
      fn.apply(context, args);
    } else {
      clearTimeout(timer);
      timer = setTimeout(function () {
        fn.apply(context, args);
      }, delay);
    }
  };
}

这样的你应该在防抖结束之后将isFirst 设置为true,不然下一次就不会立即执行了

Lemon-Cai

Lemon-Cai commented on Jan 12, 2023

@Lemon-Cai

image

关于第六版,这个写法是不是更简洁呢

nibiewabc

nibiewabc commented on Feb 20, 2023

@nibiewabc

image

关于第六版,这个写法是不是更简洁呢

我就说看着有点奇怪,这样舒服多了

ChenKun1997

ChenKun1997 commented on Jun 14, 2023

@ChenKun1997

使用context保存this是没有必要的吧,直接在apply的地方写this也是一样的

Lemon-Cai

Lemon-Cai commented on Jun 14, 2023

@Lemon-Cai
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

      Development

      No branches or pull requests

        Participants

        @huixisheng@chenxiaochun@muyudou@luckymore@Derrys

        Issue actions

          JavaScript专题之跟着underscore学防抖 · Issue #22 · mqyqingfeng/Blog