【循序渐进】搞懂防抖和节流

1,361 阅读9分钟

防抖和节流大家应该都不陌生,作为性能优化(可以是前端页面的响应性能方面,也可以是降低服务器压力方面)的必要技巧,我们说什么都要好好讲一讲,另外也本着一天一个小目标,循序渐进吃透一个知识点的劲头,今天,我们就来细聊下防抖和节流的神奇魔法。

本文将会从基础版本的防抖和节流开始,并逐渐完善一个能适配大多数使用场景的完善版本,在此过程中可能会涉及到一些 EventLoop的概念,但是我们并不会细讲,之后会有一篇关于EventLoop的文章出来,进行深入研究。

一、防抖 Debounce

防抖的应用场景相比节流要常见很多,举个最常见的例子,很多Web页面上都会与这么一个搜索框:用于模糊搜索指定内容,我们就以百度搜索为例:

这个搜索框的功能也很简单,就是当你在搜索框里输入内容时就调用接口向服务端传递该内容进行内容搜索,那我们就来实现这个功能:

...
const API = XXX
const input = document.documentElement.getElementById('keyWord')
input.oninput = function (e) {
    const keyWord = e.target.value
    API.request({
        keyWord
    })
    ...
}

很快我们就完成了这个功能,但是!当你在搜索框里快速输入内容时会发现存在一个严重的问题:超高的频率去调用接口。

而这些调用中很多都是无谓的,因为当用户在快速地连续地输入内容的过程中其实并不关心此时的反馈内容,因为他还在键入他想搜索的关键词,而只有在恰当的停顿后,我们才会认为此时的内容可能是他想搜索的关键词了,而这时候的请求才是有价值的。

因此,这个可以“智能的”、“适时的”去做“发送请求”这件事的功能或实现方式就叫做“防抖”。

防抖,在持续的不间断的调用中,程序在设置一定的阈值范围内并不会立即响应调用,而是在某次调用距离上次调用时间超过阈值时才会去响应调用。

用一张图来描述的话,就是下面这个样子:

我们来看一下百度搜索框在快速输入内容是的网络请求:

其中红框框出来的就是百度使用“防抖”技术避免的无谓的请求,进一步查看这个搜索框绑定的oninput事件:

startCircle: function() {
    var e = this;
    e.timer || ($(e.ipt).trigger("start", [e]),
    e.timer = setTimeout(function() {
        e.check(),
        e.timer = setTimeout(arguments.callee, 200)
    }, 200),
    supportInputEvent && $(e.ipt).bind("input", function() {
        e.check()
    }))
},
stopCircle: function() {
    var e = this;
    e.timer && (clearTimeout(e.timer),
    supportInputEvent && $(e.ipt).unbind("input"),
    e.timer = null,
    $(e.ipt).trigger("stop", [e]))
}

这只是输入框响应输入内容变化事件的局部实现代码,但是我们依旧可以一眼就能看出端倪来,即合理地组织setTimeoutclearTimeout就可以实现“防抖”功能,落实到我们自己的代码上:

/**
* 基础版本
* @param {fn} Function 需要防抖调用的函数
* @param {delay} Function 防抖的时间阈值(ms)
*/
function debounce (fn, delay=300) {
    let timer = null;
    return function(...args) {
        // 每次触发新的函数调用,都将之前的延时调用任务清除,并创建下一次的延时调用任务
        timer && clearTimeout(timer)
        timer = setTimeout(() => {
            fn.apply(this, args)
            timer = null
        }, delay)
    }
}

上面的代码已经基本可以满足我们的需求了,但是细想下来你会发现它有一个缺点:即使只调用了一次,但是这个调用要在延迟delay时间后才会响应,而这并不能使用所有的应用场景。

因此,我们可以进一步完善它的功能和特性,使得它可以支持传参配置是否立即响应第一次调用:

/**
* 改良版本
* @param {method} Function 需要防抖调用的函数
* @param {delay} Function 防抖的时间阈值(ms)
* @param {immediate} Boolean 是否需要立即执行(即在一个阈值范围的头部执行,如果触发时还处于上一个阈值范围内,则不会触发)
*/
function debounceX (method, delay, immediate) {
    let timer = null
    return function(...args) {

        // 每次进来都需要清空前一个延时执行任务(有的话)之后再分策略处理不同场景,其实这才是【防抖】的精髓
        timer && clearTimeout(timer)

        // 在一个阈值范围的头部执行
        if (immediate) {
            // 保证在本轮连续触发的周期内,只有第一次触发会"立即执行",后续的每次触发都需要更新定时任务延时触发;
            // 同时也要保证本轮周期结束,下一轮周期开始时依旧可以"立即执行"
            immediate = false
            timer = setTimeout(() => { 
                timer = null
                immediate = true
             }, delay)
            // 立即执行
            method.apply(this, args)
        } else {
            // 否则在一个阈值范围的尾部执行
            timer = setTimeout(() => {
                timer = null
                method.apply(this, args)
            }, delay)
        }
        
    }
}

至此,我们就完美地实现了一个可以适配大部分场景的“防抖”功能函数,那么我们自己的搜索框功能也就可以配备上了:

function search (e) {
    const keyWord = e.target.value
    API.request({
        keyWord
    })
    ...
}
input.oninput = debounceX(search, 200)

二、节流 Throttle

对于节流,在Web开发工作中,最常见的场景应该就是响应一些页面上的动画和滚动等操作。

举个最常见的例子:页面上存在某模块A,当A滚动到并超过页面的最顶部时需要“吸顶”展示(即fixed在页面顶部的固定位置)。针对这个功能,我们自然而然的写法就是:

window.onsrcoll = function (e) {
    const nodeA = document.documentElement.getElementById('A')
    const rect = nodeA.getBoundingClientRect() 
    const top = rect.top
    if (top < 0) {
        nodeA.classList.add('fixed')
    } else {
        nodeA.classList.remove('fixed')
    }
}

如果我们的代码就这样写,我们会发现它会和我们最初写的搜索框的例子一样,都会频繁地执行事件回调函数。

那我们可以用debounce来解决这个问题吗?答案是不可以,不信的话你可以试一下,你会发现当你快速滚动页面把这个模块A滚动到页面顶部以上时,它会在delay后才会“吸顶”,然后再快速滚动进入到页面内时,它又会在delay后才会变为“不吸顶”,怎么样都慢半拍。

事实证明,这种场景是不能使用debounce来解决的,我们要使用它的小伙伴throttle来解决,类似的场景还有resizemousemove等事件。

这些事件都有两个共同特点,即:

  1. 持续触发事件,但是很多触发都是无效的,如触发频率超出了屏幕刷新率(60hz,即两次触发的时间间隔小于16ms);
  2. 对事件的响应实时性要求比较高,但是超出屏幕刷新率(60hz)的响应也是无效的。

其中,如果我们想要满足60hz这个硬件技术规格,就需要使用到requestAnimationFrame这个浏览器提供的API,但是本文的重点并不是它,我们只是把这个场景深究了一下。但是核心特点是不变的,即这些事件会产生很多无效的操作,我们应该避免它们。此时,节流功能应运而生。

节流,在持续的不间断的调用中,程序在设置一定的阈值范围内只会响应一次调用,即以固定的频率响应调用。

用一张图来描述的话,就是下面这个样子:

落实到代码上最简单的实现方式就是:

/**
* 基础版本
* @param {fn} Function 需要节流调用的函数
* @param {delay} Function 节流的时间阈值(ms)
*/
function throttle (fn, delay=300) {
    let startTime = Date.now();
    let timer = null;
    return function (...args) {
        const remaining = delay - (Date.now() - startTime)
        if (remaining <= 0) {
            // 剩余时间小于0时,必须清楚上一轮的延时任务,避免上一轮的延时任务由于各种原因没有按时执行,如果不清除就可能导致执行两次
            timer && clearTimeout(timer)
            method.apply(this, args)
            startTime = Date.now()
        } else if (timer === null) {
            timer = setTimeout(() => {
                method.apply(this, args)
                startTime = Date.now()
                // 必须置为null,以保证下一轮调用可以进到else if分支来
                timer = null
            }, remaining)
        }
  }
}

这样一个throttle功能已经基本上能满足大部分的应用场景了,但是在社区中有很多优秀的js库实现了可以自由配置一些行为特性的“节流功能”,如:underscorelodash等。

下面以underscore中的throttle为例,我们来逐行分解下分析它的实现原理和设计思想:

/**
* 升级版(可配置事件的触发执行模式:'头部触发事件' || '头部触发事件')  来自underscore
* @param {*} method  回调函数
* @param {*} delay   延时阈值
* @param {*} options 如果你想设置'头部触发事件',传递{leading: false},如果你设置'头部触发事件',传递{trailing: false}。
* 两者不能同时设置为false,否则程序无法正常执行
*/
function throttle (method, delay=300, options={}) {
    let timer = null
    // previous用来标记上一次执行时间戳,其初始值设为0是有特殊用意的 : 
    // 如果options.leading !== false, 即需要在头部触发, 会使程序直接计算 remaining = delay - (now - previous),
    // 此时previous的值为0,而now的值是时间戳,意味着remaining的值必定是小于0的负数,所以直接触发事件
    let previous = 0 
    return function () {
        let self = this
        let args = Array.from(arguments)
        let now = Date.now()
        //  !previous成立,只有两种情况: 
        //    1. 绑定节流后的第一次触发事件时;
        //    2. 非第一次触发事件,并且options.leading === false。  
        //  这两种场景都是表示一个执行周期结束了,可以进入下一个执行周期了(第一次触发事件,也可以变相理解为"第0个周期结束,开始第一个周期")。  
        if (!previous && options.leading === false) {
            previous = now
        }
        // 剩余时间
        const remaining = delay - (now - previous)
        // ***重点***:
        // 1. 既可以满足每次触发都判断是否应该在"头部执行"
        // 2. 也可以避免设置了"尾部执行",而延时任务因为【任务队列】的排队问题可能出现的没有"准时执行"的问题
        if (remaining <= 0 || remaining > delay) {
            if (timer) {
                // 要及时清除那个没有"准时执行"的延时任务
                clearTimeout(timer)
                timer = null
            }
            previous = now
            method.apply(self, args)
            if (!timer) self = args = null
        //  !timer成立的情况只有两种:  
        //    1. 第一次触发事件,timer的默认值就是null(这种情况只会在第一次触发事件,且设置了leading = false, 逻辑才会走到这)
        //    2. 非第一次触发事件,且一个执行周期已经结束(包括直接触发回调和延时任务触发回调,都触发过回调了)
        } else if (!timer && options.trailing !== false) {
            timer = setTimeout(function () {
                // 重置previous,以保证(previous === 0 && options.leading === false)成立,同时也可以在'头部执行'策略时记住上一次的执行时间
                previous = options.leading === false ? 0 : Date.now()
                timer = null
                method.apply(self, args)
                if (!timer) self = args = null
            }, remaining)
        }
    }
}

代码中一些比较重要的设计思想和变量的用途我都打上了注释,细品下来还是有很多精妙之处的,如previous变量的巧妙用法等。整体感觉下来就是多看些框架源码的实现会有很多意外的收获,还是要继续加油啊打工人。

三、文末总结

在深入地理解并实现了防抖和节流的功能之后,我们不难总结出一些有价值的结论,如:

  1. 防抖解决的是那些持续的input可以持续地延迟output的交互行为,以搜索框功能为例,即:用户在持续输入内容时(input)会导致调用接口这件事情(output)一直被延迟执行,直到delay的时间范围内没有新的input才会执行;
  2. 节流解决的也是那些持续的input,对实时响应output要求较高的交互行为,但是和防抖不一样,它不会延迟响应方法的执行(output),只是会保证在一定周期内的多个input只能有一次响应被output

能实现防抖和节流功能只是基本功,而能够掌握它们的实现原理并可以针对的不同应用场景使用正确的解决方案是需要花费很多功夫的。

四、参考文章

  1. js防抖和节流
  2. 浅谈js防抖和节流