防抖与节流(概念,理解,源码实现,应用场景等)

60 阅读12分钟

为什么要使用防抖函数和节流函数(作用)?

Debounce 和 throttle 是我们在 JavaScript 中使用的两个概念,用于增强对函数执行的控制,这在事件处理程序中特别有用。防抖与节流是很相似(但不同)的概念,简单来说就是一个能控制一段时间某个函数的执行次数的方案,用来优化计算机或网络资源。再说白点就是当你的函数高频率执行时能让你的这个方法少执行几次,如果是异步的,可以少几次网络请求,这样就优化了资源。

防抖(Debounce)

1. 概念

防抖是指当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才会执行一次,如果设定时间到来之前又触发了事件,就重新开始延时。也就是说当一个用户一直触发这个函数,且每次触发函数的间隔小于既定时间,那么防抖的情况下只会执行一次

通俗理解就是:防止用户频繁触发一个事件,只执行该事件的最后一次。

e.g.

比如我们设置了一个时间间隔 5 秒,当事件触发的间隔超过 5 秒,(回调)函数才会执行,如果在 5 秒内,事件又被触发,则刷新这个 5 秒,至少5秒后事件没被触发才执行函数。

类比

乘公交车,一直有人陆陆续续上车(事件触发),司机心想30s(时间间隔) 内没人继续上,再开车(函数执行)。

2. 实现及原理

关联概念

  • 事件监听
  • 作用域
  • 闭包
  • this

作用域

这是干什么的?

作用域是一套规则,它规定了如何查找变量,也就是确定当前执行代码对变量的访问权限

简单来说,我们在写代码时,就已经把代码分隔成一个个代码块(区域),在这些代码块中定义许多变量,而作用域就规定哪些代码块能访问哪些变量。

这些变量的访问权限是在你代码写出来就已经确定的了,不能改了,是静态的了,所以也称为静态作用域,或 词法作用域(lexical scoping)

如果一个变量或者其他表达式不在当前的作用域中,那么它就是不可用的。

词法作用域(静态作用域)

词法作用域就是定义在词法阶段的作用域

换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。

闭包

简单来说就是我在写代码时候我们决定了这些变量的访问权限也就是词法作用域。然而我们可以用些手段(闭包)如return 一个函数。这样即使这个function在当前词法作用域外执行,也能访问原来定义时词法作用域内的变量,(间接地访问了这些变量)。

下面我们来看一段代码, 清晰地展示了闭包:

function foo() {
    var a = 2;
    function bar() {
        console.log(a);
    }
    return bar;
}
var baz = foo();
baz(); // 2       这就是闭包的效果。
复制代码

我们观察到: 按照词法作用域来说,baz的词法作用域是全局的,外部不能访问foo内部作用域的变量 a,但是 a确实被正常打印了。

这就是闭包的主要作用,他使得当这个函数在定义的词法作用域以外的地方被调用时可以继续访问定义时的词法作用域

这样做的话一是可以读取函数内部的变量,二是可以让这些变量的值始终保存在内存中

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

this

this 是个什么样的机制,到底如何分析它的指向
  • this 是 javascript 中的一个关键字,它提供了一种更优雅的方式来 隐式“传递” 一个对象引用,因此可以将 API 设计得更加简洁并且易于复用

  • this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

  • 当一个函数被调用时,会创建一个执行上下文。这个记录会包含函数在哪里被调用(调用栈,执行栈)、函数的调用方法、传入的参数等信息。this 就是记录的其中一个属性,会在函数执行的过程中用到。

  • this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被如何调用。

理解了这些概念后,再来简单说明下防抖函数的源码实现。

//---------------------------------测试用例--------------------------------
// 用户高频率执行的函数(需要防抖的函数),但可能是个异步请求列表,成本比较高需要优化
function userHighRequencyAction(e, content) {
    console.log(e, content);
}

// 给这个高频的方法,加防抖方案输出一个防抖的function
var userDebounceAction = debounce(userHighRequencyAction, 1000);

