总结防抖和节流在实际开发中的应用以及注意点

382 阅读12分钟

在学习之前我们应该明白要知识以下几点

  • 什么是防抖,节流
  • 两者的区别
  • 实际应用场景
  • 注意事项
  • 额外补充loadsh的防抖节流源码分析 学习以上几点你应该就可以对防抖和节流有比较深入的了解

什么是防抖,节流

防抖

防抖(debounce): 当持续触发事件时,一定时间段内没有再触发事件,事件处理函数才会执行一次,如果设定的时间到来之前,又一次触发了事件,就重新开始延时。

特点:等待某种操作停止后,加以间隔进行操作

  • 持续触发不执行,会重新开始计时
  • 不触发后以最后一次执行的一段时间之后再执行

从代码层面来说防抖分为立即执行版和非立即执行版本 非立即执行版: 其实就是定时器延时触发

function debounce(func, wait) {
    let timeout;
    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout);
        
        timeout = setTimeout(() => {
            func.apply(context, args)
        }, wait);
    }
}

立即执行版: 用timerId来决定是否执行func.apply(context, args),中间触发清空定时器,重新及时,执行完成清空定时器id

function debounce(func,wait) {
    let timeout;
    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout);

        let callNow = !timeout;
        timeout = setTimeout(() => {
            timeout = null;
        }, wait)

        if (callNow) func.apply(context, args)
    }
}

优化结合版:用一个immediate来判断执行哪个

/**
 * @desc 函数防抖
 * @param func 函数
 * @param wait 延迟执行毫秒数
 * @param immediate true 表立即执行,false 表非立即执行
 */
function debounce(func,wait,immediate) {
    let timeout;

    return function () {
        let context = this;
        let args = arguments;

        if (timeout) clearTimeout(timeout);
        if (immediate) {
            var callNow = !timeout;
            timeout = setTimeout(() => {
                timeout = null;
            }, wait)
            if (callNow) func.apply(context, args)
        }
        else {
            timeout = setTimeout(function(){
                func.apply(context, args)
            }, wait);
        }
    }
}

节流

函数节流(throttle):当持续触发事件时,保证一定时间段内只调用一次事件处理函数。如果设定的时间到来之前,又一次触发了事件,不会重新开始延时,或者delay延时会缩短,保证每隔一段时间就会触发一次。

特点:每等待某种间隔后,进行操作

  • 持续触发间隔时间超过规定时间内会执行多次
  • 到一定时间 / 其它间隔 ( 如滑动的高度 )再去执行

分为时间戳版本和定时器版本 时间戳: 通过调用最新时间和之前时间进行对比,来调用,首次会立即执行,然后把最新时间赋值给之前时间

function throttle(func, wait) {
    let previous = 0;
    return function() {
        let now = Date.now();
        let context = this;
        let args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    }
}

定时器版本: 多次触发在规定时间间隔内由于timeout存在不会进入设置定时器,直到上一次在规定时间内执行完成,情况timerout设置为null

function throttle(func, wait) {
    let timeout;
    return function() {
        let context = this;
        let args = arguments;
        if (!timeout) {
            timeout = setTimeout(() => {
                func.apply(context, args)
                timeout = null;
            }, wait)
        }

    }
}

结合版: 第一次会执行进入定时处理,后面多次触发会情况上一次定时器,但是触发时间不会重新计算,会计算剩余时间,然后执行

function throttle(fn, delay) {
    let timer = null
    let starttime = Date.now()
    return function () {
        let curTime = Date.now() // 当前时间
        let remaining = delay - (curTime - starttime)  // 从上一次到现在,还剩下多少多余时间
        let context = this
        let args = arguments
        clearTimeout(timer)
        if (remaining <= 0) {
            fn.apply(context, args)
            starttime = Date.now()
        } else {
            timer = setTimeout(fn, remaining);
        }
    }
}

image.png

防抖节流三秒内执行4次, 分别在第1秒,第2秒, 第2.5秒, 第3秒执行 非立即执行版本 防抖就是执行一次就是在6秒后执行, 节流定时器执行,在第4秒执行

