Debounce 和 throttle 是我们在 JavaScript 中使用的两个概念,用于增强对函数执行的控制,这在事件处理程序中特别有用。这两种技术都回答了同一个问题“一段时间内某个函数的调用频率是多少?”
? 相关链接
文中内容多数来自以下文章,侵删!
- hackll.com/2015/11/19/…
- github.com/mqyqingfeng…
- drupalsun.com/david-corba…
- blog.coding.net/blog/the-di…
- github.com/jashkenas/u…
- github.com/jashkenas/u…
? Debounce
1. 概念
-
本是机械开关的“去弹跳”概念,弹簧开关按下后,由于簧片的作用,接触点会连续接触断开好多次,如果每次接触都通电对用电器不好,所以就要控制按下到稳定的这段时间不通电
-
前端开发中则是一些频繁的事件触发
- 鼠标(
mousemove
...)键盘(keydown
...)事件等 - 表单的实时校验(频繁发送验证请求)
- 鼠标(
-
在 debounce 函数没有再被调用的情况下经过 delay 毫秒后才执行回调函数,例如
- 在
mousemove
事件中,确保多次触发只调用一次监听函数 - 在表单校验的时候,不加防抖,依次输入
user
,就会分成u
,us
,use
,user
四次发出请求;而添加防抖,设置好时间,可以实现完整输入user
才发出校验请求
- 在
2. 思路
-
由 debounce 的功能可知防抖函数至少接收两个参数(流行类库中都是 3 个参数)
- 回调函数
fn
- 延时时间
delay
- 回调函数
-
debounce 函数返回一个闭包,闭包被频繁的调用
- debounce 函数只调用一次,之后调用的都是它返回的闭包函数
- 在闭包内部限制了回调函数
fn
的执行,强制只有连续操作停止后执行一次
-
使用闭包是为了使指向定时器的变量不被
gc
回收- 实现在延时时间
delay
内的连续触发都不执行回调函数fn
,使用的是在闭包内设置定时器setTimeOut
- 频繁调用这个闭包,在每次调用时都要将上次调用的定时器清除
- 被闭包保存的变量就是指向上一次设置的定时器
- 实现在延时时间
3. 实现
-
符合原理的简单实现
function debounce(fn, delay) { var timer; return function() { // 清除上一次调用时设置的定时器 // 计时器清零 clearTimeout(timer); // 重新设置计时器 timer = setTimeout(fn, delay); }; } 复制代码
-
简单实现的代码,可能会造成两个问题
-
this
指向问题。debounce 函数在定时器中调用回调函数fn
,所以fn
执行的时候this
指向全局对象(浏览器中window
),需要在外层用变量将this
保存下来,使用apply
进行显式绑定function debounce(fn, delay) { var timer; return function() { // 保存调用时的this var context = this; clearTimeout(timer); timer = setTimeout(function() { // 修正 this 的指向 fn.apply(this); }, delay); }; } 复制代码
-
event
对象。JavaScript 的事件处理函数中会提供事件对象event
,在闭包中调用时需要将这个事件对象传入function debounce(fn, delay) { var timer; return function() { // 保存调用时的this var context = this; // 保存参数 var args = arguments; clearTimeout(timer); timer = setTimeout(function() { console.log(context); // 修正this,并传入参数 fn.apply(context, args); }, delay); }; } 复制代码
-
4. 完善(underscore
的实现)
-
立刻执行。增加第三个参数,两种情况
- 先执行回调函数
fn
,等到停止触发后的delay
毫秒,才可以再次触发(先执行) - 连续的调用 debounce 函数不触发回调函数,停止调用经过
delay
毫秒后才执行回调函数(后执行) clearTimeout(timer)
后,timer
并不会变成null
,而是依然指向定时器对象
function debounce(fn, delay, immediate) { var timer; return function() { var context = this; var args = arguments; // 停止定时器 if (timer) clearTimeout(timer); // 回调函数执行的时机 if (immediate) { // 是否已经执行过 // 执行过,则timer指向定时器对象,callNow 为 false // 未执行,则timer 为 null,callNow 为 true var callNow = !timer; // 设置延时 timer = setTimeout(function() { timer = null; }, delay); if (callNow) fn.apply(context, args); } else { // 停止调用后delay时间才执行回调函数 timer = setTimeout(function() { fn.apply(context, args); }, delay); } }; } 复制代码
- 先执行回调函数
-
返回值与取消 debounce 函数
- 回调函数可能有返回值。
- 后执行情况可以不考虑返回值,因为在执行回调函数前的这段时间里,返回值一直是
undefined
- 先执行情况,会先得到返回值
- 后执行情况可以不考虑返回值,因为在执行回调函数前的这段时间里,返回值一直是
- 能取消 debounce 函数。一般当
immediate
为true
的时候,触发一次后要等待delay
时间后才能再次触发,但是想要在这个时间段内想要再次触发,可以先取消掉之前的 debounce 函数
function debounce(fn, delay, immediate) { var timer, result; var debounced = function() { var context = this; var args = arguments; // 停止定时器 if (timer) clearTimeout(timer); // 回调函数执行的时机 if (immediate) { // 是否已经执行过 // 执行过,则timer指向定时器对象,callNow 为 false // 未执行,则timer 为 null,callNow 为 true var callNow = !timer; // 设置延时 timer = setTimeout(function() { timer = null; }, delay); if (callNow) result = fn.apply(context, args); } else { // 停止调用后delay时间才执行回调函数 timer = setTimeout(function() { fn.apply(context, args); }, delay); } // 返回回调函数的返回值 return result; }; // 取消操作 debounced.cancel = function() { clearTimeout(timer); timer = null; }; return debounced; } 复制代码
- 回调函数可能有返回值。
-
ES6 写法
function debounce(fn, delay, immediate) { let timer, result; // 这里不能使用箭头函数,不然 this 依然会指向 Windows对象 // 使用rest参数,获取函数的多余参数 const debounced = function(...args) { if (timer) clearTimeout(timer); if (immediate) { const callNow = !timer; timer = setTimeout(() => { timer = null; }, delay); if (callNow) result = fn.apply(this, args); } else { timer = setTimeout(() => { fn.apply(this, args); }, delay); } return result; }; debounced.cancel = () => { clearTimeout(timer); timer = null; }; return debounced; } 复制代码
? throttle
1. 概念
-
固定函数执行的速率
-
如果持续触发事件,每隔一段时间,执行一次事件
- 例如监听
mousemove
事件时,不管鼠标移动的速度,【节流】后的监听函数会在 wait 秒内最多执行一次,并以此【匀速】触发执行
- 例如监听
-
window
的resize
、scroll
事件的优化等
2. 思路
-
有两种主流实现方式
- 使用时间戳
- 设置定时器
-
节流函数 throttle 调用后返回一个闭包
- 闭包用来保存之前的时间戳或者定时器变量(因为变量被返回的函数引用,所以无法被垃圾回收机制回收)
-
时间戳方式
- 当触发事件的时候,取出当前的时间戳,然后减去之前的时间戳(初始设置为 0)
- 结果大于设置的时间周期,则执行函数,然后更新时间戳为当前时间戳
- 结果小于设置的时间周期,则不执行函数
-
定时器方式
- 当触发事件的时候,设置一个定时器
- 再次触发事件的时候,如果定时器存在,就不执行,知道定时器执行,然后执行函数,清空定时器
- 设置下个定时器
-
将两种方式结合,可以实现兼并立刻执行和停止触发后依然执行一次的效果
3. 实现
-
时间戳实现
function throttle(fn, wait) { var args; // 前一次执行的时间戳 var previous = 0; return function() { // 将时间转为时间戳 var now = +new Date(); args = arguments; // 时间间隔大于延迟时间才执行 if (now - previous > wait) { fn.apply(this, args); previous = now; } }; } 复制代码
- 触发监听事件,回调函数会立刻执行(初始的
previous
为 0,除非设置的时间间隔大于当前时间的时间戳,否则差值肯定大于时间间隔) - 停止触发后,无论停止时间在哪,都不会再执行。例如,1 秒执行 1 次,在 4.2 秒停止,则第 5 秒不会再执行 1 次
- 触发监听事件,回调函数会立刻执行(初始的
-
定时器实现
function throttle(fn, wait) { var timer, context, args; return function() { context = this; args = arguments; // 如果定时器存在,则不执行 if (!timer) { timer = setTimeout(function() { // 执行后释放定时器变量 timer = null; fn.apply(context, args); }, wait); } }; } 复制代码
- 回调函数不会立刻执行,要在 wait 秒后第一次执行,停止触发闭包后,如果停止时间在两次执行之间,则还会执行一次
-
结合时间戳和定时器实现
function throttle(fn, wait) { var timer, context, args; var previous = 0; // 延时执行函数 var later = function() { previous = +new Date(); // 执行后释放定时器变量 timer = null; fn.apply(context, args); if (!timeout) context = args = null; }; var throttled = function() { var now = +new Date(); // 距离下次执行 fn 的时间 // 如果人为修改系统时间,可能出现 now 小于 previous 情况 // 则剩余时间可能超过时间周期 wait var remaining = wait - (now - previous); context = this; args = arguments; // 没有剩余时间 || 修改系统时间导致时间异常,则会立即执行回调函数fn // 初次调用时,previous为0,除非wait大于当前时间的时间戳,否则剩余时间一定小于0 if (remaining <= 0 || remaining > wait) { // 如果存在延时执行定时器,将其取消掉 if (timer) { clearTimeout(timer); timer = null; } previous = now; fn.apply(context, args); if (!timeout) context = args = null; } else if (!timer) { // 设置延时执行 timer = setTimeout(later, remaining); } }; return throttled; } 复制代码
- 过程中的节流功能是由时间戳的原理实现,同时实现了立刻执行
- 定时器只是用来设置在最后退出时增加一个延时执行
- 定时器在每次触发时都会重新计时,但是只要不停止触发,就不会去执行回调函数 fn
4. 优化完善
-
增加第三个参数,让用户可以自己选择模式
- 忽略开始边界上的调用,传入
{ leading: false }
- 忽略结尾边界上的调用,传入
{ trailing: false }
- 忽略开始边界上的调用,传入
-
增加返回值功能
-
增加取消功能
function throttle(func, wait, options) { var context, args, result; var timeout = null; // 上次执行时间点 var previous = 0; if (!options) options = {}; // 延迟执行函数 var later = function() { // 若设定了开始边界不执行选项,上次执行时间始终为0 previous = options.leading === false ? 0 : new Date().getTime(); timeout = null; // func 可能会修改 timeout 变量 result = func.apply(context, args); // 定时器变量引用为空,表示最后一次执行,则要清除闭包引用的变量 if (!timeout) context = args = null; }; var throttled = function() { var now = new Date().getTime(); // 首次执行时,如果设定了开始边界不执行选项,将上次执行时间设定为当前时间。 if (!previous && options.leading === false) previous = now; // 延迟执行时间间隔 var remaining = wait - (now - previous); context = this; args = arguments; // 延迟时间间隔remaining小于等于0,表示上次执行至此所间隔时间已经超过一个时间窗口 // remaining 大于时间窗口 wait,表示客户端系统时间被调整过 if (remaining <= 0 || remaining > wait) { if (timeout) { clearTimeout(timeout); timeout = null; } previous = now; result = func.apply(context, args); if (!timeout) context = args = null; } else if (!timeout && options.trailing !== false) { timeout = setTimeout(later, remaining); } // 返回回调函数执行后的返回值 return result; }; throttled.cancel = function() { clearTimeout(timeout); previous = 0; timeout = context = args = null; }; return throttled; } 复制代码
- 有个问题,
leading: false
和trailing: false
不能同时设置- 第一次开始边界不执行,但是,第一次触发时,
previous
为 0,则remaining
值和wait
相等。所以,if (!previous && options.leading === false)
为真,改变了previous
的值,而if (remaining <= 0 || remaining > wait)
为假 - 以后再触发就会导致
if (!previous && options.leading === false)
为假,而if (remaining <= 0 || remaining > wait)
为真。就变成了开始边界执行。这样就和leading: false
冲突了
- 第一次开始边界不执行,但是,第一次触发时,
- 有个问题,
? 总结
- 至此,完整实现了一个
underscore
中的 debounce 函数和 throttle 函数 - 而
lodash
中 debounce 函数和 throttle 函数的实现更加复杂,封装更加彻底 - 推荐两个可视化执行过程的工具
- demo.nimius.net/debounce_th…
- caiogondim.github.io/js-debounce…
- 自己实现是为了学习其中的思想,实际开发中尽量使用 lodash 或 underscore 这样的类库。
对比
-
throttle 和 debounce 是解决请求和响应速度不匹配问题的两个方案。二者的差异在于选择不同的策略
-
电梯超时现象解释两者区别。假设电梯设定为 15 秒,不考虑容量限制
throttle
策略:保证如果电梯第 1 个人进来后,15 秒后准时送一次,不等待。如果没有人,则待机、debounce
策略:如果电梯有人进来,等待 15 秒,如果又有人进来,重新计时 15 秒,直到 15 秒超时都没有人再进来,则开始运送