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

JS函数去抖(debounce)和节流(throttle) #23

Open
liujie2019 opened this issue Mar 16, 2019 · 0 comments
Open

JS函数去抖(debounce)和节流(throttle) #23

liujie2019 opened this issue Mar 16, 2019 · 0 comments

Comments

@liujie2019
Copy link
Owner

liujie2019 commented Mar 16, 2019

[TOC]
DOM操作比起非DOM交互需要更多的内存和CPU时间,连续尝试进行过多的DOM相关操作可能会导致浏览器挂起,有时候甚至会崩溃。

如果在程序中使用了onresize事件处理程序,当调整浏览器大小的时候,该事件会连续触发。如果在该事件处理程序内部进行了相关DOM操作,其高频率的更改可能会导致浏览器崩溃。为了绕开这个问题,我们可以考虑使用定时器对该函数进行节流。

函数节流背后的基本思想是:某些代码不可以在没有间断的情况下连续重复执行。第一次调用函数,创建一个定时器,在指定的时间间隔之后运行代码。当第二次调用该函数时,它会清除之前的定时器并设置另一个。如果前一个定时器已经执行过了,这个操作就没有任何意义。然而,如果前一个定时器尚未执行,其实就是将其替换为一个新的定时器。目的是在只有在执行函数的请求停止了一段时间之后才执行。

以下场景往往由于事件频繁被触发,因而频繁执行DOM操作、资源加载等重行为,导致UI停顿甚至浏览器崩溃。

  • window对象的resize、scroll事件;
  • 拖拽时的mousemove事件;
  • 射击游戏中的mousedown、keydown事件;
  • 文字输入、自动完成的keyup事件。

实际上对于window的resize事件,实际需求大多为停止改变大小n毫秒后执行后续处理;而其他事件大多的需求是以一定的频率执行后续处理。针对这两种需求就出现了debounce和throttle两种解决办法。

throttle(又称节流)和debounce(又称去抖)其实都是函数调用频率的控制器。

debounce强制函数在某段时间内只执行一次,throttle强制函数以固定的速率执行。在处理一些高频率触发的DOM事件的时候,它们都能极大提高用户体验。

在处理诸如resize、scroll、mousemovekeydown/keyup/keypress等事件的时候,通常我们不希望这些事件太过频繁地触发,尤其是监听程序中涉及到大量的计算或者有非常耗费资源的操作。

有多频繁呢?以mousemove为例,根据DOM Level 3的规定,「如果鼠标连续移动,那么浏览器就应该触发多个连续的mousemove 事件」,这意味着浏览器会在其内部计时器允许的情况下,根据用户移动鼠标的速度来触发mousemove事件。(当然了,如果移动鼠标的速度足够快,比如一下扫过去,浏览器是不会触发这个事件的)。resize、scroll 和 key*等事件与此类似。

1. Debounce(函数防抖)

DOM事件里的debounce概念其实是从机械开关和继电器的去弹跳(debounce)衍生而来的,基本思路就是:把多个信号合并为一个信号。

JavaScript中,debounce函数所做的事情就是:强制一个函数在某个连续时间段内只执行一次,哪怕它本来会被调用多次。我们希望在用户停止某个操作一段时间之后才执行相应的监听函数,而不是在用户操作的过程当中,浏览器触发多少次事件,就执行多少次监听函数。

比如:在某个3s的时间段内连续地移动了鼠标,浏览器可能会触发几十(甚至几百)个 mousemove事件,不使用debounce的话,监听函数就要执行这么多次;如果对监听函数使用100ms去弹跳,那么浏览器只会执行一次这个监听函数,而且是在第3.1s的时候执行的。

现在,我们就来实现一个 debounce 函数。

1.1 具体实现

debounce函数接收两个参数,第一个是要去弹跳的回调函数 fn,第二个是延迟的时间delay。实际上,大部分的完整debounce 实现还有第三个参数immediate,表明回调函数是在一个时间区间的最开始执行(immediate为true)还是最后执行(immediate为false),比如underscore_.debounce。本文不考虑这个参数,只考虑最后执行的情况,感兴趣的可以自行研究。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #box, #test {
            width: 100px;
            height: 100px;
            background-color: aqua;
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <div id="test"></div>
    <script>
        function debounce(fn, delay) {
            var timer = null;
            return function () {
                // 保存函数调用时的上下文和参数
                var that = this;
                var args = arguments;
                // 每次这个返回的函数被调用,就清除定时器,以保证不执行fn
                clearTimeout(timer);
                timer = setTimeout(function () {
                    fn.apply(that, args);
                }, delay);
            }
        }
        const box = document.querySelector('#box');
        const box2 = document.querySelector('#test');
        let time = 0;
        let time2 = 0;
        // 没有采用防抖
        document.addEventListener('mousemove', function() {
            box2.innerHTML = ++time;
        }, false);
        // 采用防抖处理
        document.addEventListener('mousemove', debounce(function() {
            box.innerHTML = ++time2;
        }, 250), false);
        document.addEventListener('mouseleave', function() {
            time = 0;
            time2 = 0;
            box.innerHTML = 0;
            box2.innerHTML = 0;
        }, false);
    </script>