两者的区别

 函数节流不管事件触发有多频繁,都会保证在规定时间内一定会执行一次真正的事件处理函数 而函数防抖只是在在规定时间间隔多次触发的话只会保证第一次或者最后一次事件后才触发一次函数。

实际应用场景

防抖: 一般是输入框输入完成请求查询数据, 按钮点击, onResize,等等 节流: 全量数据商品搜索列表, 滚动加载, 鼠标move事件

注意事项

在事件开发中我们使用防抖和节流的话可能需要注意函数在页面初始化或者切换页面执行的时候会去立即调用一次,而且定时以后在去执行,这样数据回显就相对比较缓慢,特别在使用工具库的时候要注意,因为是否因为没有加入某些配置导致延时执行,loadsh的节流就是需要加入配置是否立即执行

额外补充loadsh的防抖节流源码分析

防抖 debounce

Lodash 中节流函数比较简单,直接调用防抖函数,传入一些配置就摇身一变成了节流函数,所以我们先来看看其中防抖函数是如何实现的,弄懂了防抖,那节流自然就容易理解了。

debounce(func, wait, options) 方法提供了 3 个参数,第一个是我们想要执行的函数,为方便理解文中统一称为传入函数 func,第二个是超时时间 wait,第三个是可选参数,分别是 leadingtrailingmaxWait

function debounce(func, wait, options) {
  // 通过闭包保存一些变量
  let lastArgs, // 上一次执行 debounced 的 arguments,
                          // 起一个标记位的作用,用于 trailingEdge 方法中,invokeFunc 后清空
    lastThis, // 保存上一次 this
    maxWait, // 最大等待时间,数据来源于 options,实现节流效果,保证大于一定时间后一定能执行
    result, // 函数 func 执行后的返回值,多次触发但未满足执行 func 条件时,返回 result
    timerId, // setTimeout 生成的定时器句柄
    lastCallTime // 上一次调用 debounce 的时间

  let lastInvokeTime = 0 // 上一次执行 func 的时间,配合 maxWait 多用于节流相关
  let leading = false // 是否响应事件刚开始的那次回调,即第一次触发,false 时忽略
  let maxing = false // 是否有最大等待时间,配合 maxWait 多用于节流相关
  let trailing = true // 是否响应事件结束后的那次回调,即最后一次触发,false 时忽略

  // 没传 wait 时调用 window.requestAnimationFrame()
  // window.requestAnimationFrame() 告诉浏览器希望执行动画并请求浏览器在下一次重绘之前调用指定的函数来更新动画,差不多 16ms 执行一次
  const useRAF = (!wait && wait !== 0 && typeof root.requestAnimationFrame === 'function')

  // 保证输入的 func 是函数,否则报错
  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }

  // 转成 Number 类型
  wait = +wait || 0

  // 获取用户传入的配置 options
  if (isObject(options)) {
    leading = !!options.leading
    // options 中是否有 maxWait 属性,节流函数预留
    maxing = 'maxWait' in options
    // maxWait 为设置的 maxWait 和 wait 中最大的,如果 maxWait 小于 wait,那 maxWait 就没有意义了
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }

  // ----------- 开闭定时器 -----------
  // 开启定时器
  function startTimer(pendingFunc, wait) {}

  // 取消定时器
  function cancelTimer(id) {}

  // 定时器回调函数,表示定时结束后的操作
  function timerExpired() {}

  // 计算仍需等待的时间
  function remainingWait(time) {}

  // ----------- 执行传入函数 -----------
    // 执行连续事件刚开始的那次回调
  function leadingEdge(time) {}

  // 执行连续事件结束后的那次回调
  function trailingEdge(time) {}

  // 执行 func 函数
  function invokeFunc(time) {}

  // 判断此时是否应该执行 func 函数
  function shouldInvoke(time) {}

  // ----------- 对外 3 个方法 -----------
  // 取消函数延迟执行
  function cancel() {}

  // 立即执行 func
  function flush() {}

  // 检查当前是否在计时中
  function pending() {}

  // ----------- 入口函数 -----------
  function debounced(...args) {}

  // 绑定方法
  debounced.cancel = cancel
  debounced.flush = flush
  debounced.pending = pending

  // 返回入口函数 
  return debounced
}

