throttle(节流)和 debounce(防抖)

前言

今天看到京东 Taro 一个页面,感觉进度条怪怪的(请缩小屏幕宽度或者使用手机端),总有一种跟不上的感觉,一看代码才知道,transition设置成 50ms,看桌面端的呢,0.6s,效果就舒服很多了~然后想到这个可以写个文章……………

需求

像京东这个进度条,实现原理就是通过scroll事件来驱动的,但是如果你直接用scroll事件,就会发现触发频率非常之高,如果每次都通过这样的方式然后计算高度到底看到哪了,十分小号性能,而且这是没必要的,所以诞生出 throttle(节流),意思就是,一个单位时间内,只会触发一次,比如滑到底部需要一秒,那么我们算 24 帧去检查,就可以做到同样效果而且有效避免无谓性能消耗。

throttle

先上代码,这边的核心代码是throttle,实现起来也很简单

1
2
3
4
5
6
7
8
9
10
11
12
throttle = function(func, delay) {
var timer;
return function() {
var context = this;
if (!timer) {
timer = setTimeout(function() {
func.apply(context, arguments);
timer = undefined;
}, delay || 1000 / 24);
}
};
};

通过闭包实现一个函数的包装, 实现内部timer的存在,每次触发之后计时器运行,执行之后重置为undefined

debounce

标题写了防抖,那就肯定还有一个东西,其实也和throttle类似,只不过他不是单位时间内只允许一次,而是,如果你在单位时间内重复了操作,那么这个时间重置,形象一点的 🌰 就是用户输入数据检查,比如手机号,其实没输完之前去检查他是没意义的,那么可以等到用户停止输入的一刻,再去检查,在他输入的过程中不断重置时间,我这边懒得写第二个,就用同样的进度条 🌰 看看

核心代码

1
2
3
4
5
6
7
8
9
10
11
debounce = function(func, delay) {
var timer;
return function() {
var context = this;
clearTimeout(timer);
timer = setTimeout(function() {
func.apply(context, arguments);
timer = undefined;
}, delay || 1000 / 24);
};
};

逻辑没啥好说,基本差不多,做全一点可以给返回的方法里面加一个cancel来取消单次执行。

定时器是怎么运作

其实这些写起来都不难,但是定时器是如何运作的呢?JavaScript 是单线程如何实现定时器功能?浏览器和 Node.JavaScript 环境是否又不一样呢?

浏览器

一般来讲,浏览器会多个常驻线程:

  • GUI 渲染线程
    • 主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树,布局和绘制等。
    • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
    • 该线程与 JavaScript 引擎线程互斥,当执行 JavaScript 引擎线程时,GUI 渲染会被挂起,当任务队列空闲时,主线程才会去执行 GUI 渲染。
  • JavaScript 引擎线程
    • 该线程当然是主要负责处理 JavaScript 脚本,执行代码。
    • 也是主要负责执行准备好待执行的事件,将依次进入任务队列,等待 JavaScript 引擎线程的执行。
    • 当然,该线程与 GUI 渲染线程互斥,当 JavaScript 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。
  • 定时触发器线程
    • 负责执行异步定时器一类的函数的线程,如: setTimeoutsetInterval
    • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JavaScript 引擎线程执行。
  • 事件触发线程
    • 主要负责将准备好的事件交给 JavaScript 引擎线程执行,比如定时器,网页的事件,请求回调等等
  • 异步 http 请求线程
    • 负责执行异步请求一类的函数的线程,如: Promise,axios,ajax 等。
    • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JavaScript 引擎线程执行。

其实浏览器就是通过定时器触发器线程来处理,底层可能就是调用 Native 层去实现,然后计时结束,就会加入到事件触发线程,然后事件触发线程会加入到 JavaScript 引擎线程,由于要经过这个排队时间,所以这个定时,并不准确。

Node.js

Node.js 我们知道,底层是 libuv,而底层 libuv 里面有六个事件循环,不断运行:

  • timers 阶段:这个阶段执行 timer(setTimeoutsetInterval)的回调
  • pending callbacks 阶段:处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段:仅 node 内部使用
  • poll 阶段:获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
  • check 阶段:执行 setImmediate 的回调
  • close callbacks 阶段:一些关闭回调,例如socket.on('close',...)

这边可以看到也是有一个专门的 timer 阶段,也是交给底层实现,然后回调。

clearTimeout

由于定时器都是底层实现,所以如果我们需要清除定时器的话,就需要用到系统 APIclearTimeout,如果你单纯的重置 timer 也没有任何效果,毕竟不能自欺欺人…底层已经在计时了。

总结

throttle 和 debounce 十分常用,毕竟前端就是跟用户打交道,但是里面实现核心 timer 可以牵涉十分多内容,event loop 运作,浏览器细看还能发现回流等字眼,发现小细节,才能更理解代码到底是怎么运作的。