</body>
</html>

实现思路:debounce返回了一个闭包,这个闭包依然会被连续频繁地调用,但是在闭包内部,却限制了原始函数fn的执行,强制fn只在连续操作停止后只执行一次。

再来考虑另外一个场景:根据用户的输入实时向服务器发ajax请求获取数据。我们知道,浏览器触发key*事件也是非常快的,即便是正常人的正常打字速度,key*事件被触发的频率也是很高的。以这种频率发送请求,一是我们并没有拿到用户的完整输入发送给服务器,二是这种频繁的无用请求实在没有必要。

更合理的处理方式是:在用户停止输入一小段时间以后,再发送请求。那么 debounce就派上用场了:

$('input').on('keyup', debounce(function(e) {
	// 发送 ajax 请求
}, 300))
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        #box {
            width: 100px;
            height: 100px;
            background-color: blueviolet;
        }
    </style>
</head>
<body>
    <div id="box"></div>
    <script>
        var box = document.querySelector('#box');
        var height = 100;
        function debounce(fn, delay) {
            var timer = null;
            return function () {
                var that = this;
                clearTimeout(timer);
                timer = setTimeout(function() {
                    fn.call(that);
                }, delay);
            }
        }
        /*
        没有做防抖处理
        window.addEventListener('resize', function() {
            height += 1;
            box.style.height = height + 'px';
        }, false);
        */
        window.addEventListener('resize', debounce(function() {
            height += 1;
            box.style.height = height + 'px';
        }, 250), false);
    </script>
</body>
</html>

2. Throttle(函数节流)

throttle的概念理解起来更容易,就是固定函数执行的速率,即所谓的节流。正常情况下,假设mousemove的监听函数每20ms执行一次,如果设置200ms的节流,那么它就会每200ms执行一次。比如在 1s 的时间段内,正常的监听函数可能会执行 50(1000/20) 次,节流 200ms 后则会执行 5(1000/200)次。

2.1 具体实现

debounce类似,throttle也接收两个参数,一个实际要执行的函数fn,一个执行间隔阈值threshhold

同样的,throttle的更完整实现可以参看underscore_.throttle

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        .wrapper {
            width: 200px;
            height: 200px;
            float: left;
            border: 1px solid #ddd;
            overflow: auto;
            position: relative;
        }
        .wrapper .content {
            height: 100%;
            width: 100%;
            overflow: auto;
        }
        .content .inner {
            height: 6000px;
        }
        .wrapper .desc {
            position: absolute;
        }
        .wrapper .count {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }
        .normal {
            margin-right: 20px;
        }
    </style>
</head>
<body>
    <h3>Try scrolling in the 2 boxes...</h3>
    <div>
        <div class="wrapper normal">
            <div class="desc">Normal scroll</div>
            <div class="content">
                <div class="inner"></div>
            </div>
            <span id="normal" class="count">0</span>
        </div>
        <div class="wrapper throttled">
            <div class="desc">Throttled scroll</div>
            <div class="content">
                <div class="inner"></div>
            </div>
            <span id="throttled" class="count">0</span>
        </div>
    </div>
    <script>
        function throttle(fn, threshhold) {
            // 记录上次执行的时间
            var last;
            // 定时器
            var timer = null;
            // 默认间隔为250ms
            threshhold || (threshhold = 250);
            // 返回函数,每隔threshhold毫秒就执行一次fn函数
            return function () {
                // 保存函数调用时的上下文和参数,传递给fn
                var that = this;
                var args = arguments;
                var now = +new Date();
                // 如果距离上次执行fn函数的时间小于threshhold,就不执行fn
                // 否则执行fn,并重新计时
                if (last && now < last + threshhold) {
                    clearTimeout(timer);
                    // 保证在当前时间区间结束后,再执行一次fn
                    timer = setTimeout(function() {
                        last = now;
                        fn.apply(that, args);
                    }, threshhold);
                }
                else {
                    last = now;
                    fn.apply(that, args);
                }
            }
        }
        var normalCount = 0;
        var throttledCount = 0;
        var normalSpan = document.querySelector('#normal');
        var throttledSpan = document.querySelector('#throttled');
        var normalContent = document.querySelector('.normal .content');
        var throttledContent = document.querySelector('.throttled .content');
        normalContent.addEventListener('scroll', function() {
            normalSpan.innerText = ++normalCount;
        }, false);
        throttledContent.addEventListener('scroll', throttle(function() {
            throttledSpan.innerText = ++throttledCount;
        }, 250), false);
        document.addEventListener('mouseleave', function() {
            normalCount = 0;
            throttledCount = 0;
            normalSpan.innerText = 0;
            throttledSpan.innerText = 0;
        }, false);
    </script>