debounce 函数最终返回了 debounced,返回的这个函数就是入口函数了,事件每次触发后都会执行 debounced 函数,而且会频繁的执行,所以在这个方法里需要「判断是否应该执行传入函数 func」,然后根据条件开启定时器,debounced 函数做的就是这件事。


// 开启定时器
function startTimer(pendingFunc, wait) {
  // 没传 wait 时调用 window.requestAnimationFrame()
  if (useRAF) {
    // 若想在浏览器下次重绘之前继续更新下一帧动画
    // 那么回调函数自身必须再次调用 window.requestAnimationFrame()
    root.cancelAnimationFrame(timerId);
    return root.requestAnimationFrame(pendingFunc)
  }
  // 不使用 RAF 时开启定时器
  return setTimeout(pendingFunc, wait)
}
// 取消定时器
function cancelTimer(id) {
  if (useRAF) {
    return root.cancelAnimationFrame(id)
  }
  clearTimeout(id)
}

timerExpired

startTimer 函数中传入的回调函数 pendingFunc 其实就是定时器回调函数 timerExpired,表示定时结束后的操作。

定时结束后无非两种情况,一种是执行传入函数 func,另一种就是不执行。对于第一种需要判断下是否需要执行传入函数 func,需要的时候执行最后一次回调。对于第二种计算剩余等待时间并重启定时器,保证下一次时延的末尾触发。

// 定时器回调函数,表示定时结束后的操作
function timerExpired() {
  const time = Date.now()
  // 1、是否需要执行
  // 执行事件结束后的那次回调,否则重启定时器
  if (shouldInvoke(time)) {
    return trailingEdge(time)
  }
  // 2、否则 计算剩余等待时间,重启定时器,保证下一次时延的末尾触发
  timerId = startTimer(timerExpired, remainingWait(time))
}

remainingWait

这里计算仍然需要等待的时间,使用的变量有点多,足足有 9 个,我们先看看各个变量的含义。

  • time 当前时间戳
  • lastCallTime 上一次调用 debounce 的时间
  • timeSinceLastCall 当前时间距离上一次调用 debounce 的时间差
  • lastInvokeTime 上一次执行 func 的时间
  • timeSinceLastInvoke 当前时间距离上一次执行 func 的时间差
  • wait 输入的等待时间
  • timeWaiting 剩余等待时间
  • maxWait 最大等待时间,数据来源于 options,为了节流函数预留
  • maxing 是否设置了最大等待时间,判断依据是 maxWait in options
  • maxWait - timeSinceLastInvoke 距上次执行 func 的剩余等待时间
// 计算仍需等待的时间
function remainingWait(time) {
  // 当前时间距离上一次调用 debounce 的时间差
  const timeSinceLastCall = time - lastCallTime
  // 当前时间距离上一次执行 func 的时间差
  const timeSinceLastInvoke = time - lastInvokeTime
  // 剩余等待时间
  const timeWaiting = wait - timeSinceLastCall

  // 是否设置了最大等待时间
    // 是(节流):返回「剩余等待时间」和「距上次执行 func 的剩余等待时间」中的最小值
    // 否:返回剩余等待时间
  return maxing
    ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke)
      : timeWaiting
}

leadingEdge

执行事件刚开始的那次回调,即事件刚触发就执行,不再等待 wait 时间之后,在这个方法里主要有三步。

  • 设置上一次执行 func 的时间 lastInvokeTime
  • 开启定时器
  • 执行传入函数 func
// 执行连续事件刚开始的那次回调
function leadingEdge(time) {
  // 1、设置上一次执行 func 的时间
  lastInvokeTime = time
  // 2、开启定时器,为了事件结束后的那次回调
  timerId = startTimer(timerExpired, wait)
  // 3、如果配置了 leading 执行传入函数 func
  // leading 来源自 !!options.leading
  return leading ? invokeFunc(time) : result
}

trailingEdge

这里就是执行事件结束后的回调了,这里做的事情很简单,就是执行 func 函数,以及清空参数

// 执行连续事件结束后的那次回调
function trailingEdge(time) {
  // 清空定时器
  timerId = undefined

  // trailing 和 lastArgs 两者同时存在时执行
  // trailing 来源自 'trailing' in options ? !!options.trailing : trailing
  // lastArgs 标记位的作用,意味着 debounce 至少执行过一次
  if (trailing && lastArgs) {
    return invokeFunc(time)
  }
  // 清空参数
  lastArgs = lastThis = undefined
  return result
}

