一、前言
听说你浅浅的微笑就像乌梅子酱~
为什么要记笔记?因为老师常说好记性不如烂笔头呀~
首先,防抖(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表示第一次执行):
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 表示第一次执行的是第三次触发的结果):
上述两个函数(防抖和节流)都使用了 JavaScript 一个重要的技术点:闭包(使用了父级作用域的变量 fn,delay,timer)。
下面学习一个比较厉害的防抖函数:Lodash 防抖函数 , Lodash 源码仓库 。
很多情况下,看得懂单行代码、单个函数,却有种丈二和尚摸不着头脑之感,不免感叹:一个防抖函数这么多复杂吗?
二、Lodash debounce
Lodash debounce 遵从节流是拥有最大时限的防抖, 融合了防抖和节流(若 'maxWait' in options 则是节流, 否则是防抖),节流的最大时限 maxWait >= wait 。
2.1 浅析整体结构
引用 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)图示:
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;
2.3 两个计算属性 lastCallTime 和 lastInvokeTime
前面 简单版防抖/节流函数,涉及时间参数的delay。在定时器延时期间再次触发,防抖是重新计时(再开延时delay的定时器);节流则判断是否在定时器延时期间,若在直接退出,否则开启定时器。通过判断定时器是否存在if(timerId)
判断当前这次触发是否允许执行 func。
在 Lodash debounce 有三个时间节点:
- (1)定时器延时结束执行回调函数时间 time;
- (2)最新触发 debounced 时间 lastCallTime;
- (3)最新执行 func 的时间 lastInvokeTime;
三个时间节点作用:
- 判断是否允许唤起/执行 func(shouldInvoke);
- 在定时器执行回调函数时计算还需延时时间(remainingWait);
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]", 空字符在前面表示结果值得类型已经定了, 是一个字符串
- ''+[[]] / [[]]+'' -> 得到空字符