Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
mqyqingfeng opened this issue Jun 2, 2017 · 242 comments
Open

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

mqyqingfeng opened this issue Jun 2, 2017 · 242 comments

Comments

@mqyqingfeng
Copy link
Owner

mqyqingfeng commented Jun 2, 2017

前言

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

  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,对作者也是一种鼓励。

@wcflmy
Copy link

wcflmy commented Jun 6, 2017

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

@mqyqingfeng
Copy link
Owner Author

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

@xxxgitone
Copy link

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

@mqyqingfeng
Copy link
Owner Author

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

@hujiulong
Copy link

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

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

@mqyqingfeng
Copy link
Owner Author

@hujiulong requestAnimationFrame 确实是神器呐~

@zhouyingkai1
Copy link

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

@YeaseonZhang
Copy link

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

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

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

@mqyqingfeng
Copy link
Owner Author

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

@xietao91
Copy link

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

@Junrui-L
Copy link

大神厉害了,学习了

@chenxiaochun
Copy link

请问,您用的什么录屏软件啊,QuickTime吗?

@jawil
Copy link

jawil commented Aug 28, 2017

@chenxiaochun 推荐一下我用的。mac 上,我在用Gifox, snagit。但其实,giphy 也不错
https://giphy.com/apps/giphycapture
App Store 直接下

http://recordit.co/ 这个也不错,简单粗暴,就是没有配置选项

还有这个也是免费的,而且 windows mac 都支持。。收费除了 SnagIt(支持 windows 和 mac),还有 Gifox(只支持 mac)

@chenxiaochun
Copy link

chenxiaochun commented Aug 29, 2017

@jawil@mqyqingfeng 发现了另一款截屏录屏神器,推荐给两位啊。不仅功能强大,颜值也很高。http://jietu.qq.com/

@mqyqingfeng
Copy link
Owner Author

@chenxiaochun 我用的是一个 mac 下的叫做 licecap 的录制 GIF 的小软件,免费而且不需要安装,直接打开就能用

@stormqx
Copy link

stormqx commented Sep 2, 2017

用promise应该可以也可以返回setTimeout中回调函数的结果。

@mqyqingfeng
Copy link
Owner Author

@stormqx 确实如此,用 promise 可以实现这个效果,不过 underscore 中没有实现 promise,所以这里也就没有使用 promise,不过说起来,ES6 系列中会讲到从零实现一个 promise ,欢迎关注哈~

@codingwesley
Copy link

写的很好 感谢赐教!

@zhufangmin1990
Copy link

非常感谢分享,受教了!

@lynn1824
Copy link

good!

@dengnan123
Copy link

为什么不来讲解 lodash 呢

@lynn1824
Copy link

lynn1824 commented Oct 30, 2017

学以致用,感谢楼主!

/**
 * Created by Administrator on 2017/10/30.
 */
function setResult(tag, content, color) {
    if(tag && typeof tag == 'object') {
        tag.innerHTML = content;
        tag.style.color = color;
    }
}

var validateEmail = function (e) {
    // 邮箱正则
    var reg = /^[a-z0-9]+(\w|_)+@+([a-z0-9]){2,4}.[a-z]{2,4}$/;
    var currentValue = e.target.value;
    var resultTag = document.getElementById('resultEmail'),
        content = reg.test(currentValue) ? '邮箱正确' : '请输入正确的邮箱',
        color = reg.test(currentValue) ? 'green' : 'red';
    setResult(resultTag, content, color);
}

var validateMobile = function (e) {
    // 手机号正则
    var reg = /^1(3|4|5|7|8){1}[0-9]{9}$/
    var currentValue = e.target.value;
    var resultTag = document.getElementById('resultMobile'),
        content = reg.test(currentValue) ? '手机号正确' : '请输入正确的手机号',
        color = reg.test(currentValue) ? 'green' : 'red';
    setResult(resultTag, content, color);
}

// 防抖
function debounce(func, wait) {
    var timeOut;

    return function () {
        if(timeOut) {
            clearTimeout(timeOut);
        }
        // 保存this上下文,参数
        var that = this, args = arguments;
        timeOut = setTimeout(function () {
            func.apply(that, args);
        }, wait)
    }
}

