背景
众所周知,浏览器的 JavaScript 引擎是基于单线程的。我们可以通过 setTimeout
来创建异步任务,实现防抖(debounce)、节流(throttle)、缓存续期(renew)等操作。
通过 setTimeout
和 clearTimeout
,我们很快就可以实现一个缓存管理器:
class Cache {
// ...
put(key, value, lifetime) {
const cache = { value };
this.data[key] = cache;
this.hit(key, lifetime);
}
hit(key, lifetime) {
const cache = this.data[key];
if (cache) {
clearTimeout(cache.timer); cache.timer = setTimeout(this.clear, lifetime, key); }
}
}
然后在一个频繁使用缓存的场景下,我们惊奇地发现,缓存处理占用的时间占据了总处理时间的一半,时间都耗费在了 setTimeout
和 clearTimeout
上,这个开销是非常大的,甚至让我们使用缓存的意义都不那么大了。
原因分析
首先,我们可以大概了解一下 setTimeout
和 clearTimeout
的原理。
根据HTML规范,可以知道,每次调用 setTimeout
都会发生以下步骤:
- 做一些准备工作;
- 生成一个 handle,值为一个大于 0 的整数,用于标识当前设置的 timeout;
- 把这个 handle 加入到激活的定时器列表(list of active timers)中,并关联一个任务;
- 返回这个 handle,等前面的 timer 执行完后执行这个任务。
每次 clearTimeout
都会从激活的定时器列表中去掉对应的 handle。
这里就存在了一个问题,每次新增和销毁 timer,都会涉及一个列表的操作,而且由于不同的 timer 之间是有执行顺序的,所以这个列表中的元素必然会相互影响。所以不论这个列表底层是如何维护的,频繁操作肯定会有一些问题。
于是,我用 Chrome 的 Performance 工具做了个实验。
用上面实现的 cache,连续调用 1000 次缓存续期:
for (let i = 0; i < 1000; i += 1) {
cache.hit(key, 1000);
}
得到如下结果:
从图中可以看到,CPU 的消耗有了一个高峰,setTimeout
和 clearTimeout
耗时近50ms,如果同时有别的操作,则很容易造成页面卡顿。而实际上,上面的代码啥都没干,从结果来看,把整个循环去掉,也不会产生什么区别,这就意味着,timer 的消耗非常不划算,有很大的优化空间。
解决方案
我们无法对浏览器底层进行优化,但是我们可以从实现上尽量避免产生无效的 timer:
- 每次只产生一个 timer,同时保存续期后实际的到期时间;
- 等当前 timer 到期后,重新计算超时时间,如果有需要,再重新创建一个 timer。
这个过程是连续的(consecutive),而不再是有很多个 timer 在并行,所以逻辑上更清晰了,效率也明显提升。
代码改造如下:
class Cache {
// ...
put(key, value, lifetime) {
const cache = { value };
this.data[key] = cache;
this.hit(key, lifetime);
}
hit(key, lifetime) {
const cache = this.data[key];
if (cache) {
cache.expire = Date.now() + lifetime;
if (!cache.timer) this.checkLater(key); }
}
check(key) {
const cache = this.data[key];
if (cache) {
if (cache.expire < Date.now()) {
delete this.data[key];
} else {
this.checkLater();
}
}
}
checkLater(key) {
const cache = this.data[key];
if (cache) {
cache.timer = setTimeout(this.check, cache.expire - Date.now(), key);
}
}
}
改造后效果如下:
可以看到,CPU 几乎没有什么波动,总耗时只有1ms,差距非常明显。
延伸
除了缓存管理的场景,我们在处理频繁触发的事件(如 scroll
、mousemove
)时,也会经常用到 timer 来减少处理的频率。这时 debounce 的实现就会存在和上面类似的潜在问题。如果在大型 SPA 中大量使用多 timer 并发的方式,就有可能导致页面性能大量损耗在 timer 上,而出现卡顿或者其他问题。
我们从监控上发现过少量奇怪的错误:
Uncaught RangeError: Failed to execute 'setTimeout' on 'Window': Too many PausableObjects
Google 了一下,并没有找到有效的信息,只是发现 PausableObject
是 Chromium 中实现的一个类,大概是用于 JavaScript 线程挂起的时候,暂停当前执行的任务,包括所有的 timer。更多信息见此。
最后根据目前了解的情况和网上获得的极少信息,猜想这个问题出现的原因是,当前注册的 timer 已经多到影响每一帧的渲染或是达到了浏览器内部的某些限制了,所以无法再创建更多的 timer。所以就联想到可能跟 debounce
的实现方式上的缺陷有关。
总结
- timer 的性能并不好,不适合大量使用;
- 我们可以结合 JavaScript 是单线程的特点,从设计上避免大量使用 timer 的问题。