liujie2019/Blog

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

Opened this issue · 0 comments

[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的节流、防抖及使用场景