节流与防抖
防抖和节流是针对响应跟不上触发频率这类问题的两种解决方案。在给DOM绑定事件时,有些事件我们是无法控制触发频率的。 如鼠标移动事件onmousemove, 滚动滚动条事件onscroll,窗口大小改变事件onresize,这类操作都会导致事件会被高频触发。如果事件的回调函数较为复杂,就会导致响应跟不上触发,出现页面卡顿,假死现象。
又比如,在实时检查输入时,如果我们绑定onkeyup事件发请求去服务端检查,用户输入过程中,事件的触发频率也会很高,会导致大量的请求发出,响应速度会大大跟不上触发。
在滚动事件中需要做个复杂计算或者实现一个按钮的防二次点击操作。这些需求都可以通过函数防抖动来实现。
尤其是第一个需求,如果在频繁的事件回调中做复杂计算,很有可能导致页面卡顿,不如将多次计算合并为一次计算,只在一个精确点做操作。
防抖和节流的作用都是防止函数多次调用。
防抖
debounce,去抖动。
debounce基本思想:当事件被触发时,设定一个周期延迟执行,若期间又被触发,则重新设定周期,直到周期结束,执行动作。
这意味着,在一个周期内,狂点的话,每次点击都会重新倒计时。因此,最后一次点击后,再等待一个周期的时间,才会执行。
如图:
后期,又扩展了前缘debounce,即执行动作在前,然后设定周期,周期内有事件被触发,不执行动作,且周期重新设定。
也就是说,在一个周期内,狂点的话,第一次点击就会马上执行,后续的所有点击都不会执行动作,只会重新倒计时,最后更新的倒计时结束之后,会清空计时器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// func是用户传入需要防抖的函数 // wait是等待时间 const debounce = (func, wait = 50) => { // 缓存一个定时器id let timer = 0 // 这里返回的函数是每次用户实际调用的防抖函数 // 如果已经设定过定时器了就清空上一次的定时器 // 开始一个新的定时器,延迟执行用户传入的方法 return function(...args) { if (timer) clearTimeout(timer) timer = setTimeout(() => { func.apply(this, args) }, wait) } } // 不难看出如果用户调用该函数的间隔小于wait的情况下,上一次的时间还未到就被清除了,并不会执行函数 |
这个防抖只能在最后调用。一般的防抖会有immediate选项,表示是否立即调用。
1. 在搜索引擎搜索的时候,显然希望用户输入完最后一个字才调用查询接口,此时适用延迟执行的防抖函数,它总是在一连串(间隔小于wait的)函数触发之后调用。
2. 用户在github点star的时候,在用户点第一下的时候就要去调用接口,并且成功之后改变star按钮的样子,此时适用立即执行的防抖函数,它总是在第一次调用,并且下一次调用必须与前一次调用的时间间隔大于wait才会触发。
优化后,带有立即执行选项的防抖函数。如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
// 这个是用来获取当前时间戳的 function now() { return +new Date() } /** * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行 * * @param {function} func 回调函数 * @param {number} wait 表示时间窗口的间隔 * @param {boolean} immediate 设置为ture时,是否立即调用函数 * @return {function} 返回客户调用函数 */ function debounce (func, wait = 50, immediate = true) { let timer, context, args // 延迟执行函数 // 时间一到,清除定时器ID,如果是延迟调用则调用函数 const later = () => setTimeout(() => { // 延迟函数执行完毕,清空缓存的定时器序号 timer = null // 延迟执行的情况下,函数会在延迟函数中执行 // 使用到之前缓存的参数和上下文 if (!immediate) { func.apply(context, args) context = args = null } }, wait) // 这里返回的函数是每次实际调用的函数 return function(...params) { // 如果没有创建延迟执行函数(later),就创建一个 if (!timer) { timer = later() // 如果是立即执行,调用函数 // 否则缓存参数和调用上下文 if (immediate) { func.apply(this, params) } else { context = this args = params } // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个 // 这样做延迟函数会重新计时 } else { clearTimeout(timer) timer = later() } } } |
对于按钮防二次点击的实现:
如果函数是立即执行的,就立即调用;
如果函数是延迟执行的,就缓存上下文和参数,放到延迟函数中去执行。开始一个定时器后,只要定时器还在,每次点击都重新计时。等定时器时间到,定时器重置为 null,就可以再次点击了。
debounce的其他版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// 版本1 // 周期内有新事件触发,清除旧定时器,重置新定时器;这种方法,需要高频的创建定时器。 var debounce = (fn, wait) => { let timer, timeStamp=0; let context, args; let run = ()=>{ timer= setTimeout(()=>{ fn.apply(context,args); },wait); } let clean = () => { clearTimeout(timer); } return function(){ context=this; args=arguments; let now = (new Date()).getTime(); if(now-timeStamp < wait){ console.log('reset',now); clean(); // clear running timer run(); // reset new timer from current time }else{ console.log('set',now); run(); // last timer alreay executed, set a new timer } timeStamp=now; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 版本2 // 周期内有新事件触发时,重置定时器开始时间戳,定时器执行时,判断开始时间戳,若开始时间戳被推后,重新设定延时定时器。 // 优化版: 定时器执行时,判断start time 是否向后推迟了,若是,设置延迟定时器 var debounce = (fn, wait) => { let timer, startTimeStamp=0; let context, args; let run = (timerInterval)=>{ timer= setTimeout(()=>{ let now = (new Date()).getTime(); let interval=now-startTimeStamp if(interval<timerInterval){ // the timer start time has been reset, so the interval is less than timerInterval console.log('debounce reset',timerInterval-interval); startTimeStamp=now; run(timerInterval-interval); // reset timer for left time }else{ fn.apply(context,args); clearTimeout(timer); timer=null; } },timerInterval); } return function(){ context=this; args=arguments; let now = (new Date()).getTime(); startTimeStamp=now; if(!timer){ console.log('debounce set',wait); run(wait); // last timer alreay executed, set a new timer } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
// 版本3 // 在版本2的基础上,增加了是否立即执行选项 // 增加前缘触发功能 var debounce = (fn, wait, immediate=false) => { let timer, startTimeStamp = 0; let context, args; let run = (timerInterval)=>{ timer= setTimeout(()=>{ let now = (new Date()).getTime(); let interval=now-startTimeStamp if(interval<timerInterval){ // the timer start time has been reset,so the interval is less than timerInterval console.log('debounce reset',timerInterval-interval); startTimeStamp=now; run(timerInterval-interval); // reset timer for left time }else{ if(!immediate){ fn.apply(context,args); } clearTimeout(timer); timer=null; } },timerInterval); } return function(){ context=this; args=arguments; let now = (new Date()).getTime(); startTimeStamp=now; // set timer start time if(!timer){ console.log('debounce set',wait); if(immediate) { fn.apply(context,args); } run(wait); // last timer alreay executed, set a new timer } } } |
节流
节流的策略是,固定周期内,只执行一次动作,若有新事件触发,不执行。周期结束后,又有事件触发,开始新的周期。
特点是:在连续高频触发事件时,动作会被定期执行,响应平滑。
节流策略也分前缘和延迟两种。延迟是指周期结束后执行动作,前缘是指执行动作后再开始周期。
延迟的节流示意图:
前缘的节流示意图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
/** * underscore 节流函数,返回函数连续调用时,func 执行频率限定为 次 / wait * * @param {function} func 回调函数 * @param {number} wait 表示时间窗口的间隔 * @param {object} options 如果想忽略开始函数的的调用,传入{leading: false}。 * 如果想忽略结尾函数的调用,传入{trailing: false} * 两者不能共存,否则函数不能执行 * @return {function} 返回客户调用函数 */ _.throttle = function(func, wait, options) { var context, args, result; var timeout = null; // 之前的时间戳 var previous = 0; // 如果 options 没传则设为空对象 if (!options) options = {}; // 定时器回调函数 var later = function() { // 如果设置了 leading,就将 previous 设为 0 // 用于下面函数的第一个 if 判断 previous = options.leading === false ? 0 : _.now(); // 置空一是为了防止内存泄漏,二是为了下面的定时器判断 timeout = null; result = func.apply(context, args); if (!timeout) context = args = null; }; return function() { // 获得当前时间戳 var now = _.now(); // 首次进入前者肯定为 true // 如果需要第一次不执行函数 // 就将上次时间戳设为当前的 // 这样在接下来计算 remaining 的值时会大于0 if (!previous && options.leading === false) previous = now; // 计算剩余时间 var remaining = wait - (now - previous); context = this; args = arguments; // 如果当前调用已经大于上次调用时间 + wait // 或者用户手动调了时间 // 如果设置了 trailing,只会进入这个条件 // 如果没有设置 leading,那么第一次会进入这个条件 // 还有一点,你可能会觉得开启了定时器那么应该不会进入这个 if 条件了 // 其实还是会进入的,因为定时器的延时 // 并不是准确的时间,很可能你设置了2秒 // 但是他需要2.2秒才触发,这时候就会进入这个条件 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) { // 判断是否设置了定时器和 trailing // 没有的话就开启一个定时器 // 并且不能不能同时设置 leading 和 trailing timeout = setTimeout(later, remaining); } return result; }; }; |
其他版本的throttle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// 版本1 // 简单版: 定时器期间,只执行最后一次操作 var throttling = (fn, wait) => { let timer; let context, args; let run = () => { timer=setTimeout(()=>{ fn.apply(context,args); clearTimeout(timer); timer=null; },wait); } return function () { context=this; args=arguments; if(!timer){ console.log("throttle, set"); run(); }else{ console.log("throttle, ignore"); } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// 版本2 // 增加前缘选项 var throttling = (fn, wait, immediate) => { let timer, timeStamp=0; let context, args; let run = () => { timer=setTimeout(()=>{ if(!immediate){ fn.apply(context,args); } clearTimeout(timer); timer=null; },wait); } return function () { context=this; args=arguments; if(!timer){ console.log("throttle, set"); if(immediate){ fn.apply(context,args); } run(); }else{ console.log("throttle, ignore"); } } } |
总结
debounce
和throttling
各有特点,要根据具体情况进行选择。如果事件触发是高频但是有停顿时,可以选择debounce; 在事件连续不断高频触发时,只能选择throttling,因为debounce可能会导致动作只被执行一次,界面出现跳跃。