Lodash防抖函数源码解读

avatar
前端开发

一、前言

听说你浅浅的微笑就像乌梅子酱~

为什么要记笔记?因为老师常说好记性不如烂笔头呀~

首先,防抖(debounce)和节流(throttle) 是 JavaScript 的一个非常重要的知识点,来源于实际开发需要,是针对高频事件触发的优化,可有效降低高频事件的触发次数,减少资源的消耗,相对更加优雅。

其次,二者都借助定时器 setTimeout 来实现。防抖指抖动完成后触发事件执行,在单位时间内若再次触发则重新计时,即重视周期的结果变化节流是包含最大时限的防抖(假设在100s内持续频繁触发,防抖的处理结果是100s后才会执行,但这样对用户极不友好,所以在防抖函数中加一个最大时限,当达到最大时限时,即便仍在等待期,也会触发一次),在单位时间内必然执行一次(没有人工干预情况下)。节流相比防抖更细腻,重视周期的过程变化

1.1 简单版防抖函数

function debounce(fn, delay=200) {
  let timer
  return function() {
      if (timer) clearTimeout(timer) // 如果在 delay 时间内再次触发的,则**重新计时**
      timer = setTimeout(()=> { // 使用了 ES6 的箭头函数,因为其上下文指向父级
          fn.apply(this, arguments)
          timer = undefined // 及时回收闭包参数
      }, delay)
  }
}

图示(c1表示第一次触发,z1表示第一次执行):

28b860b6bceef54605ac0acddb69969.png

1.2 简单版节流函数

function throttle(fn, delay=200) {
  let timer = null
  return function() {
      if (timer) return // 如果在 delay 时间内再次触发的,则退出
      timer = setTimeout(()=> { // 使用了 ES6 的箭头函数,因为其上下文指向父级
          fn.apply(this, arguments)
          clearTimeout(timer)
          timer = undefined // 及时回收闭包参数
      }, delay)
  }
}

图示(z1:c3 表示第一次执行的是第三次触发的结果):

9d04ca0d621bece4801a05b434b8e2c.png

上述两个函数(防抖和节流)都使用了 JavaScript 一个重要的技术点:闭包(使用了父级作用域的变量 fn,delay,timer)。

下面学习一个比较厉害的防抖函数:Lodash 防抖函数Lodash 源码仓库

很多情况下,看得懂单行代码、单个函数,却有种丈二和尚摸不着头脑之感,不免感叹:一个防抖函数这么多复杂吗?

二、Lodash debounce

Lodash debounce 遵从节流是拥有最大时限的防抖, 融合了防抖和节流(若 'maxWait' in options 则是节流, 否则是防抖),节流的最大时限 maxWait >= wait

2.1 浅析整体结构

debounced.png

引用 Lodash debounce:

import { debounce } from 'lodash'
const dedInputChange = debounce(inputChange, 1000) // inputChange 监控输入框输入

调用防抖 debounce,传入高频事件 inputChange 和 单位时间 1000ms,返回新的 debounced (防抖动)函数。输入框每输入一个字符,触发 inputChange 事件,从而触发防抖动函数 dedInputChange。

每次触发 dedInputChange,记录当前时间 time(Date.now(), debounced 通过时间戳来计算时长, 防抖:相对于 lastCallTime,节流相对于 lastInvokeTime),更新作用域(lastThis, lastArgs)和触发时间(lastCallTime),更新触发时间前先调用 shouldInvoke(time) 判断是本次触发否允许执行 func,返回isInvoking,具体逻辑:

/**
 * 要返回的函数
 * 确定作用域和参数
 * 更新触发事件的时间, 也就是 lastCallTime
 * 启动定时器 timerId
 * @param {Array} args 以数组形式接收入参,若无入参则为空数组[]
 * @returns
 */
function debounced(...args) {
  const time = Date.now() // 最新触发时间
  const isInvoking = shouldInvoke(time) // 本次触发是否允许执行 func
  
  lastArgs = args // 更新作用域
  lastThis = this
  lastCallTime = time // 更新触发时间
  
  if (isInvoking) {
    if (timerId === undefined) { // 情况1
      return leadingEdge(lastCallTime) // 第一次触发事件执行
    }
    if (maxing) { // 情况3
      // Handle invocations in a tight loop.
      timerId = startTimer(timerExpired, wait)
      return invokeFunc(lastCallTime)
    }
  }
  if (timerId === undefined) { // 情况2
    timerId = startTimer(timerExpired, wait)
  }
  
  return result // result存储func返回值
}

防抖和节流(假设maxWait=wait)图示:

微信图片_20230228202509.png 微信图片_20230228202517.png

2.2 涉及变量/参数说明

  • func 高频事件,必传参数,若未传入则抛出异常 throw new TypeError('Expected a function')
  • wait 等待时长,单位毫秒(1000ms=1s),默认值 0;
  • options.leading 是否先执行后延时;
  • options.trailing 是否先延时后执行;
  • options.maxWait 等待的最大时限,表示是节流;
  • lastThis 和 lastArgs 是作用域参数,每次触发 debounced 更新一次;
  • timerId 定时器;
  • result 存储 func 返回值;
  • leading 是否先执行后延时,默认值为 false;
  • trailing 是否先延时后执行,默认值为 true;
  • maxing 是否是节流, 依据 'maxWait' in options
  • lastCallTime 表示最新触发 debounced 的时间;
  • lastInvokeTime 表示最新执行 func 的时间,默认值 0;
  • timeSinceLastCall 距离上一次触发 debounced 的时间;
  • timeSinceLastInvoke 距离上一次执行 func 的时间;
  • timeWaiting 表示防抖还需等待时长, 等于 time - lastCallTime;

9165e073452e8492375e35f9e1a1ee6.png

2.3 两个计算属性 lastCallTime 和 lastInvokeTime

前面 简单版防抖/节流函数,涉及时间参数的delay。在定时器延时期间再次触发,防抖是重新计时(再开延时delay的定时器);节流则判断是否在定时器延时期间,若在直接退出,否则开启定时器。通过判断定时器是否存在if(timerId)判断当前这次触发是否允许执行 func。

Lodash debounce 有三个时间节点:

  • (1)定时器延时结束执行回调函数时间 time
  • (2)最新触发 debounced 时间 lastCallTime
  • (3)最新执行 func 的时间 lastInvokeTime

三个时间节点作用

  • 判断是否允许唤起/执行 func(shouldInvoke);
  • 在定时器执行回调函数时计算还需延时时间remainingWait);

计算属性.png

2.4 源码注解

/**
 * 时间:2023/3/2
 * 笔者:露水晰123
 * 主题:Lodash防抖和节流(节流是拥有最大时限的防抖)
 */

/**
 * 判断是否是对象类型
 * @param {*} data
 * @returns
 */
 function isObject(data) {
  const type = typeof data
  if (data !== null && type === "object") {
    return true
  }
  return false
}

/**
 * 防抖函数(包含了节流)
 * @param {Function} func 回调函数, 高频事件, 必传参数且须是 function 类型
 * @param {*} wait
 * @param {*} options
 * @returns
 */
function debounce(func, wait, options) {
  // 必传参func校验
  if (typeof func !== "function") {
    throw new TypeError('Expected a function')
  }

  let lastThis, // 作用域
    lastArgs,
    timerId, // 定时器
    result, // func返回值
    lastCallTime, // 最新触发debounce的时间
    maxWait // 最大时限(节流是拥有最大时限的防抖)

  let lastInvokeTime = 0 // 最新执行func的时间
  let leading = false // 是否在定时器执行前执行func一次
  let trailing = true // 是否在定时器后执行func, 默认方式
  let maxing = false // 是否是节流,默认是防抖

  wait = +wait || 0 // 单位时间,单位毫秒,1000ms=1s
  if (isObject(options)) {
    leading = !!options.leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
    maxing = 'maxWait' in options
    maxWait = maxing ? Math.max(+options.maxWait, wait) : maxWait // 最大时限>=wait
  }

  /**
   * 判断是否允许执行 func
   * @param {*} time
   */
  function shouldInvoke(time) {
    const timeSinceLastCall = time - lastCallTime // 距离上一次触发debounce的时间
    const timeSinceLastInvoke = time - lastInvokeTime

    return ( // 防抖/节流
      lastCallTime === undefined // 表示一轮的第一次触发, 允许
      || timeSinceLastCall >= wait // 表示距离上一次触发 debounce 的时间是否已超过 wait
    ) || (
      maxing && timeSinceLastInvoke >= maxWait // 表示距离上一次执行 func 的时间是否已超过最大时限 maxWait(节流)
    )
  }

  /**
   * 开启定时器
   * @param {Function} pendingFunc 定时器回调函数
   * @param {Number} wait 定时器延时时间,单位毫秒,1000ms=1s
   * @returns 返回定时器
   */
  function startTimer(pendingFunc, wait) {
    return setTimeout(pendingFunc, wait)
  }

  /**
   * 执行 func 的函数
   * 回收作用域参数
   * 赋值,深拷贝和浅拷贝的区别
   * @returns
   */
  function invokeFunc(time) {
    // lastThis 和 lastArgs 的类型是 object(引用类型),引用类型存的是地址(堆结构)
    // 赋值,深拷贝和浅拷贝的区别

    const thisScope = lastThis // 赋值,将该引用类型存放的地址给 thisSCope, 存的是地址
    const thisArgs = lastArgs

    // 涉及垃圾回收机制(自动回收,手动回收),手动回收
    lastThis = lastArgs = undefined // 赋值(undefined表示无原始值,数字为NaN;null是一个对象,数字为0),将这两个引用类型的值由内存地址更改为 undefined,与原地址存储的数据断掉关系,但不会改变原内存数据,故 thisScope 和 thisArgs 值不变

    lastInvokeTime = time // 更新执行 func 的时间
    result = func.apply(thisScope, thisArgs) // apply更改this的指向(第一个参数是要更改的this,第二个参数是传递给前面方法的,以数组形式传递【与call的区别】)

    return result
  }

  /**
   * 后执行 func
   * @param {*} time
   * @returns
   */
  function trailingEdge(time) {
    timerId = undefined // 手动回收

    if (trailing && lastArgs) {
      return invokeFunc(time)
    }

    lastThis = lastArgs = undefined // 手动回收
    return result
  }

  /**
   * 计算剩余等待时长
   * 防抖:wait - (time - lastCallTime)
   * 节流:
   * @param {*} time
   * @returns
   */
  function remainingWait(time) {
    const timeSinceLastCall = time - lastCallTime
    const timeWaiting = wait - timeSinceLastCall

    const timeSinceLastInvoke = time - lastInvokeTime
    // console.log('maxWait:', timeWaiting, maxWait - timeSinceLastInvoke)

    return maxing
      ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) // 节流
      : timeWaiting // 防抖
  }

  /**
   * 定时器的回调函数
   * 定时器延时结束,判断是否允许执行 func(防抖:在定时器延时期间可能触发,若触发则重新开定时器,再等wait毫秒)
   */
  function timerExpired() {
    const time = Date.now() // 定时器回到函数执行时的时间戳
    const isInvoking = shouldInvoke(time) // 判断是否允许执行 func,是否还需等待(防抖:在定时器wait期间再次触发则重新计时,再等wait毫秒)

    if (isInvoking) {
      return trailingEdge(time)
    }

    const reamingTime = remainingWait(time) // 计算剩余等待时间
    // 节流不会走到这里
    // 为什么? 假设 maxWait=wait
    // 原因分析如下:
    // (1)第一次触发,开启第一个定时器(延时时间wait毫秒);
    // (2)在这个定时器延时期间再次触发;
    // (3)第一个定时器延时结束,执行定时器回调函数timerExpired;
    // (4)判断是否允许执行func:因为距离上一次触发时间小于wait,判断timeSinceLastInvoke(距离上一次执行时间大于或等于maxWait);
    // (5)若小于maxWait(说明maxWait>wait),isInvoking值为false,重开定时器,计算剩余延时时长,这里取得是timeWaiting和(maxWait-timeSinceLastInvoke)的最小值,得到reamingTime(最后,这次执行func的时间距离上一次执行func的时间可能是小于maxWait的)
    // (6)若等于maxWait(说明maxWait=wait),isInvoking值为true,立即执行
    // 结果;若maxWait>wait,还是会走到这里的
    // 防抖:在wait期间再次触发,重新计算延时时长
    timerId = startTimer(timerExpired, reamingTime) // 重开定时器,因为是在上一个wait期间触发的,所以距离上一次触发debounce已经有一段时间的延时了,所以计算剩余延时时长即可
  }

  /**
   * 一轮的第一次触发
   * 记录第一次执行时间为第一次触发时间
   * 开启定时器
   * 若配置了参数 leading 为 true,还需先执行一次 func
   * @param {*} time
   * @returns
   */
  function leadingEdge(time) {
    lastInvokeTime = time // 假设第一触发时间为第一次执行func时间,方便后面进行时间戳的计算

    timerId = startTimer(timerExpired, wait) // 开启定时器,延时wait毫秒
    return leading ? invokeFunc(time) : result // 如果 leading 为 true,表示在定时器回调函数执行前先执行一次 func
  }

  /**
     * 清除定时器
     * 正在进行中的定时器会被关闭
     */
   function cancelTimer() {
    clearTimeout(timerId)
  }

  /**
   * 取消防抖/节流
   * 清除定时器
   * 重置闭包参数
   */
  function cancel() {
    if (timerId !== undefined) {
      cancelTimer(timerId)
    }
    lastInvokeTime = 0 // 重置执行func时间
    lastThis = lastArgs = lastCallTime = timerId = undefined
  }

  /**
   * 立即执行一次
   * 拿到最新的func执行结果
   * @returns
   */
  function flush() {
    return timerId === undefined ? result : trailingEdge(Date.now())
  }

  /**
   * 判断是不是在进行中
   * @returns Boolean
   */
  function pending() {
    return timerId !== undefined
  }

  /**
   * 闭包函数
   * @param  {...any} args 以数组形式接收入参, 若无则是空数组[]
   * @returns
   */
  function debounced(...args) {
    const time = Date.now() // 当前时间戳
    const isInvoking = shouldInvoke(time) // 是否允许执行func

    lastThis = this // 更新作用域
    lastArgs = args
    lastCallTime = time // 更新触发debounce的时间

    if (isInvoking) { // 如果允许执行func
      if (timerId === undefined) {
        // 情况1(防抖+节流):一轮的第一次触发
        return leadingEdge(lastCallTime) // 一轮的第一次触发debounce(假设第一触发时间为第一次执行func时间)
      }
      if (maxing) {
        // 情况3(节流)
        timerId = startTimer(timerExpired, wait) // 重开定时器
        return invokeFunc(lastCallTime)
      }
    }
    if (timerId === undefined) {
      // 情况2(节流)
      // 为什么不可能是防抖?
      // 原因:在第一个执行func后的wait秒期间再次触发,此时isInvoking为false(距离上一次触发时间要小于wwait,这种情况下,有定时器在走,定时器回调函数执行时,肯定会remainWait再计算定时器延时时长,就不会走到情况2了,自相矛盾,故不可能是防抖)
      timerId = startTimer(timerExpired, wait)
    }

    // 提供三个方法
    // 给函数添加属性(函数本身也是对象)
    debounced.cancel = cancel
    debounced.flush = flush
    debounced.pending = pending

    return result
  }

  return debounced
}