document.getElementById('emailIpt').onkeyup = debounce(validateEmail, 1000);
document.getElementById('mobileIpt').onkeyup = debounce(validateMobile, 1000);

@YuFengjie97
Copy link

被第四版绕晕了

@wangenze267
Copy link

想问一下,事件用js动态绑定(跟示例一样),就好使,如果是在html标签绑定就不好使,是什么原因呢?

@mqyqingfeng
Copy link
Owner Author

@YuFengjie4 第四版添加了 immediate 的功能,具体是觉得哪里没有理解呢?

@mqyqingfeng
Copy link
Owner Author

mqyqingfeng commented Nov 30, 2021

@wangenze267 使用 HTML 标签也是可以的,你可以把你的 demo 分享出来,我帮你看一下问题所在

<!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" onmousemove="getUserAction()"></div>
    <script>
        var count = 1;
        var container = document.getElementById('container');

        function getUserAction() {
            container.innerHTML = count++;
        };
    </script>
</body>

</html>

@xiaoguoqing-tae
Copy link

xiaoguoqing-tae commented Nov 30, 2021 via email

@xxxxinc
Copy link

xxxxinc commented Dec 13, 2021

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

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);
    }
  };
}

@Madaovo 这种方案只是第一次立即执行,之后还是会延迟执行。

@orime
Copy link

orime commented Jan 17, 2022

博主的immediate确实起到了初始执行一次的效果,但接下来的行为会变成停顿wait秒后,马上触发一次再接着wait,因为wait过后timeout又变成了null,
image
这种行为有点不太像常规防抖,做了一个小改动,可以达到文章所说要求:
image

@fwqaaq
Copy link

fwqaaq commented Jan 18, 2022

//immadiate 立即执行 ,点一次的时候触发一次,点多次的时候先触发一次,间隔wait再触发第二次


  function debounce(fn, wait, immediate=true) {

      let timer;

      return function () {

          const context = this;

          const hasImmeAndFirst = !timer&&immediate



          if (timer) clearTimeout(timer);

          timer = setTimeout(() => {

              if (!hasImmeAndFirst) {

                  fn.apply(context, arguments)

              }

              timer = null;

          }, wait)

          if (hasImmeAndFirst) {

              fn.apply(context, arguments)

          }

      }

  }

大家可以参考我的代码,没有问题

这种更像是立即执行加节流,而不是防抖

@xuedafei
Copy link

xuedafei commented Feb 2, 2022

最初看第四版本理解成immediate为true时,立即出发一次,然后每隔一定时间触发像截流一样,怎么看代码都理解不了,还以为博主写错了,后来看了评论后又仔细看了一遍,理解错immediate的作用力,是自己马虎了

@SanpLee
Copy link

SanpLee commented Mar 11, 2022

你好 ,我不太明白 这段代码的作用
timeout = setTimeout(function() {

   timeout = null

}, wait);

@MrWen-lab
Copy link

鼠标只滑动一次不动的话会多执行一次,这个有优化方向吗

@DefeatLaziness
Copy link

大佬,遇到理解不了是选择暂时跳过还是继续刚

@Emt-lin
Copy link

Emt-lin commented Mar 24, 2022

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

@DefeatLaziness
Copy link

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

好咧,谢谢大佬

@Kento97
Copy link

Kento97 commented Mar 28, 2022

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

@Marszht
Copy link

Marszht commented Apr 17, 2022

关于为什么"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
Copy link

ghost commented Jul 2, 2022

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

总感觉给个返回值逻辑不是很对,因为fn压根就没执行,但是debounce的逻辑执行了就会有undefined返回,会不会给debounce方法引入一个callback参数作为fn执行取返回值更好一点

// 防抖变形: 取fn返回值
function debounce(fn, time = 1000, callback = () => { }) {
  let timer = null;

  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      callback(fn(...args));
    }, time);
  }
}

@xi2And33
Copy link

xi2And33 commented Jul 7, 2022

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

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
Copy link

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

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

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

@YuFengjie97
Copy link

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

@Yang-y-good
Copy link

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

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
Copy link

image

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

@nibiewabc
Copy link

image

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

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

@ChenKun1997
Copy link

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

@Lemon-Cai
Copy link

Lemon-Cai commented Jun 14, 2023 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests