likes
comments
collection
share

Lodash防抖函数源码解读

作者站长头像
站长
· 阅读数 70

一、前言

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

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

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

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

1.1 简单版防抖函数

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

图示(c1表示第一次触发,z1表示第一次执行): Lodash防抖函数源码解读

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)
      }, delay)
  }
}

图示(z1:c3 表示第一次执行的是第三次触发的结果): Lodash防抖函数源码解读

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

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

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

二、浅析 Lodash debounce

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

2.1 浅析整体结构

function debounce(func, wait, options) {
  // 1. 定义变量
  let lastThis,
    lastArgs, // 作用域
    timerId, // 定时器
    lastCallTime, // 最新一次触发时间
    result, // 存储 func 返回值
    maxWait // 最大等待时限, 单位毫秒(节流是拥有最大时限的防抖)

  let lastInvokeTime = 0 // 最新一次执行 func 时间
  let leading = false
  let trailing = true // 默认先延时后执行
  let maxing = false // 是否设置了最大时限(若为true则是节流,否则是防抖)

  // 2. 处理入参
  wait = +wait || 0 // 单位时间, 默认为0(+wait,可将非数字类型转为数字类型)
  if (isObject(options)) {
    leading = !!options.leading // 在开启延时前会先执行一次
    maxing = 'maxWait' in options // options对象里是否包含了maxWait属性
    maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : maxWait // 最大时限 maxWait>=wait
    trailing = 'trailling' in options ? !!options.trailing : trailing 
  }
  
  // 3. 主方法
  // debounced 是闭包函数, 因其内部使用了父级的变量和方法
  function debounced(...args) {
    const time = Date.now()
    const isInvoking = shouldInvoke(time) // 在更新lastCallTime之前调用,比较的是距离上一次的触发时间
    // console.log('isInvoking:', isInvoking, wait, timerId, maxWait, time-lastCallTime)

    lastThis = this // 更新作用域
    lastArgs = args // 数组形式, 若无入参则是空数组[]
    lastCallTime = time // 更新触发时间

    if (isInvoking) {
      if (timerId === undefined) {
        console.log('情况1:', timerId)
        return leadingEdge(lastCallTime) // 一轮中的第一次触发
      }
      // 如果是节流:有最大时限,而这个最大时限小于wait时,定时器还在等待单位时间期间即timerId有值
      // 此时若再次触发debounce,shouldInvoke(time)返回的是true,需要先执行一次[return invokeFunc(lastCallTime)],还要重新开启定时器
      if (maxing) {
        // 在情况3下, 开启定时器, 延时的 wait 期间, 再次触发 debounce 且满足 timeSinceLastInvoke >= maxWait 即 shouldInvoke 返回值为 true
        console.log('情况2:', timerId, maxing)
        timerId = startTimer(timerExpired, wait) // 注意:为什么定时器的延时时间依然是wait?(原因:节流是拥有最大时限的防抖, 这个最大时限 maxWait >= wait, 所以即使 maxWait > wait 情况下, 可根据剩余时间重开定时器, startTimer(timerExpired, remainingWait(time)))
        return invokeFunc(lastCallTime) // 先执行一次
      }
    }
    if (timerId === undefined) {
      // 作用一: 刚执行完func(timerId值为undefined), 在紧挨着的单位时间内再次触发 debounce, 此时 timeSinceLastCall 和 timeSinceLastInvoke 都小于 wait, 即 shouldInvoke 返回值为 false
      // 作用二: 保证最后一次执行func
      console.log('情况3:', isInvoking, timerId)
      timerId = startTimer(timerExpired, wait)
    }
    
    return result
  } 
  
  return debounced
}

涉及变量/参数说明:

  • func 高频事件;
  • wait 等待时长,单位毫秒(1000ms=1s);
  • 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;

2.2 leadingEdge

/**
 * 一轮的第一次触发
 * @param {*} time 
 * @returns 
 */
function leadingEdge(time) {
  lastInvokeTime = time // 问题1: 为什么要在这里设置最新执行func的时间? 
  timerId = startTimer(timerExpired, wait) // 开启定时器,等待 wait 毫秒后执行回调 timerExpired
  return leading ? invokeFunc(time) : result // leading 为 true 表示先执行后延时, invokeFunc:执行 func
}

针对问题1, 是因为后面时间判断是通过时间戳, 将一轮的第一次触发debounce时间作为lastInvokeTime的初值,后面在判断中可更准确计算 time - lastInvokeTime.

