本文共 12201 字,大约阅读时间需要 40 分钟。
有如下代码
let n = 1window.onmousemove = () => { console.log(`第${n}次触发回调`) n++}复制代码
当我们在PC端页面上滑动鼠标时,一秒可以可以触发约60次事件。大家也可以访问下面的在线例子进行测试。
查看在线例子: by Logan () on .
这里的回调函数只是打印字符串,如果回调函数更加复杂,可想而知浏览器的压力会非常大,可能降低用户体验。
resize
、scroll
或mousemove
等事件的监听回调会被频繁触发,因此我们要对其进行限制。
函数节流简单来说就是对于连续的函数调用,每间隔一段时间,只让其执行一次。初步的实现思路有两种:
设置一个对比时间戳,触发事件时,使用当前时间戳减去对比时间戳,如果差值大于设定的间隔时间,则执行函数,并用当前时间戳替换对比时间戳;如果差值小于设定的间隔时间,则不执行函数。
function throttle(method, wait) { // 对比时间戳,初始化为0则首次触发立即执行,初始化为当前时间戳则wait毫秒后触发才会执行 let previous = 0 return function(...args) { let context = this let now = new Date().getTime() // 间隔大于wait则执行method并更新对比时间戳 if (now - previous > wait) { method.apply(context, args) previous = now } }}复制代码
查看在线例子: by Logan () on .
当首次触发事件时,设置定时器,wait毫秒后执行函数并将定时器置为null
,之后触发事件时,如果定时器存在则不执行,如果定时器不存在则再次设置定时器。
function throttle(method, wait) { let timeout return function(...args) { let context = this if (!timeout) { timeout = setTimeout(() => { timeout = null method.apply(context, args) }, wait) } }}复制代码
查看在线例子: by Logan () on .
mousemove
)mousedown/keydown
事件(单位时间只能发射一颗子弹)mousemove
)mousemove
)keyup
)scroll
加了 debounce
后,只有用户停止滚动后,才会判断是否到了页面底部;如果是 throttle
的话,只要页面滚动就会间隔一段时间判断一次代码说话,有错恳请指出
function throttle(method, wait, {leading = true, trailing = true} = {}) { // result 记录method的执行返回值 let timeout, result // 记录上次原函数执行的时间(非每次更新) let methodPrevious = 0 // 记录上次回调触发时间(每次都更新) let throttledPrevious = 0 let throttled = function(...args) { let context = this // 使用Promise,可以在触发回调时拿到原函数执行的返回值 return new Promise(resolve => { let now = new Date().getTime() // 两次相邻触发的间隔 let interval = now - throttledPrevious // 更新本次触发时间供下次使用 throttledPrevious = now // 重置methodPrevious为now,remaining = wait > 0,假装刚执行过,实现禁止立即执行 // 统一条件:leading为false // 加上以下条件之一 // 1. 首次触发(此时methodPrevious为0) // 2. trailing为true时,停止触发时间超过wait,定时器内函数执行(methodPrevious被置为0),然后再次触发 // 3. trailing为false时(不设定时器,methodPrevious不会被置为0),停止触发时间超过wait后再次触发(interval > wait) if (leading === false && (!methodPrevious || interval > wait)) { methodPrevious = now // 保险起见,清除定时器并置为null // 假装刚执行过要假装的彻底XD if (timeout) { clearTimeout(timeout) timeout = null } } // 距离下次执行原函数的间隔 let remaining = wait - (now - methodPrevious) // 1. leading为true时,首次触发就立即执行 // 2. 到达下次执行原函数时间 // 3. 修改了系统时间 if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } // 更新对比时间戳,执行函数并记录返回值,传给resolve methodPrevious = now result = method.apply(context, args) resolve(result) // 解除引用,防止内存泄漏 if (!timeout) context = args = null } else if (!timeout && trailing !== false) { timeout = setTimeout(() => { // leading为false时将methodPrevious设为0的目的在于 // 若不将methodPrevious设为0,如果定时器触发后很长时间没有触发回调 // 下次触发时的remaining为负,原函数会立即执行,违反了leading为false的设定 methodPrevious = leading === false ? 0 : new Date().getTime() timeout = null result = method.apply(context, args) resolve(result) // 解除引用,防止内存泄漏 if (!timeout) context = args = null }, remaining) } }) } // 加入取消功能,使用方法如下 // let throttledFn = throttle(otherFn) // throttledFn.cancel() throttled.cancel = function() { clearTimeout(timeout) previous = 0 timeout = null } return throttled}复制代码
调用节流后的函数的外层函数也需要使用Async/Await语法等待执行结果返回
使用方法见代码:
function square(num) { return Math.pow(num, 2)}// let throttledFn = throttle(square, 1000)// let throttledFn = throttle(square, 1000, {leading: false})// let throttledFn = throttle(square, 1000, {trailing: false})let throttledFn = throttle(square, 1000, {leading: false, trailing: false})window.onmousemove = async () => { try { let val = await throttledFn(4) // 原函数不执行时val为undefined if (typeof val !== 'undefined') { console.log(`原函数返回值为${val}`) } } catch (err) { console.error(err) }}// 鼠标移动时,每间隔1S输出:// 原函数的返回值为:16复制代码
查看在线例子: by Logan () on .
具体的实现步骤请往下看
这样实现的效果是首次触发立即执行,停止触发后会再执行一次
function throttle(method, wait) { let timeout let previous = 0 return function(...args) { let context = this let now = new Date().getTime() // 距离下次函数执行的剩余时间 let remaining = wait - (now - previous) // 如果无剩余时间或系统时间被修改 if (remaining <= 0 || remaining > wait) { // 如果定时器还存在则清除并置为null if (timeout) { clearTimeout(timeout) timeout = null } // 更新对比时间戳并执行函数 previous = now method.apply(context, args) } else if (!timeout) { // 如果有剩余时间但定时器不存在,则设置定时器 // remaining毫秒后执行函数、更新对比时间戳 // 并将定时器置为null timeout = setTimeout(() => { previous = new Date().getTime() timeout = null method.apply(context, args) }, remaining) } }}复制代码
我们来捋一捋,假设连续触发回调:
查看在线例子: by Logan () on .
// leading为控制首次触发时是否立即执行函数的配置项function throttle(method, wait, leading = true) { let timeout let previous = 0 return function(...args) { let context = this let now = new Date().getTime() // !previous代表首次触发或定时器触发后的首次触发,若不需要立即执行则将previous更新为now // 这样remaining = wait > 0,则不会立即执行,而是设定定时器 if (!previous && leading === false) previous = now let remaining = wait - (now - previous) if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } previous = now method.apply(context, args) } else if (!timeout) { timeout = setTimeout(() => { // 如果leading为false,则将previous设为0, // 下次触发时会与下次触发时的now同步,达到首次触发(对于用户来说)不立即执行 // 如果直接设为当前时间戳,若停止触发一段时间,下次触发时的remaining为负值,会立即执行 previous = leading === false ? 0 : new Date().getTime() timeout = null method.apply(context, args) }, remaining) } }}复制代码
查看在线例子: by Logan () on .
// trailing为控制停止触发后是否还执行一次的配置项function throttle(method, wait, {leading = true, trailing = true} = {}) { let timeout let previous = 0 return function(...args) { let context = this let now = new Date().getTime() if (!previous && leading === false) previous = now let remaining = wait - (now - previous) if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } previous = now method.apply(context, args) } else if (!timeout && trailing !== false) { // 如果有剩余时间但定时器不存在,且trailing不为false,则设置定时器 // trailing为false时等同于只使用时间戳来实现节流 timeout = setTimeout(() => { previous = leading === false ? 0 : new Date().getTime() timeout = null method.apply(context, args) }, remaining) } }}复制代码
查看在线例子: by Logan () on .
有些时候我们需要在不可触发的这段时间内能够手动取消节流,代码实现如下:
function throttle(method, wait, {leading = true, trailing = true} = {}) { let timeout let previous = 0 // 将返回的匿名函数赋值给throttled,以便在其上添加取消方法 let throttled = function(...args) { let context = this let now = new Date().getTime() if (!previous && leading === false) previous = now let remaining = wait - (now - previous) if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } previous = now method.apply(context, args) } else if (!timeout && trailing !== false) { timeout = setTimeout(() => { previous = leading === false ? 0 : new Date().getTime() timeout = null method.apply(context, args) }, remaining) } } // 加入取消功能,使用方法如下 // let throttledFn = throttle(otherFn) // throttledFn.cancel() throttled.cancel = function() { clearTimeout(timeout) previous = 0 timeout = null } // 将节流后函数返回 return throttled}复制代码
查看在线例子: by Logan () on .
需要节流的函数可能是存在返回值的,我们要对这种情况进行处理,underscore
的处理方法是将函数返回值在返回的debounced
函数内再次返回,但是这样其实是有问题的。如果原函数执行在setTimeout
内,则无法同步拿到返回值,我们使用Promise处理原函数返回值。
function throttle(method, wait, {leading = true, trailing = true} = {}) { // result记录原函数执行结果 let timeout, result let previous = 0 let throttled = function(...args) { let context = this // 返回一个Promise,以便可以使用then或者Async/Await语法拿到原函数返回值 return new Promise(resolve => { let now = new Date().getTime() if (!previous && leading === false) previous = now let remaining = wait - (now - previous) if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } previous = now result = method.apply(context, args) // 将函数执行返回值传给resolve resolve(result) } else if (!timeout && trailing !== false) { timeout = setTimeout(() => { previous = leading === false ? 0 : new Date().getTime() timeout = null result = method.apply(context, args) // 将函数执行返回值传给resolve resolve(result) }, remaining) } }) } throttled.cancel = function() { clearTimeout(timeout) previous = 0 timeout = null } return throttled}复制代码
使用方法一:在调用节流后的函数时,使用then
拿到原函数的返回值
function square(num) { return Math.pow(num, 2)}let throttledFn = throttle(square, 1000, false)window.onmousemove = () => { throttledFn(4).then(val => { console.log(`原函数的返回值为:${val}`) })}// 鼠标移动时,每间隔1S后输出:// 原函数的返回值为:16复制代码
使用方法二:调用节流后的函数的外层函数使用Async/Await语法等待执行结果返回
使用方法见代码:
function square(num) { return Math.pow(num, 2)}let throttledFn = throttle(square, 1000)window.onmousemove = async () => { try { let val = await throttledFn(4) // 原函数不执行时val为undefined if (typeof val !== 'undefined') { console.log(`原函数返回值为${val}`) } } catch (err) { console.error(err) }}// 鼠标移动时,每间隔1S输出:// 原函数的返回值为:16复制代码
查看在线例子: by Logan () on .
模仿underscore
实现的函数节流有一点美中不足,那就是 leading:false
和 trailing: false
不能同时设置。
如果同时设置的话,比如当你将鼠标移出的时候,因为 trailing
设置为 false
,停止触发的时候不会设置定时器,所以只要再过了设置的时间,再移入的话,remaining
为负数,就会立刻执行,就违反了 leading: false
,这里我们优化的思路如下:
计算连续两次触发回调的时间间隔,如果大于设定的间隔值时,重置对比时间戳为当前时间戳,这样就相当于回到了首次触发,达到禁止首次触发(伪)立即执行的效果,代码如下,有错恳请指出:
function throttle(method, wait, {leading = true, trailing = true} = {}) { let timeout, result let methodPrevious = 0 // 记录上次回调触发时间(每次都更新) let throttledPrevious = 0 let throttled = function(...args) { let context = this return new Promise(resolve => { let now = new Date().getTime() // 两次触发的间隔 let interval = now - throttledPrevious // 更新本次触发时间供下次使用 throttledPrevious = now // 更改条件,两次间隔时间大于wait且leading为false时也重置methodPrevious,实现禁止立即执行 if (leading === false && (!methodPrevious || interval > wait)) { methodPrevious = now } let remaining = wait - (now - methodPrevious) if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout) timeout = null } methodPrevious = now result = method.apply(context, args) resolve(result) // 解除引用,防止内存泄漏 if (!timeout) context = args = null } else if (!timeout && trailing !== false) { timeout = setTimeout(() => { methodPrevious = leading === false ? 0 : new Date().getTime() timeout = null result = method.apply(context, args) resolve(result) // 解除引用,防止内存泄漏 if (!timeout) context = args = null }, remaining) } }) } throttled.cancel = function() { clearTimeout(timeout) methodPrevious = 0 timeout = null } return throttled}复制代码
查看在线例子: by Logan () on .
作者:Logan70 链接:https://juejin.im/post/5be24d76e51d451def13cca2 来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。