前言
ECMAScript标准是深入学习JavaScript原理最好的资料,没有其二。
通过增加对ECMAScript语言的理解,理解javascript现象后面的逻辑,提升个人编码能力。
欢迎关注和订阅专栏 重学前端-ECMAScript协议上篇
想说清楚setTimeout,不得不提一下浏览器的架构了,浏览器是多进程多线程的结构。
多进程多线程的浏览器
进程和线程
- 进程: 资源(CPU、内存等)分配的最小单位。
- 线程: CPU调度的最小单位。
- 一个进程可以包含多个线程。
浏览器的多进程
- 怎么查看浏览器有哪些进程呢, 浏览器 更多工具 => 任务管理器


上图可以看到很多进程,简单罗列几个说:
- 浏览器主进程
负责控制浏览器除标签页(渲染进程)外的界面,地址栏,状态栏,前进后退,刷新等等。 - 浏览器渲染进程
负责界面渲染,脚本执行,事件处理等等。默认情况下,每个Tab会创建一个渲染进程。 - GPU进程:
负责浏览器界面的渲染。比如3D绘制。 - 网络进程:
负责网络资源(图片,JS等)加载。
渲染进程的多个线程
- JS引擎线程
负责解析和执行JS。JS引擎线程和GUI渲染线程是互斥的,同时只能一个在执行。我们常说的JS是单线程,在浏览器中对应的就是这个线程。 - GUI 渲染线程
解析html和CSS, 构建DOM树,CSSOM树,(Render)渲染树、和绘制页面等。 - 事件触发线程
负责处理各种事件。比如用户输入,计时器(setTimeout/setInterval),异步网络请求等等,会把任务添加到事件触发线程,当任务符合触发条件触发时,就把任务添加到待处理队列的队尾,等JS引擎线程去处理。
这里是等待,只表示进入了任务队列。 - 异步http请求线程
ajax的异步请求,fetch请求等。 ajax同步请求呢,没有产生异步任务。 - 定时触发器线程
setTimeout和setInteval计时的线程。定时的计时并不是由JS引擎线程负责的, JS引擎线程如果阻塞会影响计时的准确性。
在浏览中,我们说的异步操作一般是浏览器的两个或者两个以上线程共同完成的。
比如
- ajax异步请求: 异步http请求线程 + JS引擎线程。
- setTimeout: 定时触发器线程 + JS引擎线程。
事件循环机制
浏览器里面的事件循环,大致有三种:
- Window 事件循环(渲染进程的)
多个标签页可能共享一个渲染进程,所以可能共享一个事件循环。
例如:- 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
- 如果窗口是包含在 iframe 中,则它可能会和包含它的窗口共享一个事件循环。
- 在多进程浏览器中多个窗口碰巧共享了同一个进程。
- Worker 事件循环
web worker , shared worker 和 service worker 都有自己的事件循环。 - Worklet 事件循环
这包含了 Worklet(en-US)、AudioWorklet(en-US) 以及 PaintWorklet(en-US)。
重点当然是渲染进程的事件循环,额,这又要提到两位兄弟宏任务和微任务。
宏任务,微任务
JS线程里面有两个任务队列,一个宏任务队列,一个微任务队列。
浏览器里面的微任务只有Promise和MutationObserver,当然 queueMicrotask 也是可以添加微任务的。
javascript
self.queueMicrotask(() => {
// code
})
Promise想必无人不知,后者主要用于监听DOM节点的变化,其细节就不深入介绍了,挂一个MDN的链接 MutationObserver。
事件循环流程
用一句总结: 一次执行一个宏任务,清空微任务队列。进入下次循环。
如果微任务继续产生微任务,这里就可以出现无止境的情况,至于后果,大家懂的都懂。
极简化之后的图如下(实际上不仅仅只有这两个队列):

setTimeout定时器为什么不准
作用: 设置一个定时器,该定时器在定时器到期后执行一个函数或指定的一段代码。
这里我们就要结合事件循环,重新认识一下setTimeout, 其语法如下:
javascript
var timeoutID = setTimeout(function[, delay, arg1, arg2, ...]);
var timeoutID = setTimeout(function[, delay]);
var timeoutID = setTimeout(code[, delay]);
看起来有三种格式,本质都没变化
第一个参数: 可以是函数,也可是是字符串,如果是字符串,会采用类似eval的方式进行调用, eval嘛,是恶魔,还是不建议大家这么玩。
第二个参数 delay: 延迟的毫秒数 。
第三个以及以后的参数: 这个得说一下,当函数被执行的时候,这些参数会被传给函数。可以用于解决一个经典的闭包问题:
javascript
for(var i=0; i< 5, i++){
setTimeout((val)=> console.log(val), i* 1000, i)
}
// 0
// 1
// 2
// 3
// 4
到这里,一切看起来都还没有问题,那我们就开始整问题。
最小间隔问题
1ms ???
之前有传言是1ms, 咱不说话,看一段代码吧:
javascript
var startTime = performance.now()
//设置最大值
setTimeout(() => {
console.log("a", performance.now() - startTime);
}, 2 ** 31);
//设置最小值
setTimeout(() => {
console.log("b", performance.now() - startTime);
}, 1);
// 小数0.5
setTimeout(() => {
console.log("c", performance.now() - startTime);
}, 0.5);
//设置0
setTimeout(() => {
console.log("d", performance.now() - startTime);
}, 0);
Windows 10 firefox 103.0.2 (64 位) :