2.3 timerExpired

/**
 * 定时器回调函数
 * 重点在定时器未失效和失效后做什么
 *
 * 已经等待了 wait 毫秒
 * 防抖: 若在等待期间没有再次触发, 则直接执行, 否则重新计时
 * 节流: 若 maxWait > wait, 计算还需等待时长 remainingWait
 * @returns
 */
function timerExpired() {
  const time = Date.now() // 开始执行func的时间
  const isInvoking = shouldInvoke(time) // 在定时器等待期,是否有再次触发 debounce(每次触发,记录最新触发时间 lastCallTime)
  if (isInvoking) {
    return trailingEdge(time)
  }
  timerId = startTimer(timerExpired, remainingWait(time)) // 防抖重新计时, 节流重新计算剩余时间
}
  • startTimer: 返回一个定时器

2.4 trailingEdge

执行回调函数, 回收闭包参数(定时器,作用域)

/**
 * 执行回调 func
 * @param {*} time
 * @returns
 */
function trailingEdge(time) {
  timerId = undefined // 清空定时器
  
  if (trailing && lastArgs) {
    return invokeFunc(time) // 执行 func
  }
  
  lastThis = lastArgs = undefined // 释放作用域,为下次触发做初始化准备(闭包,手动回收,养成好习惯)
  return result
}

2.5 invokeFunc

/**
 * 执行 func
 * @param {*} time
 * @returns
 */
function invokeFunc(time) {
  const thisArg = lastThis // 先将作用域存起来,在下面释放后也可以使用
  const args = lastArgs
  
  lastArgs = lastThis = undefined // 释放作用域
  lastInvokeTime = time // 更新 func 执行时间
  result = func.apply(thisArg, args) // 执行 func
  return result
}

注意: 闭包参数用完回收, 养成好习惯

2.6 remainingWait

/**
 * 还剩多少等待时长
 * @param {*} time
 * @returns
 */
function remainingWait(time) {
  const timeSinceLastCall = time - lastCallTime // 距离上一次触发debounce的时间(防抖:再次触发需重新计时,从lastCallTime开始计算,lastCallTime+wait, 反过来,当前时间减去lastCallTime就是已等待的时间,总等待时间是wait,后减前就是)
  const timeWaiting = wait - timeSinceLastCall // 防抖还需等待时长
  const timeSinceLastInvoke = time - lastInvokeTime // 节流:距离上一次执行func的时间,这样才能保证每单位时间执行一次
  return maxing
    ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) // 节流有最大时限, 取防抖还需等待时长和节流还需等待时长的最小值, 为什么取最小呢?
    : timeWaiting
}

若是节流(最大时限 maxWait >= wait), 为什么要取最小值?

2.7 shouldInvoke

计算时间戳的变化, 判断是否允许执行 func

/**
 * 是否允许执行 func
 * @param {*} time 最新触发时间
 */
function shouldInvoke(time) {
  const timeSinceLastCall = time - lastCallTime // 若是第一次触发调用,值为 NaN;
  const timeSinceLastInvoke = time - lastInvokeTime // 距离上一次执行func的时间
  return (
    lastCallTime === undefined // 一轮的第一次触发debounce, 会调用一次 shouldInvoke
    || timeSinceLastCall >= wait // 一轮的第一个定时器结束后, 执行定时器回调 timerExpired, 去判断当前距离上一次触发debounce的时间是否大于等于单位时间 wait(若为 true 即在等待期没有再次触发;否则在等待期有触发,要重新计时)
  ) || (
    maxing && timeSinceLastInvoke >= maxWait // 节流有最大时限
  )
}

到此, 若只是看代码, 游走在崩溃的边缘, 不是很好理解. 结合手绘时间线, 会更好理解(后期会提供自己绘的).

Lodash debounce 还提供了三个方法:

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

三、总结

  • 节流是拥有最大时限的防抖
  • 闭包是一个函数使用了非自己的作用域的参数/方法
  • Lodash debounce 通过时间戳的计算, 判断是否允许执行 func, 还剩多长时间可执行 func

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

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

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

  • {}+'' -> 得到数字类型的 0
  • ''+{} -> 得到字符串类型的 "[object Object]", 空字符在前面表示结果值得类型已经定了, 是一个字符串
  • ''+[[]] / [[]]+'' -> 得到空字符
转载自:https://juejin.cn/post/7204770304400818235
评论
请登录