// 如何触发那个高频函数 绑定一个onmousemove事件,来模拟高频触发  $(1)事件监听$
document.onmousemove = function (e) {
    userDebounceAction(e, 'test debounce'); // 给防抖函数传个参
}
//---------------------------------实现-----------------------------------

function debounce(func, wait) {              // -----> $(2)作用域和闭包$
    let timer; 
    return function () { 
        let context = this;                 
        let args = arguments;
        if (timer) {
            clearTimeout(timer);
        }
        timer = setTimeout(function () {
            func.apply(context, args);       // -----> $(3)this$
            // 其实就是 context.func(args)
        }, wait);
    };
}

简单说明

就是利用 setTimeout 这个WebApi的延迟效果,设置一个定时器,当没有到达延迟时间就清除定时器(clearTimeout),并建立一个新的定时器,继续等延迟时间,如此循环。

关于源码实现的几个疑问

  • 为什么要返回函数或者说为什么要用 闭包
    • 其实debounce函数只调用了一次,后面调用的全是闭包函数,其实了解闭包都知道这样 timer 定时器的变量不被gc回收,这样下次执行时仍然指向的是上一次设置的定时器。
  • 为什么要绑定 this
    • 因为func 执行的时候this指向全局对象(浏览器中是window),根据词法作用域,可以在外层用个变量保存下 this, 再用 apply 进行显示绑定。
  • 为什么要有 arguments
    • 因为 JavaScript 在事件处理函数中会提供事件对象 event, 所以我们得把参数一并传入。

3. 应用场景

  • 搜索输入框(Autocomplete),当不再输入后的几百毫秒再去发送请求,减少服务器压力。

    • 看一个🌰(栗子):
//模拟一段ajax请求
function ajax(content) {
  console.log('ajax request ' + content)
}

let inputa = document.getElementById('unDebounce')

inputa.addEventListener('keyup', function (e) {
    ajax(e.target.value)
})

看一下运行结果:

2018-09-04 09 23 46

可以看到,我们只要按下键盘,就会触发这次ajax请求。不仅从资源上来说是很浪费的行为,而且实际应用中,用户也是输出完整的字符后,才会请求。下面我们优化一下:

//模拟一段ajax请求
function ajax(content) {
  console.log('ajax request ' + content)
}

function debounce(fun, delay) {
    return function (args) {
        let that = this
        let _args = args
        clearTimeout(fun.id)
        fun.id = setTimeout(function () {
            fun.call(that, _args)
        }, delay)
    }
}
    
let inputb = document.getElementById('debounce')

let debounceAjax = debounce(ajax, 500)

inputb.addEventListener('keyup', function (e) {
        debounceAjax(e.target.value)
    })

看一下运行结果:

2018-09-04 09 29 50

可以看到,我们加入了防抖以后,当你在频繁的输入时,并不会发送请求,只有当你在指定间隔内没有输入时,才会执行函数。如果停止输入但是在指定间隔内又输入,会重新触发计时。

  • 注册框(判断是否重复用户名)

  • 提交按钮的点击。

  • 不停改变浏览器窗口大小会触发多次 resize 事件,引起浏览器的重排

节流(throttle)

1. 概念

节流是指动作绑定事件后,动作触发事件,在这段时间内,如果动作又发生,则无视该动作,直到事件执行完后,才能重新触发

通俗理解就是:在指定的时间间隔内,只允许我们的函数执行一次。

e.g.

比如一个事件在被疯狂触发,本来每秒执行几百次(回调)函数,而你使用函数节流设了个时间间隔 1s,那么这个函数在1s 内只会执行一次。

类比

在公交车总站乘坐公交车,不管车上是否有人,不管是否有人上车,若规定了10分钟发一次车(时间间隔),那么只有在这辆车发车之后下辆车才会发车。