Windows 10 chrome 版本 104.0.5112.81(正式版本) (64 位):

Windows 10 chrome 版本 91.0.4472.114(正式版本) (32 位)

Windows 10 edge 版本 104.0.1293.63 (正式版本) (64 位):

Windows 10 360浏览器 86.0.4240.198 极速模式

从测试结果,可以看到最新的chrome和edge的支持粒度已经小于1ms了,至于fireFox嘛, 因为其performance.now() 方法返回的数值并没有到小数位,所以最后计算得到的数值都是整数。
真的想更快在下一次时间循环的执行,到底是设置0,还是0.5 , 还是1呢? 依旧测试一段代码:
javascript
var startTime = performance.now()
//设置1ms
setTimeout(() => {
console.log("a", performance.now() - startTime);
}, 1);
//设置0.5
setTimeout(() => {
console.log("b", performance.now() - startTime);
}, 0.5);
// 设置0
setTimeout(() => {
console.log("c", performance.now() - startTime);
}, 0);
Windows 10 chrome 版本 104.0.5112.81(正式版本) (64 位):

Windows 10 firefox 103.0.2 (64 位) :
Windows 10 edge 版本 104.0.1293.63 (正式版本) (64 位):

Windows 10 360浏览器 86.0.4240.198 极速模式

