Lodash防抖函数源码解读
一、前言
听说你浅浅的微笑就像乌梅子酱~
为什么要记笔记?因为老师常说好记性不如烂笔头呀~
首先,防抖(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表示第一次执行):
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 表示第一次执行的是第三次触发的结果):
上述两个函数(防抖和节流)都使用了 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