</body>
</html>

原理也不复杂,相比 debounce,无非是多了一个时间间隔的判断,其他的逻辑基本一致。throttle 的使用方式如下:

$(document).on('mouvemove', throttle(function(e) {
	// 代码
}, 250))

3. debounce和throttle各自的使用场景

  • throttle常用的场景是限制 resize 和 scroll 的触发频率。
  • debounce常用的场景是限制 mousemove 和keydown/keyup/keypress。
3.1 debounce使用场景

第一次触发后,进行倒计wait毫秒,如果倒计时过程中有其他触发,则重置倒计时;否则执行fn。用它来丢弃一些重复的密集操作、活动,直到流量减慢。例如:

  • 对用户输入的验证,不在输入过程中就处理,停止输入后进行验证;
  • 提交ajax时,不希望1s中内大量的请求被重复发送。
3.2 throttle使用场景

第一次触发后先执行fn(当然可以通过{leading: false}来取消),然后wait ms后再次执行,在单位wait毫秒内的所有重复触发都被抛弃。即如果有连续不断的触发,每wait ms执行fn一次。与debounce相同的用例,但是你想保证在一定间隔必须执行的回调函数。例如:

  • 对用户输入的验证,不想停止输入再进行验证,而是每n秒进行验证;
  • 对于鼠标滚动、window.resize进行节流控制。

4. 正真的业务场景:

一个相当常见的例子:用户在你无限滚动的页面上向下滚动鼠标加载页面,你需要判断现在距离页面底部多少。如果用户快接近底部时,我们应该发送请求来加载更多内容到页面。在此debounce没有用,因为它只会在用户停止滚动时触发,但我们需要用户快到达底部时去请求。通过throttle我们可以不间断的监测距离底部多远。

$(document).ready(function(){
  // 这里设置时间间隔为300ms
  $(document).on('scroll', throttle(function(){
    check_if_needs_more_content();
  }, 300));

  // 是否需要加载更多资源
  function check_if_needs_more_content() {     
    var pixelsFromWindowBottomToBottom = 0 + $(document).height() - $(window).scrollTop() - $(window).height();
    // 滚动条距离页面底部小于200,加载更多内容
    if (pixelsFromWindowBottomToBottom < 200){
      // 加载更多内容
      $('body').append($('.item').clone()); 
    }
  }
});

5. debounce和throttle的差异(可视化解释)

image

6. 项目实例

在学习Vue的时候,官网也用到了一个里子,就是用于对用户输入的事件进行了去抖,因为用户输入后需要进行ajax请求,如果不进行去抖会频繁的发送ajax请求,所以通过debounce对ajax请求的频率进行了限制。

methods: {
  // `_.debounce` 是一个通过 Lodash 限制操作频率的函数。
  // 在这个例子中,我们希望限制访问 yesno.wtf/api 的频率
  // AJAX 请求直到用户输入完毕才会发出。想要了解更多关于
  getAnswer: _.debounce(function() {
    if (!reg.test(this.question)) {
      this.answer = 'Questions usually end with a question mark. ;-)';
      return;
    }
    this.answer = 'Thinking ... ';
    let self = this;
    axios.get('https://yesno.wtf/api')
    // then中的函数如果不是箭头函数,则需要对this赋值self
    .then((response) = > {
      this.answer = _.capitalize(response.data.answer)
    }).
    catch ((error) = > {
      this.answer = 'Error! Could not reach the API. ' + error
    })
  }, 500) // 这是我们为判定用户停止输入等待的毫秒数
},

参考文档

  1. Debounce 和 Throttle 的原理及实现
  2. debounce 和 throttle 的可视化差异
  3. 函数去抖(debounce)和函数节流(throttle)
  4. debounce与throttle区别
  5. JS高级技巧学习小结
  6. 7分钟理解JS的节流、防抖及使用场景
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant