阅读 74

JS 防抖和节流实现详解

概念

平时开发中常遇到的场景:

  1. 页面resize,scroll事件,常见于需要做页面适配的时候,需要根据最终呈现的页面情况进行dom渲染。
  2. 搜索框input事件,例如要支持输入实时搜索,或者在输入过程中,每间隔一段时间进行搜索,或者当用户输入完成,然后开始搜索。
  3. 在页面的无限加载场景下,我们需要用户在滚动页面时,每隔一段时间发一次 Ajax 请求,而不是在用户停下滚动页面操作时才去请求数据。

函数防抖(debounce) 函数节流(throttle) 都是为了限制函数的执行频次,以优化函数触发频率过高导致的响应速度跟不上触发频率,出现延迟或卡顿的现象。

函数防抖(debounce)

防抖: 触发高频事件,n秒后,函数会执行一次,如果n秒内高频事件再次被触发,则重新计算时间。

如下图,持续触发scroll事件时,并不执行handle函数,当1000毫秒内没有触发scroll事件时,才会执行handle函数。

image.png

原理是维护一个计时器,规定在delay时间后触发函数,但是在delay时间内再次触发的话,就会取消之前的计时器而重新设置。这样一来,只有最后一次操作能被触发。

举一个常见的功能,很多网站会提供这么一个按钮:用于返回顶部。

image.png

这个按钮会在滚动到距离顶部一定位置之后才出现,那么我们现在抽象出这个功能需求-- 监听浏览器滚动事件,返回当前滚条与顶部的距离

这个需求可以直接这样写:

function showTop  () {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.addEventListener('scroll', showTop)
复制代码

但是! 在运行的时候会发现存在一个问题:这个函数的默认执行频率,太!高!了!。  高到什么程度呢?以chrome为例,我们可以点击选中一个页面的滚动条,然后点击一次键盘的【向下方向键】,会发现函数执行了8-9次

image.png

然而实际上我们并不需要如此高频的反馈,毕竟浏览器的性能是有限的,不应该浪费在这里,所以接着讨论如何优化这种场景。

基于上述场景,首先提出第一种思路:在第一次触发事件时,不立即执行函数,而是给出一个期限值比如1000ms,然后:

  • 如果在1000ms内没有再次触发滚动事件,那么就执行函数
  • 如果在1000ms内再次触发滚动事件,那么当前的计时取消,重新开始计时

效果:如果短时间内大量触发同一事件,只会执行一次函数。

实现:既然前面都提到了计时,那实现的关键就在于setTimeout这个函数,由于还需要一个变量来保存计时,考虑维护全局纯净,可以借助闭包来实现:

/*
 * 防抖(debounce)函数
 * @param { function } fn 需要防抖的函数
 * @param { number } delay 毫秒,防抖期限值
 */
function debounce(fn, delay){
    let timer = null //借助闭包
    return function() {
        const context = this;               
        const args = arguments; 
        
        if(timer) clearTimeout(timer)
        
        timer = setTimeout(() => {
            fn.apply(context, args);
            timer = null // 执行完毕清除计时器
        }, delay)
    }
}
// 然后是旧代码
function showTop() {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.addEventListener('scroll', debounce(showTop, 1000))
复制代码

此时会发现,必须在停止滚动1秒以后,才会打印出滚动条位置。

函数节流(throttle)

节流:高频事件触发,但在n秒内只会执行一次真正的事件处理函数,所以节流会稀释函数的执行频率。原理是通过判断是否有延迟调用函数未执行。

如下图,持续触发scroll事件时,并不立即执行handle函数,每隔1000毫秒才会执行一次handle函数。

image.png

我们还是以上一个例子继续说明,使用上面的防抖方案来处理问题的结果是:

如果在限定时间段内,不断触发滚动事件(比如某个用户闲着无聊,按住滚动不断的拖来拖去),只要不停止触发,理论上就永远不会输出当前距离顶部的距离。

但是如果产品同学的期望处理方案是:即使用户不断拖动滚动条,也能在某个时间间隔之后给出反馈呢?

思路: 在触发的滚动事件时间范围内,每间隔一段时间,执行一次真正的事件处理函数。

节流throttle代码(定时器):

/*
 * 节流(throttle)函数
 * @param { function } fn 需要节流的函数
 * @param { number } delay 毫秒,节流期限值
 */
function throttle(fn, delay){
    let timer = null
    return function() {
       const context = this;               
       const args = arguments; 
       
       //休息时间 暂不接客
       if(timer) return
       
       // 工作时间,执行函数并且在间隔期内把状态位设为无效
        timer = setTimeout(() => {
            fn.apply(context, args);
            timer = null;
        }, delay)
    }
}

// 以下照旧
function showTop() {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.addEventListener('scroll', throttle(showTop, 1000))
复制代码

当触发事件的时候,我们设置一个定时器,再次触发事件的时候,如果定时器存在,就不执行,直到delay时间后,定时器执行执行函数,并且清空定时器,这样就可以设置下个定时器。当第一次触发事件时,不会立即执行函数,而是在delay秒后才执行。而后再怎么频繁触发事件,也都是每delay时间才执行一次。当最后一次停止触发后,由于倒数第二次触发只会存在定时器已清除和未清除这两种情况,而这两种情况,最后一次触发后,都会有定时器存在(未到时间的定时器,或已到时间但重新创建的定时器),还会执行一次函数。

运行以上代码的结果是: 如果一直拖着滚动条进行滚动,那么会以1s的时间间隔,持续输出当前位置和顶部的距离

节流throttle代码(时间戳):

function throttle(fn, delay) {            
  let prev = Date.now();            
  return function() {                
    const context = this;                
    const args = arguments;    
    
    let now = Date.now();    
    
    if (now - prev >= delay) { 
           prev = Date.now(); 
      fn.apply(context, args);                                   
    }            
  }        
}

// 以下照旧
function showTop() {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.addEventListener('scroll', throttle(showTop, 1000))
复制代码

当高频事件触发时,第一次会立即执行(throttle方法执行与真正触发事件的间隔一般大于delay),而后再怎么频繁地触发事件,也都是每delay时间才执行一次事件。而当最后一次事件触发完毕后,事件大概率不会再被执行了,因为最后一次事件执行与最后一次触发事件之间的时间间隔可能小于delay(触发事件是一个高频行为)。

更精确地,可以用时间戳+定时器,当第一次触发事件时马上执行事件处理函数,最后一次触发事件后也还会执行一次事件处理函数。

节流throttle代码(时间戳+定时器):

/*
 * 节流(throttle)函数
 * @param { function } fn 需要节流的函数
 * @param { number } delay 毫秒,节流期限值
 */
function throttle(fn, delay) {     
    let timer = null;     
    let startTime = Date.now();     
    return function() {
        const context = this;             
        const args = arguments;  
        
        const curTime = Date.now();             
        const remaining = delay - (curTime - startTime);
        
        if(timer) clearTimeout(timer);     
        
        function handleAndRest() {
            startTime = Date.now();        
            timer = null
            fn.apply(context, args);
        }
        if(remaining <= 0) {
            handleAndRest()
        } else {                    
            timer = setTimeout(handleAndRest, remaining);              
        }      
    }
}

// 以下照旧
function showTop() {
    const scrollTop = document.body.scrollTop || document.documentElement.scrollTop;
  console.log('滚动条位置:' + scrollTop);
}
window.addEventListener('scroll', throttle(showTop, 1000))
复制代码

在节流函数内部使用开始时间startTime、当前时间curTimedelay来计算剩余时间remaining,当remaining<=0时表示该执行事件处理函数了(保证了第一次触发事件就能立即执行事件处理函数和每隔delay时间执行一次事件处理函数)。

如果还没到时间的话就设定在remaining时间后再触发 (保证了最后一次触发事件后还能再执行一次事件处理函数)。当然在remaining这段时间中如果又一次触发事件,那么会取消当前的计时器,并重新计算一个remaining来判断当前状态。

文章分类
前端
文章标签