至于答案嘛,看图都能明白个大概,设置0.5大概率会比设置0或者1更快的执行。而且设置1,可能会有意外的效果。
4ms
刚才的代码段都是正常执行一次,setTimeout的嵌套执行,效果又是不一样的了。
最新的chrome, firefox和edge嵌套层级五层以上,并且setTimeout的最小延迟小于4ms,设置timeout为4ms.
javascript
var startTime = performance.now();
// print cost
function printTime(count) {
const now = performance.now();
console.log(count,"cost:", now - startTime);
startTime = now;
}
setTimeout(() => {
printTime(1);
setTimeout(() => {
printTime(2);
setTimeout(() => {
printTime(3);
setTimeout(() => {
printTime(4);
setTimeout(() => {
printTime(5);
setTimeout(() => {
printTime(6);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);
}, 0);

有人会问,为什么是5次和4ms, 这是 HTML Standard 8.6 Timers 规定的,至于真正的实现是浏览器厂商决定的。

小结
- 非嵌套执行, setTimout的最小执行间隔可以小于1ms
- 如果想尽快执行, setTimeout设置一个小数间隔(比如0.5)可能会更快
- 嵌套执行的话,五次(不同浏览器可能有不同)以后,最小时间间隔不小于4ms
延时毫秒数的含义
完全弄清楚这个问题,先得了解setTimeout工作的整套流程,看下面简单的代码,执行的步骤
javascript
var startTime = performance.now();
setTimeout(function log(){
console.log("cost:", performance.now() - startTime);
},300)
- setTimeout 执行的时候,定时触发器线程 开始计时
- 300ms之后,定时触发器线程 结束计时,事件触发线程 把任务(log函数)添加到待处理队列的队尾,等待 JS引擎线程 去处理。
这里是等待,因为是队列,可能前面还有宏任务,也可能当前有宏任务还没有执行完毕,也有可能微任务队列的任务还没有执行完毕等等。
300ms的含义:预期300ms进入宏任务队列。 - 宏任务队列轮到log函数执行
看起来不复杂,还是说两点:
- setTimeout这里有多个线程参与:
定时触发器线程: 负责计时
事件触发线程: 负责把任务添加到任务队列
JS引擎线程: 负责执行任务
至于为什么,当然是稳定性。 - setTimeout 设置的延时毫秒数, 准确的含义是多少毫秒后进入任务队列
其能不能被执行,还受限很多因素,比如之前提到的最小时间间隔,JS引擎线程是否有宏任务在执行,微任务队列是否有任务等待执行等等。
如果当前循环周期,某个宏任务或者微任务执行了很久,或者任务队列还有大把任务等待执行,自然达不到你的预期。
接近0的延时计时器
如果想比setTimeout(fn, 0)更快,我们应该怎么做。
除了上面已经提到针对部分浏览器的方案,设置一个0.5ms的延时。
微任务方案
其中一种方案,在本次事件循环周期内执行。答案就是微任务,前面提到的Promise, MutationObserver, queueMicrotask 都可以。在兼容允许的情况下queueMicrotask当然是首选。
javascript
function setTimeoutZero(fn){
queueMicrotask(fn)
}
var startTime = performance.now();
setTimeoutZero(function(){
console.log('setTimeoutZero:', performance.now() - startTime);
})
效果非常显著,总之是非常的快。


MessageChannel, postMessage等
javascript
function setTimeoutZero(fn){
const channel = new MessageChannel("____zero____");
function sendMessage() {
channel.port2.postMessage(null);
}
channel.port1.onmessage = function() {
fn.apply(null, fn)
};
sendMessage();
}
var startTime = performance.now();
setTimeout(function(){
console.log('setTimeout 0:', performance.now() - startTime);
}, 0);
setTimeout(function(){
console.log('setTimeout 0.5:', performance.now() - startTime);
}, 0.5);
setTimeoutZero(function(){
console.log('setTimeoutZero:', performance.now() - startTime);
});
在setTimeout不支持小于1ms的浏览器环境测试,setTimeoutZero 确实属于最快的是了。
Windows 10 chrome 版本 91.0.4472.114(正式版本) (32 位)
Windows 10 360浏览器 86.0.4240.198 极速模式

同样的代码,在支持小于1ms的浏览器下测试, setTimeoutZero 就歇了,而是谁的代码在前面,谁就先执行。
Windows 10 firefox 103.0.2 (64 位)
Windows 10 chrome 版本 104.0.5112.81(正式版本) (64 位)
Windows 10 edge 版本 104.0.1293.63 (正式版本) (64 位):

小结
因为浏览器本身也在迭代,这里主要是通过测试获得一些思路。
-
要想更快的延时执行,可以采用产生微任务和宏任务两条思路。
requestAnimationFrame的效果如何呢? 这里留给大家思考。
-
采用MessageChannel或者postMessage方案时,新旧浏览器表现可能不一样
接下来要谈的就是如何让setTimeout尽可能满足预期,单次执行不是本文讨论的重点。
以一个手机验证码获取倒计时为例,一般验证码获取一次,60S才能再次获取。
采用setTimout实现,时间间隔1000ms, 至于为什么不采用setInterval, 那就是另外一个话题了。
动态修复
单次动态修复
预期执行间隔1000ms。
记录上一次的执行时间,动态进行修复。
- 如果是小的误差:
实际消耗 1100毫秒,那么下次设置的间隔就修复为900ms。当然具体的方案还是根据业务方的需求来确定。
- 如果是大的误差,
实际消费2000ms, 直接数值减少2(比如50s 变为48s),正常来说,很少出现这种情况。真出现,那页面也不知道卡成什么样呢。
全局动态修复
只记录开始的时间T0,某次代码执行的时间是T1, 剩余显示时间是基于 60 - (T1-T0)计算的秒数。
似乎看起来没有问题,但是页面处理非活动状态的时候,计时器会变得不准。 原因: 节约资源量。
依然存在问题
等你切换回来,如果没有修复,那时间展示肯定不准,如果你采用了修复,那么时间肯定会突然从某个数字跳动到一个相差比较多的数字。
未被激活的 tabs 的定时最小延迟
firefox
未被激活的 tabs 的定时最小延迟>=1000ms。
firefox还可以通过如下的三步骤进行修改, 比如修改 为 200
- 输入 about:blank
- 搜索 min_background
- 修改 dom.min_background_timeout_value_without_budget_throttling 为 200

比如,如上图修改之后,就算Tab标签未被激活,如下的输出依旧会每200ms进行输出。
javascript
function plan() {
setTimeout(function () {
counts--;
setCount(counts);
if (counts > 1) {
plan();
}
}, 200);
}
plan();
chrome
可以通过设置启动项的选项 --disable-background-timer-throttling , 让至少1000ms的规则失效。

electron
其有专门的选项,可以让计时器不进入 "休眠"。
其为: backgroundThrottling
javascript
const win = new BrowserWindow({
width: 300,
height: 400,
frame: false, /
resizable: false,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
backgroundThrottling: false, // 禁止未激活时节流,保证计时器正常供
},
});
未激活Tabs计时器解决方案
监听页面可见性
当浏览器最小化窗口或切换到另一个选项卡时就会触发visibilityChange事件,我们可以在事件中用document.hidden(true/false)或者document.visibilityState("visible"/"hidden")判断当前窗口的状态,来决定除定时器后者重新更新定时器。
javascript
document.addEventListener("visibilitychange", function() {
if (document.hidden === true) {// document.visibilityState == "visible"
// 页面被挂起
} else {
// 页面由挂起被激活
}
});
web Worker
允许您在与应用程序的主执行线程不同的线程上运行脚本操作。
即使在应用程序的选项卡变为非活动状态时,计时器也会继续倒计时。
方案就是计时器在Web Worker启动,然后通知Tab页面进行相关的操作。
通过一个例子加深理解,同时启用两个倒计时,一个采用web worker,另外一个采用传统的方式。

web worker的代码(演示代码,请勿太在乎细节处理):
javascript
const timeoutTickets = {}
onmessage = function (e) {
const data = e.data;
if (!data || !data.type) {
return
}
const { type, key, delay } = data;
let ticket;
switch (type) {
case 'setTimeout':
// 计时到期,执行回调(发送消息)
ticket = setTimeout(function () {
postMessage({
type,
key
})
}, delay);
timeoutTickets[key] = ticket
break;
case 'clearTimeout':
clearTimeout(timeoutTickets[data.key])
break;
default:
break
}
}
主线程与web worker通讯核心代码(演示代码,请勿太在乎细节处理):
javascript
// 记录回调
const setTimeoutCallbacks = {};
// web worker 监听消息
const worker = new Worker("./wk_timeout.js");
worker.onmessage = function (ev) {
const { data } = ev;
if (!data || !data.type) {
return;
}
const { type, key } = data;
let ticket;
// 判断类型
switch (type) {
case "setTimeout":
// 执行回调
setTimeoutCallbacks[key].call(null);
// 删除回调信息
delete setTimeoutCallbacks[key];
default:
break;
}
};
// 启用计时
function setWKTimeout(fn, delay) {
setTimeoutCallbacks[seconds] = fn;
worker.postMessage({
type: "setTimeout",
key: seconds,
delay,
});
}
主进程完整代码(演示代码,请勿太在乎细节处理):
javascript
<body>
<div>
剩余时间:
<div id="leftSeconds"></div>
</div>
<div>
剩余时间:
<div id="leftSecondsWK"></div>
</div>
<script src="../../libs/eruda.js"></script>
<script>eruda.init();</script>
<script>
(function () {
let lastTime = performance.now();
function setSeconds(seconds) {
leftSeconds.innerHTML = `${seconds}秒`;
const now = performance.now();
console.log(
"setTimeout seconds:",
seconds,
" cost:",
now - lastTime
);
lastTime = now;
}
let seconds = 60;
setSeconds(seconds);
console.log("setTimeout startTime:", performance.now());
function plan() {
setTimeout(function () {
seconds--;
setSeconds(seconds);
if (seconds > 1) {
plan();
}
}, 1000);
}
plan();
})();
</script>
<script>
// web worker setTimout
(function () {
let lastTime = performance.now();
function setSeconds(seconds) {
leftSecondsWK.innerHTML = `${seconds}秒`;
const now = performance.now();
console.log(
"setTimeoutWorker seconds:",
seconds,
" cost:",
now - lastTime
);
lastTime = now;
}
let seconds = 60;
setSeconds(seconds);
// 记录回调
const setTimeoutCallbacks = {};
// web worker 监听消息
const worker = new Worker("./wk_timeout.js");
worker.onmessage = function (ev) {
const { data } = ev;
if (!data || !data.type) {
return;
}
const { type, key } = data;
let ticket;
// 判断类型
switch (type) {
case "setTimeout":
// 执行回调
setTimeoutCallbacks[key].call(null);
// 删除回调信息
delete setTimeoutCallbacks[key];
default:
break;
}
};
function setWKTimeout(fn, delay) {
setTimeoutCallbacks[seconds] = fn;
worker.postMessage({
type: "setTimeout",
key: seconds,
delay,
});
}
console.log("setWKTimeout:", performance.now());
function plan() {
setWKTimeout(function () {
seconds--;
setSeconds(seconds);
if (seconds > 1) {
plan();
}
}, 1000);
}
plan();
})();
</script>
HackTimer
就是基于web Worker封装的第三方库 hacktimer, 利用 web Worker来计时,而不是主线程本身。
其分为两个部分
web woker部分,计时部分。
与 web worker部分通讯,并重写了 setInterval,clearInterval,setTimeout,clearTimeout。
在无感中,你就使用了 web worker的计时器,虽然代码是五年前的代码,但是思路依旧值得学习。