1浏览器页面是由消息队列和事件循环系统来实现的。
接下来将会通过setTimeout和XMLHttpRequest这两个WebAPI来介绍事件循环。
浏览器如何实现setTimeout?
渲染进程中所有运行在主线程上的任务都需要先添加在消息队列中,然后事件循环系统按照顺序执行消息队列中的任务。
典型事件的执行流程:
- 当接收到HTML文档数据,渲染引擎就会将"解析DOM"事件添加到消息队列中。
- 当用户改变了Web页面的窗口大小,渲染引擎就会将"重新布局"的事件添加到消息队列中。
- 当出发了Javascript引擎垃圾回收机制,渲染引擎就会将"垃圾回收"任务添加到消息队列中。
- 执行一段异步代码,也需要将执行任务添加到消息队列中。
当这些事件添加到消息队列中,事件循环系统就会按照消息队列中的顺序来执行事件。
通过定时器设置的回调函数稍微有点特别,他是需要在指定时间间隔内被调用,但是消息队列中的任务是按照顺序来执行的,所以为了保证回调函数可以在指定的时间间隔内执行,不能直接将定时器的回调函数添加到消息队列中。
在Chrome
浏览器中除了正常使用的消息队列外,还有另外一个消息队列,这个队列维护了需要延迟处理的任务列表,包括了定时器和chromium
内部一些需要延迟执行的任务。所以创建一个定时器的时候,渲染进程就会将该定时器的回调函数添加到延时队列中。
JS
class DelayedQueue {
constructor() {
this.queue = [];
}
// 添加任务到延迟队列
addDelayedTask(task, delay) {
const executionTime = Date.now() + delay;
this.queue.push({ task, executionTime });
this.sortQueue();
}
// 执行队列中已到期的任务
executeExpiredTasks() {
const currentTime = Date.now();
while (this.queue.length > 0 && this.queue[0].executionTime <= currentTime) {
const { task } = this.queue.shift();
task();
}
}
// 排序队列,确保队列按照执行时间递增排序
sortQueue() {
this.queue.sort((a, b) => a.executionTime - b.executionTime);
}
}
// 创建延迟队列实例
const delayedQueue = new DelayedQueue();
// 模拟IO线程产生的任务
function produceTask() {
setInterval(() => {
const task = () => {
console.log("执行IO线程产生的任务");
};
// 将任务加入延迟队列,并设置延迟时间为2000毫秒
delayedQueue.addDelayedTask(task, 2000);
}, 2000);
}
// 模拟渲染主进程的事件循环
function eventLoop() {
setInterval(() => {
// 执行延迟队列中已到期的任务
delayedQueue.executeExpiredTasks();
}, 1000);
}
// 启动IO线程模拟任务的产生
produceTask();
// 启动渲染主进程的事件循环
eventLoop();
这个例子中,我添加了一个名为 DelayedQueue
的类,用于维护一个延迟队列。每当需要添加一个延迟执行的任务时,调用 addDelayedTask
方法,并传入任务函数和延迟时间。executeExpiredTasks
方法负责检查队列中是否有已到期的任务,并执行它们。 sortQueue
方法用于确保队列按照执行时间递增排序。在 eventLoop
中,每秒钟检查一次延迟队列,执行已到期的任务。这样,可以确保 setTimeout
的精确性。
使用setTimeout的一些注意项
1.如果当前任务执行事件过久,会影响到定时器任务的执行
当当前任务执行时间过久时,可能会影响 setTimeout
任务的准时执行。这是因为 setTimeout
的执行并不是严格准时的,而是在指定的延迟时间后将任务添加到消息队列,然后等待主线程空闲时执行。如果当前任务执行时间较长,它可能会导致 setTimeout
的实际执行时间被推迟。
下面是一个简单的例子,演示了当前任务执行时间过久时对 setTimeout
任务的影响:
js
// 模拟一个耗时较长的任务
function longRunningTask() {
console.log('开始执行长时间运行的任务');
// 模拟耗时操作,这里设置为 5 秒
const endTime = Date.now() + 5000;
while (Date.now() < endTime) {
// 什么都不做,模拟耗时操作
}
console.log('长时间运行的任务执行完成');
}
// 启动 setTimeout 任务
function startSetTimeoutTask() {
setTimeout(() => {
console.log('setTimeout 任务执行');
}, 1000); // 设置延迟时间为 1 秒
}
// 启动长时间运行的任务
longRunningTask();
// 启动 setTimeout 任务
startSetTimeoutTask();
在这个例子中,longRunningTask
模拟了一个执行时间较长的任务,它会占用主线程达到 5 秒。而 startSetTimeoutTask
启动了一个延迟 1 秒的 setTimeout
任务。 由于 longRunningTask
的执行时间较长,它会推迟 setTimeout
任务的实际执行时间。因此,虽然 setTimeout
的延迟时间设置为 1 秒,但实际上会在 longRunningTask
执行完成后的更长时间后才执行。 这种情况下,如果要确保 setTimeout
任务准时执行,可以考虑在长时间运行的任务中使用 requestAnimationFrame
或者 setImmediate
(在一些环境中可用)来分割任务,以确保在每个任务块之间让出主线程,使得消息队列中的 setTimeout
任务有机会得到执行。
2.如果setTimeout存在嵌套调用,那么系统会设置最短时间间隔为4毫秒
在浏览器环境中,setTimeout
的最短时间间隔是由浏览器实现决定的,并不是 JavaScript
标准规定的。通常来说,浏览器为了性能和效率的考虑,会将连续调用的 setTimeout
任务进行合并执行,并设置一个最小时间间隔来防止过于频繁的执行。
嵌套调用 setTimeout
的场景中,即使你设置了较短的时间间隔,浏览器也可能会进行合并执行。以下是一个简单的例子来演示:
js
function nestedTimeoutExample() {
// 第一层 setTimeout
setTimeout(() => {
console.log('第一层 setTimeout 执行');
// 第二层 setTimeout,时间间隔为1毫秒
setTimeout(() => {
console.log('第二层 setTimeout 执行');
}, 1);
}, 1);
}
nestedTimeoutExample();
在这个例子中,我们有两个嵌套的 setTimeout
,第一层和第二层都设置了 1 毫秒的时间间隔。但是,由于浏览器的实现机制,可能会将它们合并执行,并不保证准确的 1 毫秒的时间间隔。浏览器可能会设置一个最小的时间间隔(通常是4毫秒),以防止过于频繁的任务执行。如果需要更精确的定时,可以考虑使用 equestAnimationFrame
,但这同样也受到浏览器的实现影响。
3.未激活的页面,setTimeout执行的最小间隔是1000毫秒
在未激活的页面中,浏览器通常会对定时器的执行间隔进行调整,以降低对系统资源的占用。这种行为是为了提高性能和降低功耗,因为未激活的页面对用户而言可能是不可见的,对其执行的任务可能会进行一些优化。
在大多数浏览器中,未激活的页面中的 setTimeout
最小间隔通常被调整为1000毫秒(1秒)。这意味着如果你在未激活的页面中使用setTimeout
,设置的时间间隔小于1000毫秒时,浏览器可能会延长执行时间间隔。
4.延迟执行时间有最大值
JavaScript
中的延迟执行时间没有一个严格的最大值,但在实际应用中可能会受到一些限制。这些限制通常来自浏览器或宿主环境,并且可能因浏览器、设备或运行环境的不同而异。
定时器函数(例如 setTimeou
t)的延迟参数是一个32位整数,因此在理论上,最大的延迟值是2^31 - 1,即2147483647毫秒,约为24.86天。超过这个值的延迟时间可能会导致溢出,因此在实际应用中,延迟时间不应该超过这个范围。
js
setTimeout(() => {
// 2147483647 毫秒约为24.86天
console.log('执行延迟任务');
}, 2147483647);
5.使用setTimeout设置的回调函数中的this不符合直觉
在 JavaScript
中,setTimeout
设置的回调函数中的 this
的值是在运行时确定的,并且通常取决于函数的调用方式。这种情况可能导致 this
的值与直觉不符,特别是在回调函数被对象方法调用时。
在使用 setTimeout
时,回调函数实际上成为了一个全局函数,并不再与原始的调用上下文(调用该函数的对象)相关联。因此,this
的值将不再指向调用对象。
js
// 示例:使用 setTimeout 导致 this 不指向调用对象
const myObject = {
value: 42,
printValue: function () {
console.log(this.value);
}
};
// 在 myObject 上调用 printValue 方法
myObject.printValue(); // 输出: 42
// 使用 setTimeout 调用 printValue 方法
setTimeout(myObject.printValue, 1000); // 输出: undefined(this 不再指向 myObject)
解决 setTimeout 中 this 不指向调用对象的问题,有几种常见的方法:
- 使用箭头函数: 箭头函数不会创建自己的
this
上下文,而是从外部继承。因此,使用箭头函数作为setTimeout
的回调函数可以确保this
指向正确的对象。
js
const myObject = {
value: 42,
printValue: function () {
console.log(this.value);
}
};
setTimeout(() => myObject.printValue(), 1000); // 输出: 42
- 使用 .bind() 方法: 可以使用
.bind()
方法将回调函数与指定的对象绑定,确保this
指向该对象。
js
const myObject = {
value: 42,
printValue: function () {
console.log(this.value);
}
};
setTimeout(myObject.printValue.bind(myObject), 1000); // 输出: 42
- 使用函数包装器: 使用一个函数包装器,将原始函数作为参数传递给
setTimeout
。
js
const myObject = {
value: 42,
printValue: function () {
console.log(this.value);
}
};
setTimeout(function () {
myObject.printValue();
}, 1000); // 输出: 42