前言
防抖与节流通常作为项目优化的手段,一般都是为了防止用户在短时间内快而频地多次操作,触发动作执行。比如防止用户点击多次提交按钮,触发表单多次提交;防止用户拉动滚动条,多次触发加载更多等情况
防抖
什么是防抖
防抖,顾名思义,就是防止抖动,简而言之就是多次快速频繁地触发事件,也只会执行一次事件函数,但是要记住,需要加上一个时间限制(总不能上一次触发和下一次触发前后相隔半个钟,也才执行一次吧?这样就不是防抖了,而是bug了)。
所以,防抖最正确的解释应该是,为了防止快速且频繁的触发事件而导致多次执行事件函数,我们这多次触发的事件只执行一次事件函数。而对于如何定义“频繁”,我们可以设定一个间隔时间,如果两次事件触发的间隔时间低于设定的时间,则定义为频繁。这样的场景有很多,比如监听滚动、鼠标移动事件onmousemove、频繁点击表单的提交按钮等等
debounce的特点是当事件快速连续不断触发时,动作只会执行一次。分为两种,一种是延迟debounce,一种是前缘debounce。 延迟debounce,是在周期结束时执行,前缘debounce,是在周期开始时执行。但当触发有间断,且间断大于我们设定的间隔时间时,动作就会有多次执行。
延迟debounce策略是当事件被触发时,设定一个周期延迟执行动作,若期间又被触发,则重新设定周期,直到周期结束,执行动作。
前缘debounce,即执行动作在前,然后设定周期,周期内有事件被触发,不执行动作,且周期重新设定。
实现防抖函数
版本1: 周期内有新事件触发,就清除旧定时器,重置新定时器;这种方法,需要高频的创建定时器。
此方法属于延迟debounce,即周期结束时执行,而且这种方法是多次创建新的计时器去替换旧的计时器,意思就是周期会不断刷新替换,只在最后一次周期结束后,才会执行
importReactfrom'react'exportdefaultfunctionDebunceTest() { // 创建debounce防抖函数constdebounce= (func: any, wait: any) => { // func为原本事件触发后所需要执行的函数、wait为防抖的周期时长lettimeout: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储定时器,以便判断当前是否处于定时状态(防抖状态),因此借助闭包缓存定时器实例letcontext: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。因此借助闭包缓存调用者的thisletargs: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数。// 事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了。因此借助闭包缓存事件函数参数(比如event参数)// 通过定时器延迟执行事件函数letrun= ()=>{ timeout=setTimeout(()=>{ // 通过 apply 修改func的this指向,并让func获取真正的事件函数(即防抖函数return出来的函数)的参数,之后执行funcfunc.apply(context,args); }, wait); } // 清除定时器letclean= () => { clearTimeout(timeout); } returnfunction (this: any) { // 备注:"this: any" 是ts的写法,如果不传this,下方语句“context = this;”会报错,js的写法不传this也不会报错context=this; // 谁调用函数(这里的函数是防抖函数return出来的函数),this就指向谁。// 这步不能少,因为原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。args=arguments; // arguments 是一个对应于传递给函数的参数的类数组对象,可以获取函数的参数(这里的函数是防抖函数return出来的函数)。// 这步不能少,因为某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了if (timeout) { // 说明当前仍然处在上一个计时周期过程中,并且又触发了相同事件。则取消上一个计时周期,重新创建周期,覆盖上一次的操作console.log('reset'); clean(); // 取消当前计时周期run(); // 重新创建周期,覆盖上一次的操作 } else { // 说明上一个计时周期已经结束,那么可以开启新的周期console.log('set'); run(); } } } // 要执行的事件函数consthandleClick= (e: any) => { console.log('提交表单', e); } return ( <divonClick={debounce(handleClick, 1500)}>提交表单</div> ) }
版本2:周期内有新事件触发时,重置定时器开始时间戳,定时器执行时,判断开始时间戳,若开始时间戳被推后,重新设定延时定时器。并且增加是否立即执行选项(也就是前缘debounce或延迟debounce)
importReactfrom'react'exportdefaultfunctionDebunceTest() { // 创建debounce防抖函数constdebounce= (func: any, wait: any, immediate: any) => { // func为原本事件触发后所需要执行的函数、wait为防抖的周期时长、immediate为是否先执行事件函数func再做防抖(true为前缘debounce,false为延迟debounce)lettimeout: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储定时器,以便判断当前是否处于定时状态(防抖状态),因此借助闭包缓存定时器实例letcontext: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。因此借助闭包缓存调用者的thisletargs: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数。// 事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了。因此借助闭包缓存事件函数参数(比如event参数)lettimestamp: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来记录当前最新的定时器计时时间内最后一次事件触发的时间戳,因此借助闭包缓存当前最新的定时器计时时间内最后一次事件触发的时间戳letresult: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储func执行之后返回的结果,因此借助闭包缓存func执行之后返回的结果letlater=function() { console.log(2111); // 获取当前时间letnow= (newDate()).getTime(); // 获取当前定时器计时时间内最后一次事件触发至定时器计时结束的时间间隔letlast=now-timestamp; // 如果当前间隔时间少于设定的时间wait且大于0就重新设置定时器://(比如wait传参是3000ms,而第一次计时周期内(3000ms内)最后一次触发事件发生在了2900ms,// 那么在经历完3000ms之后,执行定时器的回调函数,发现最后一次触发事件距离3000ms的计时结束的时间间隔才100ms,不足3000ms,// 这时候在执行定时器的回调时,不应该立马判定当前3000ms周期内的防抖结束,因为用户是有可能会在超过3000ms周期之后,比如超过3000ms周期100ms之后,还会触发事件,// 那么我们就应该继续执行防抖,阻止func的多次执行,所以需要再次启动定时器,再次启动的定时器的时间周期应该是 “wait-当前定时器计时时间内最后一次事件触发至定时器计时结束的时间间隔” ,// 而不是盲目的再次以wait为周期重新创建定时器(这样可以缩短启动下次防抖的时间,以及缩短非立即执行func的开始执行时间))if (last<wait&&last>=0) { console.log(3221); timeout=setTimeout(later, wait-last); } else { console.log('用户停止触发事件且计时结束了'); // 清空timeout、context、args闭包变量,释放内存timeout=context=args=null; // 如果不是立即执行,则执行funcif (!immediate) { result=func.apply(context, args); } } }; returnfunction(this: any) { // 备注:"this: any" 是ts的写法,如果不传this,下方语句“context = this;”会报错,js的写法不传this也不会报错context=this; // 谁调用函数(这里的函数是防抖函数return出来的函数),this就指向谁。// 这步不能少,因为原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。args=arguments; // arguments 是一个对应于传递给函数的参数的类数组对象,可以获取函数的参数(这里的函数是防抖函数return出来的函数)。// 这步不能少,因为某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了// 获得当前事件触发的时间戳letnow= (newDate()).getTime() // 更新当前最新的定时器计时时间内最后一次事件触发的时间戳timestamp=now; // 如果定时器不存在且func需要立即执行if (!timeout&&immediate) { // 通过 apply 修改func的this指向,并让func获取真正的事件函数(即防抖函数return出来的函数)的参数,之后执行funcresult=func.apply(context, args); context=args=null; } // 如果定时器不存在就创建一个(这里创建的定时器只会在每次wait时间周期内的第一次事件触发时创建,因为这个是setTimeout而不是setInterval,所以当第一次触发事件创建定时器之后,timeout就会一直存在或者被其他定时器覆盖,而不会被清空)if (!timeout) { timeout=setTimeout(later, wait); } // 如果定时器存在,说明此时处于防抖状态,不需要再次创建新的定时器if (timeout) { console.log('如果定时器存在,说明此时处于防抖状态,不需要再次创建新的定时器'); } // 返回func执行之后的结果returnresult; }; }; // 要执行的事件函数consthandleClick= (e: any) => { console.log('提交表单', e); } return ( <divonClick={debounce(handleClick, 3000, true)}>提交表单</div> ) }
节流
什么是节流
防抖动和节流本质是不一样的。防抖动是多次触发但只会执行一次,节流是多次触发但周期内只会执行一次
throttling,节流的策略是,每个时间周期内,不论触发多少次事件,也只执行一次动作。上一个时间周期结束后,又有事件触发,开始新的时间周期,同样新的时间周期也只会执行一次动作。 节流策略也分前缘和延迟两种。与debounce类似,延迟是指周期结束后执行动作,前缘是指执行动作后再开始周期。
延迟throttling示意图:
前缘throttling 示意图:
实现节流函数
对于节流,一般有两种方式可以实现,分别是时间戳版和定时器版。
时间戳版:(前缘throttling)
importReactfrom'react'exportdefaultfunctionThrottleTest() { letthrottle= (func: any, wait: any) => { // func为原本事件触发后所需要执行的函数、wait为节流的周期时长letprev: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储一个新周期的起始时间,因此借助闭包缓存新周期的起始时间returnfunction(this: any) { // 备注:"this: any" 是ts的写法,如果不传this,下方语句“context = this;”会报错,js的写法不传this也不会报错letcontext=this; // 谁调用函数(这里的函数是节流函数return出来的函数),this就指向谁。// 这步不能少,因为原本的事件函数func,我们是作为参数传递给了节流函数,事件真正执行的函数是节流函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。letargs=arguments; // arguments 是一个对应于传递给函数的参数的类数组对象,可以获取函数的参数(这里的函数是节流函数return出来的函数)。// 这步不能少,因为某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了节流函数,事件真正执行的函数是节流函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把节流函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了letnow=Date.now(); // 获取当前时间if (!prev||now-prev>=wait) { // 如果当前周期起始时间不存在(那就是第一次触发事件),或者当前周期已经结束console.log('新周期开始'); func.apply(context, args); // 通过 apply 修改func的this指向,并让func获取真正的事件函数(即节流函数return出来的函数)的参数,之后执行funcprev=Date.now(); // 更新新周期的起始事件 } } } // 要执行的事件函数consthandleClick= (e: any) => { console.log('提交表单', e); } return ( <divonClick={throttle(handleClick, 3000)}>提交表单</div> ) }
定时器版:(延迟throttling)
importReactfrom'react'exportdefaultfunctionThrottleTest() { letthrottle= (func: any, wait: any) => { // func为原本事件触发后所需要执行的函数、wait为节流的周期时长lettimeout: any=null; returnfunction(this: any) { // 备注:"this: any" 是ts的写法,如果不传this,下方语句“context = this;”会报错,js的写法不传this也不会报错letcontext=this; // 谁调用函数(这里的函数是节流函数return出来的函数),this就指向谁。// 这步不能少,因为原本的事件函数func,我们是作为参数传递给了节流函数,事件真正执行的函数是节流函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。letargs=arguments; // arguments 是一个对应于传递给函数的参数的类数组对象,可以获取函数的参数(这里的函数是节流函数return出来的函数)。// 这步不能少,因为某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了节流函数,事件真正执行的函数是节流函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把节流函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了if (!timeout) { timeout=setTimeout(() => { timeout=null; func.apply(context, args); // 通过 apply 修改func的this指向,并让func获取真正的事件函数(即节流函数return出来的函数)的参数,之后执行func }, wait) } } } // 要执行的事件函数consthandleClick= (e: any) => { console.log('提交表单', e); } return ( <divonClick={throttle(handleClick, 3000)}>提交表单</div> ) }
时间戳版本与定时器版本的可配置结合(配置前缘throttling以及延迟throttling):
importReactfrom'react'exportdefaultfunctionThrottleTest() { constthrottle= (func: any, wait: any, options: any) => { // func为原本事件触发后所需要执行的函数;// wait为节流的周期时长;// options是一个对象,如果想周期内第一次触发事件就执行func(即前缘throttling),传入{leading: false},如果想周期内最后一次触发事件才执行func(即延迟throttling),传入{trailing: false},两者不能共存,否则函数不能执行lettimeout: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储定时器,以便判断当前是否处于定时状态(防抖状态),因此借助闭包缓存定时器实例letcontext: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。因此借助闭包缓存调用者的thisletargs: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数。// 事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了。因此借助闭包缓存事件函数参数(比如event参数)letresult: any=null; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储func执行之后返回的结果,因此借助闭包缓存func执行之后返回的结果letprevious=0; // 为了避免全局的命名污染,因此我们不考虑使用全局变量。但同时我们需要一个变量来存储一个新周期的起始时间,因此借助闭包缓存新周期的起始时间// 如果 options 没传则设为空对象if (!options) options= {}; letlater=function() { previous=options.leading===false?0 : Date.now(); // 如果是设定周期内第一次触发事件就执行func,那么就重置previous为0,否则就将previous设为当前时间timeout=null; // 清空context、args闭包变量,释放内存result=func.apply(context, args); // 通过 apply 修改func的this指向,并让func获取真正的事件函数(即节流函数return出来的函数)的参数,之后执行funcif (!timeout) context=args=null; // 清空context、args闭包变量,释放内存 }; returnfunction(this: any) { // 备注:"this: any" 是ts的写法,如果不传this,下方语句“context = this;”会报错,js的写法不传this也不会报错letnow=Date.now(); if (!previous&&options.leading===false) previous=now; // 如果当前周期起始时间不存在(那就是第一次触发事件),或者当前周期已经结束letremaining=wait- (now-previous); context=this; // 谁调用函数(这里的函数是防抖函数return出来的函数),this就指向谁。// 这步不能少,因为原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数。// 此时func的this指向是window,如果func内部想修改this指向当前函数的调用者,就必须存储this,之后借助apply修改func的this指向。args=arguments; // arguments 是一个对应于传递给函数的参数的类数组对象,可以获取函数的参数(这里的函数是防抖函数return出来的函数)。// 这步不能少,因为某些事件函数是自带event参数的(比如click、input),而此时原本的事件函数func,我们是作为参数传递给了防抖函数,事件真正执行的函数是防抖函数return出来的函数,只有它才能拿到event参数。// 而原本的事件函数func是拿不到event参数的,如果少了这步,不把防抖函数return出来的函数的arguments对象存起来的话 func就获取不到event参数了if (remaining<=0||remaining>wait) { if (timeout) { clearTimeout(timeout); timeout=null; } previous=now; result=func.apply(context, args); if (!timeout) context=args=null; } elseif (!timeout&&options.trailing!==false) { timeout=setTimeout(later, remaining); } returnresult; }; }; // 要执行的事件函数consthandleClick= (e: any) => { console.log('提交表单', e); } return ( <divonClick={throttle(handleClick, 3000, {trailing: false})}>提交表单</div> ) }
防抖与节流注意事项
①使用闭包
为了避免全局的命名污染,因此我们不考虑使用全局变量。同时为了让所需变量能得到缓存,因此我们使用闭包存储部分需要用到的变量
②this指向的问题以及event参数:
因为事件触发调用的,是防抖/节流函数return出来的函数,而不是事件函数func,事件函数func是作为参数传入防抖/节流函数,其this指向是window,而不是触发事件调用函数的dom,同理,自然也拿不到事件自带的event参数,所以我们要通过闭包,存储this上下文以及argument这一传递给函数的参数的类数组对象
实际上,所有的高阶函数,内部都需要注意this的绑定
文章参考
https://blog.csdn.net/hupian1989/article/details/80920324
https://segmentfault.com/a/1190000018428170