invokeFunc

// 执行 Func 函数
function invokeFunc(time) {
  // 获取上一次执行 debounced 的参数
  const args = lastArgs
  // 获取上一次的 this
  const thisArg = lastThis

  // 重置
  lastArgs = lastThis = undefined
  lastInvokeTime = time
  result = func.apply(thisArg, args)
  return result
}

shouldInvoke

在入口函数中执行 invokeFunc 时会先判断下是否应该执行,我们来详细看下具体逻辑,和 remainingWait 中类似,变量有点多,先来回顾下这些变量。

  • time 当前时间戳
  • lastCallTime 上一次调用 debounce 的时间
  • timeSinceLastCall 当前时间距离上一次调用 debounce 的时间差
  • lastInvokeTime 上一次执行 func 的时间
  • timeSinceLastInvoke 当前时间距离上一次执行 func 的时间差
  • wait 输入的等待时间
  • maxWait 最大等待时间,数据来源于 options,为了节流函数预留
  • maxing 是否设置了最大等待时间,判断依据是 maxWait in options

我们来一步一步看下判断的核心代码,总共有 4 种逻辑。 会发现一共有 4 种情况返回 true,区分开看也比较理解。

  • lastCallTime === undefined 第一次调用时
  • timeSinceLastCall >= wait 超过超时时间 wait,处理事件结束后的那次回调
  • timeSinceLastCall < 0 当前时间 - 上次调用时间小于 0,即更改了系统时间
  • maxing && timeSinceLastInvoke >= maxWait 超过最大等待时间
// 判断此时是否应该执行 func 函数
function shouldInvoke(time) {
  // 当前时间距离上一次调用 debounce 的时间差
  const timeSinceLastCall = time - lastCallTime
  // 当前时间距离上一次执行 func 的时间差
  const timeSinceLastInvoke = time - lastInvokeTime

  // 上述 4 种情况返回 true
  return (lastCallTime === undefined || (timeSinceLastCall >= wait) ||
          (timeSinceLastCall < 0) || (maxing && timeSinceLastInvoke >= maxWait))
}

对外 3 个方法

debounced 函数提供了 3 个方法,分别是cancelflushpending,通过如下方式提供属性进行绑定。

// 绑定方法
debounced.cancel = cancel
debounced.flush = flush
debounced.pending = pending

cancel

这个就是取消执行,取消主要做的就是清除定时器,然后清除必要的闭包变量,回归初始状态。

// 取消函数延迟执行
function cancel() {
  // 清除定时器
  if (timerId !== undefined) {
    cancelTimer(timerId)
  }
  // 清除闭包变量
  lastInvokeTime = 0
  lastArgs = lastCallTime = lastThis = timerId = undefined
}

flush

这个是对外提供的立即执行方法,方便需要的时候调用。

  • 如果不存在定时器,意味着还没有触发事件或者事件已经执行完成,则此时返回 result 结果
  • 如果存在定时器,立即执行 trailingEdge,执行完成后会清空定时器id,lastArgslastThis
// 立即执行 func
function flush() {
  return timerId === undefined ? result : trailingEdge(Date.now())
}

pending

获取当前状态,检查当前是否在计时中,存在定时器 id timerId 意味着正在计时中。

// 检查当前是否在计时中
function pending() {
  return timerId !== undefined
}

节流函数 throttle

节流函数的定义和自定义实现我就不再介绍了,之前专门写过一篇文章,戳这里学习

throttle

这部分源码比较简单,相比防抖来说只是触发条件不同,说白了就是 maxWaitwait 的防抖函数。

function throttle(func, wait, options) {
  // 首尾调用默认为 true
  let leading = true
  let trailing = true

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  // options 是否是对象
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  // maxWait 为 wait 的防抖函数
  return debounce(func, wait, {
    leading,
    trailing,
    'maxWait': wait,
  })
}

参考: www.cnblogs.com/momo798/p/9…

juejin.cn/post/701829…

github.com/mqyqingfeng…

cloud.tencent.com/developer/a…