2. 实现及原理

  • 时间戳实现

    function throttle(fn, wait) {
      var args;
      // 前一次执行的时间戳
      var previous = 0;
      return function() {
        // 将时间转为时间戳
        var now = +new Date();
        args = arguments;
        // 时间间隔大于延迟时间才执行
        if (now - previous > wait) {
          fn.apply(this, args);
          previous = now;
        }
      };
    }
    
    • 触发监听事件,回调函数会立刻执行(初始的previous为 0,除非设置的时间间隔大于当前时间的时间戳,否则差值肯定大于时间间隔)
    • 停止触发后,无论停止时间在哪,都不会再执行。例如,1 秒执行 1 次,在 4.2 秒停止,则第 5 秒不会再执行 1 次
    • 实现原理:当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)。结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳,结果小于设置的时间周期,则不执行函数
  • 定时器实现

    function throttle(fn, wait) {
      var timer, context, args;
      return function() {
        context = this;
        args = arguments;
        // 如果定时器存在,则不执行
        if (!timer) {
          timer = setTimeout(function() {
            // 执行后释放定时器变量
            timer = null;
            fn.apply(context, args);
          }, wait);
        }
      };
    }
    
    • 回调函数不会立刻执行,要在 wait 秒后第一次执行,停止触发闭包后,如果停止时间在两次执行之间,则还会执行一次

    • 实现原理:简单来说,当触发事件的时候,设置一个 timer, 再次触发事件的时候 timer 存在(不为null),则不执行,直到函数执行了,把timer置空,并启动设置下一个定时器。这也就保证了 wait时间内函数只会执行一次。

3. 应用场景

  • 无限滚动列表。用户向下滚动列表时,需要时刻检查用户屏幕离底部有多远。如果用户接近底部,我们应该请求下一页内容并将其附加到页面上。如果不对函数调用的频率加以限制的话,那么可能我们滚动一次滚动条就会产生N次的调用,损耗浏览器引擎。使用节流函数限制接口调用频率,优化性能。

  • 高频点击提交按钮,比如你抢票的时候,多次点击抢票按,只有在这一次抢票失败后才会执行下一次。

    • 看一个🌰:

  function throttle(fun, delay) {
        let last, deferTimer
        return function (args) {
            let that = this
            let _args = arguments
            let now = +new Date()
            if (last && now < last + delay) {
                clearTimeout(deferTimer)
                deferTimer = setTimeout(function () {
                    last = now
                    fun.apply(that, _args)
                }, delay)
            }else {
                last = now
                fun.apply(that,_args)
            }
        }
    }

    let throttleAjax = throttle(ajax, 1000)

    let inputc = document.getElementById('throttle')
    inputc.addEventListener('keyup', function(e) {
        throttleAjax(e.target.value)
    })
复制代码

看一下运行结果:

2018-09-04 09 36 49

可以看到,我们在不断输入时,ajax会按照我们设定的时间,每1s执行一次。

总结

  • 函数防抖和函数节流都是防止事件在某一时间频繁触发,但是这两者之间的原理却不一样。
  • 函数防抖是某一段时间内只执行最后一次,而函数节流是某一段时间内只执行一次(间隔时间执行)。

防抖函数思路

  • 由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)

    • 回调函数fn
    • 延时时间delay
  • debounce 函数返回一个闭包,闭包被频繁的调用

    • debounce 函数只调用一次,之后调用的都是它返回的闭包函数
    • 在闭包内部限制了回调函数fn的执行,强制只有连续操作停止后执行一次
  • 使用闭包是为了使指向定时器的变量不被gc回收

    • 实现在延时时间delay内的连续触发都不执行回调函数fn,使用的是在闭包内设置定时器setTimeOut
    • 频繁调用这个闭包,在每次调用时都要将上次调用的定时器清除
    • 被闭包保存的变量就是指向上一次设置的定时器

节流函数思路

  • 有两种主流实现方式

    • 使用时间戳
    • 设置定时器
  • 节流函数 throttle 调用后返回一个闭包

    • 闭包用来保存之前的时间戳或者定时器变量(因为变量被返回的函数引用,所以无法被垃圾回收机制回收
  • 时间戳方式

    • 当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)
    • 结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳
    • 结果小于设置的时间周期,则不执行函数
  • 定时器方式

    • 当触发事件的时候,设置一个定时器
    • 再次触发事件的时候,如果定时器存在,就不执行,直到定时器不存在,然后执行函数,清空定时器
    • 设置下个定时器

参考文章