三、Lodash throttle

节流是拥有最大时限的防抖,在 Lodash throttle 函数里,最大时限 maxWait 值就是传入的 wait。

function throttle(func, wait, options) {
  let leading = true
  let trailing = true

  if (typeof func !== 'function') {
    throw new TypeError('Expected a function')
  }
  if (isObject(options)) {
    leading = 'leading' in options ? !!options.leading : leading
    trailing = 'trailing' in options ? !!options.trailing : trailing
  }
  return debounce(func, wait, {
    leading,
    trailing,
    'maxWait': wait // 最大时限,maxWait=wait
  })
}

到此,若只是看代码,不是很好理解,可手写代码结合代码块功能加上手绘时间线, 更好理解(可参考2.1图示)。

Lodash debounce 提供了三个属性方法:

  • cancel: 关闭定时器, 释放闭包参数
  • flush: 立即执行一次 func
  • pending: 防抖或节流函数是否在进行中, 通过定时器判断 timerId !== undefined

四、总结

  • 节流是拥有最大时限的防抖(maxWait >= wait,那 maxWait 小于 wait 呢?);
  • Lodash debounce 通过时间戳的计算(做减法,当前时间戳 Date.now() 减去上一次触发时间戳 lastCallTime 或上一次执行func的时间戳 lastInvokeTime), 判断是否允许执行 func(isInvoking)、还剩多长时间可执行 func(remainingWait);
  • 闭包是一个函数使用了非自己的作用域的参数/方法;
  • 使用闭包函数注意,闭包参数及时回收,养成好习惯;
  • clearTimeout(timerId) 清除定时器timerId后,timerId还是有值的(是一个数字,标记上一个定时器是第几个,若是闭包参数及时回收:手动回收timerId = undefined);

+wait 可以将非数字类型的数字转为数字类型(加号的作用, 更像 Number(wait) 的简洁化)

  • 字符串
  • +('') -> 数字类型的 0
  • +('123') -> 数字类型的 123
  • 布尔类型
  • +(true) -> 数字类型的 1
  • +(false) -> 数字类型的 0
  • 引用类型
  • +([]) -> 数字类型的 0
  • +([123] -> 数字类型的 123
  • +(['123']) -> 数字类型的 123
  • +([[123]]) -> 数字类型的123(无论嵌套了多少层, 都会被剥掉)
  • +(null) -> 数字类型的 0

类似这样的简洁操作还有 ''+wait, wait+'', 变量与空字符串相加, 可以将非字符串类型转为字符串类型

  • {}+'' -> 得到数字类型的 0
  • ''+{} -> 得到字符串类型的 "[object Object]", 空字符在前面表示结果值得类型已经定了, 是一个字符串
  • ''+[[]] / [[]]+'' -> 得到空字符