手写防抖函数 debounce 和节流函数 throttle
本文参考:
基础理论
最近看到这么一道面试题:手写实现 debounce 和 throttle。
一脸懵逼,真的是。这两个英文单词都是什么鬼(原谅我英文词汇量太烂),后来看了下,原来是防抖和节流的意思啊。
那么,防抖和节流又是什么东西?
这两个东西,其实都是用来处理某个工作短时间内过于频繁触发的场景,只是根据不同的处理方式有不同的说法。
防抖:某个函数在短时间内只执行最后一次。
意思也就是说,函数被触发时,需要先延迟,在延迟的时间内,如果再次被触发,则取消之前的延迟,重新开始延迟。这样就能达到,只响应最后一次,其余的请求都过滤掉。
这种处理方式有很多实际的应用场景:比如对输入框数据的校验处理,没必要每输入一个字符就校验一遍;
节流:某个函数在指定时间段内只执行第一次,直到指定时间段结束,周而复始。
跟防抖不一样的是,节流是指定时间段内只执行第一次,也就是这段时间内,只需要响应第一次的请求即可,后续的请求都会被过滤掉,直到下个时间段,重新来过,周而复始。
应用场景:Android 里的屏幕刷新机制,每个帧(16.6ms)内,不管进行了多少次请求界面刷新的操作,只需响应第一次的请求,去向底层注册监听帧信号即可。因为接收到帧信号后,是通过遍历 View 树来刷新界面,所以注册的动作只需要进行一次就够了。Vue 的虚拟 DOM 的刷新也是类似的机制。
以上这些概念还不足以明白的话,再看张图(盗自开头链接中的文章):
这样一来就理解了吧,第一行表示不做任何处理,频繁调用函数,每次都会响应;
经过 debounce 防抖处理后,只响应最后一次,因为防抖本质上就是通过延迟,所以实际执行函数时机会晚于函数的请求时机;
而经过 throttle 节流处理后,是按一定的频率来处理这堆频繁调用的函数,每个周期内,只响应第一次,过滤后面的请求,直到下个周期。
其实,或许你并没有接触到 debounce 防抖或 throttle 节流这种专业术语的说法,但实际开发中,你肯定或多或少有进行过类似防抖或节流的处理。下面讲讲它的实现,你就会发现,很似曾相识。
手写 throttle 节流函数
节流,顾名思义,就是节省流量。那么,为什么可以节流,自然就是这频繁被触发的工作,其实没必要次次响应。
我们上面举了个 Android 的屏幕刷新机制的例子,也就是在一个周期内,可以有无数次会触发屏幕刷新的操作,但其实只要第一次的操作去注册一下帧信号就可以了。
实现上,其实也很简单,就是加个标志位而已:
1 | function throttle(fn, interval = 200) { |
是吧,就是简单的加个标志位来进行过滤。这里有个关键的点:fn.call(this, …args),为什么要通过 call 这种修改函数内部 this 的方式来调用原函数?直接 fn() 不行吗?
原因在手写 debounce 里分析吧,因为那里也是一样的处理。
那么,看到这个实现方案,有没有感觉有点熟悉,在项目中肯定会有所接触的,虽然由于这里的 throttle 函数是个通用的工具函数,而且是高阶函数,可能在项目中看到的不多。至少,我好像并没有在实际项目中使用过。
但这样的,你肯定经常写:
1 | var flag = null; |
这种通过 flag 标志位过滤重复事件的处理,其实就跟节流的思想有点类似。区别只是,节流是通过一定的频率来修改标志位,来重新放行,而上面这种用法,则是依赖于某个任务完成后,再去回调修改标志位,也就是任务不完成,重复的事件都会被过滤。但两者的思想其实很类似。
手写 debounce 防抖函数
防抖处理我实际中用得比较多,所以打算讲讲,网上大众的实现,以及我针对具体项目的场景下的实现。
js 版
网上基本都是用的高阶函数实现,即封装一个工具函数 debounce,它以参数形式接收原函数,并返回一个经过防抖处理的新函数,后续涉及到需要防抖处理的,都需要使用新函数来替代原函数。
1 | function debounce(fn, delay = 200) { |
其实很简单,就是每次调用函数前,先移除上次还处于延迟中的任务,然后重新发起一次新的延迟等待。
上面最重要的地方在于 fn.call(this, …args),这里之所以要通过 call 方式来修改原函数的 this,是因为,原函数通过参数进行传递时,是只会被当做普通函数处理,不管原函数本来是否挂载在某个对象上。
所以,如果 debounce 内部直接以 fn() 方式调用原函数,会导致原函数的内部 this 指向发生变化。
有两种解决方式:
一是:debounce 以 fn() 方式调用,但在使用 debounce 的地方,传递 fn 原函数时需要先进行绑定,如:
1 | var o = { |
这是一种方式,缺点是需要使用者手动进行显示绑定 this。
另一种方式:debounce 内部通过 apply 或 call 方式来调用原函数。
但这种方式也有一个前提,就是 debounce 返回的新函数需要把它当做原函数,和原函数一样的处理。如果原函数本来挂载在某对象上,新生成的函数也需要挂载到那对象上,因为 debounce 内部的 fn.call(this) 时,这个 this 是指返回的新函数调用时的 this。所以,需要让新函数的 this 和原函数是一致的,才会是期望的正常行为。
1 | var o = { |
总之,debounce 的用途就是通用的工具函数,所以需要防抖处理的工作,都可以通过 debounce 进行包装转换。
就算你没写过这个通用的工具函数,至少在项目中,也写过直接定义一个全局变量来进行防抖处理吧,类似这样:
1 | var flag = null; |
这其实也是防抖的处理,只是实现方式是直接对需要进行防抖处理的函数,在其代码基础上,直接进行改动。不具有通用性。
所以我才说,网上大众版的 debounce 防抖函数,也许你没接触过,也没见过,但不代表你没接触到防抖处理的思想,在实际项目中,其实或多或少都会有所接触了,只是实现的方式、通用性等不一样而已。
当然,以上的 js 版实现,只是一种最基础的方案,文章开头给出的链接中,还有很多扩展的实现,比如增加了支持第一次触发立即执行的功能;和 throttle 节流结合用法;手动取消延迟的功能等等。
感兴趣的可以自行查阅,我是觉得,大概知道基础思想就够了,实际项目中再根据需要去进行扩展。
ts + angular 版
我还想讲讲我在实际项目中所进行的防抖处理,上面的 js 版在每篇防抖文章中,基本都是那样实现,都是封装一个高阶函数。
但我实际开发中,使用的是 TypeScript,这是一种类似于 Java 思想的强类型语言,所以很少会用到高阶函数的思想,更多的是封装工具类。
再加上,我框架是使用 angular,项目中除了有防抖处理的场景,还有其他诸如延迟任务的场景,轮询任务的场景等等。这些不管是从用法、实现上等来说,都很相似,所以我都统一封装在一起。
另外,涉及 setTimeout,setInterval 这两个 API,如果没有进行清理工作,很容易造成内存泄漏,因此跟 setTimeout 和 setInterval 相关的用法,我都将它跟 angular 的组件进行绑定处理,避免开发人员忘记清理,至少我还可以在组件销毁时去自动清理。
1 | export class PollingTaskUtils { |
当初封装的时候没有写注释,感兴趣的再细看吧,这里就是做个记录,方便后续查阅,下面看看用法:
1 | /** |
其实用法跟直接用 setTimeout 和 setInterval 没多大区别,但好处在于,增加了跟组件的绑定,增加了对任务标识的处理,这样一来,即使忘记清理,内部也可以在组件销毁时自动去清理,即使多次调用,只要任务标识不一样,内部就会进行防抖处理。可以省掉一部分的工作量。
当然,这些所有的出发点,仅适用于我的项目,因为毕竟是从项目中遇到的需求中来进行封装处理的,并不一定适用于你。
我想说的是,这些工具函数的封装,重要的是掌握其思想,为什么需要进行防抖处理?防抖处理的基本实现是什么?知道这些即可,其余的,再自行根据需要扩展学习。