事件循环老生常谈了,社区的相关文章也非常多了,但这次为了彻底搞懂,我深度查看了规范中的规则以及 Chromium 中的源码实现。本文章作为笔记记录。
本文主要参考了
WHATWG
规范中的定义。建议直接从规范中学习。 各家浏览器对事件循环的具体实现会有细微差别,本文所有代码输出均使用 chrome 。
面试碰到很多问题,比如下面这些,面试官实际上很可能想问的是 事件循环,搞明白了之后就不用害怕被面试官牵着走了,甚至可以主动引导面试官问事件循环。
定义
进程是一个正在执行的程序的实例。它包含程序代码、数据、资源(如文件、内存)以及执行中的程序计数器、寄存器和堆栈。
特点
示例
操作系统中的每个运行的应用程序,如文本编辑器、浏览器或计算器,都是一个进程。
定义
线程是进程中的一个执行路径,也被称为轻量级进程( Lightweight Process, LWP )。一个进程可以包含多个线程,它们共享进程的内存和资源。
特点
示例
在一个文本编辑器中,可能会有一个线程负责响应用户输入,另一个线程负责自动保存文档,还有一个线程负责拼写检查。
进程与线程的对比
特性 | 进程 | 线程 |
---|---|---|
内存空间 | 独立 | 共享进程内存 |
创建开销 | 大 | 小 |
通信方式 | 通过进程间通信( IPC ) | 通过共享内存 |
崩溃影响 | 独立进程崩溃不影响其他进程 | 线程崩溃可能导致整个进程崩溃 |
执行效率 | 较低(独立内存) | 较高(共享内存) |
进程与线程的使用场景
总结
总结来说,进程提供隔离性和稳定性,而线程提供高效的并行执行能力。
浏览器是一个多进程多线程的应用程序,而 js 则是单线程的。
主进程
负责浏览器的用户界面(比如浏览器上面的地址栏、前进后退、书签管理等等)、管理各个子进程(管理和创建渲染进程)、处理用户输入以及与操作系统的交互(如文件访问)
渲染进程
每个标签页、iframe 都是一个独立的渲染进程。渲染进程需要处理非常多的任务,譬如:
网络进程
专门负责网络请求和资源下载,独立于主进程和渲染进程
GPU 进程
负责处理图形相关任务,如 3D 绘图和硬件加速,以提高渲染性能
插件进程
浏览器插件运行在独立的插件进程中,负责处理插件相关的任务。
…
在浏览器的每个渲染进程中,通常包括以下线程:
众所周知,JavaScript 在浏览器的主线程中是单线程执行的。而异步编程允许代码在不阻塞主线程的情况下执行耗时操作。
在浏览器环境中,每个标签页都有其独立的渲染进程,其中包含一个主线程负责处理多项任务,如解析 HTML 、构建 DOM 树、解析 CSS 、计算样式和布局、渲染页面以及执行 JavaScript 代码等。
如果所有这些任务都同步执行,可能会导致以下问题:
为解决这些问题,浏览器引入了异步编程模型处理各种类型的任务,确保了主线程不会被阻塞,提高了程序的整体效率和响应性。事件循环就是异步的实现方式。
同步代码、微任务、宏任务的执行顺序是什么?
在初始阶段,script 标签中的代码被包装称为一个宏任务放到任务队列中,此时是没有其他微任务的。在全局代码中按照出现的顺序立即执行的都是同步代码。
异步代码则分为微任务和宏任务。
在 JavaScript 中,微任务( Microtasks )是为了在当前事件循环结束之前执行的小任务。
微任务在 JavaScript 执行堆栈为空时运行;微任务的执行优先级高于宏任务( Macrotasks )。另外,W3C 规范中规定每个渲染进程(标签页或 Web Worker )中必须且只能有一个微任务队列!
不理解也没关系,只需要记住下面的微任务即可
Promise 回调函数
通过 .then()
、.catch()
、.finally()
注册的回调函数是微任务。但Promise 本身不是微任务。
例如:下面代码中console.log('Promise');
属于全局同步代码,并不会进入微任务队列!
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
console.log('Promise');
resolve();
}).then(() => {
console.log('then');
});
// 监听 DOM 变化的回调
const observer = new MutationObserver(() => {
// 该回调函数会进入为任务队列
console.log('MutationObserver');
});
observer.observe(document.querySelector('.box'), {
attributes: true
});
document.querySelector('.box').setAttribute('data', Math.random());
Promise.resolve()
.then(() => {
console.log('Promise 1');
})
.then(() => {
console.log('Promise 2');
});
console.log('Script end');
// 执行结果:
// Script start
// Promise
// Script end
// then
// MutationObserver
// Promise 1
// Promise 2
// setTimeout
MutationObserver 的回调函数
当监听 DOM 变化,其触发的回调函数是微任务。参考上面的代码 console.log('MutationObserver');
是先进入微任务队列后按队列顺序执行的。
queueMicrotask 的回调函数
显式将任务添加到微任务队列。可以用来改变某些代码的执行顺序,但如果增加过多queueMicrotask
,可能导致其他任务无法执行或延迟, 比如页面渲染任务。
console.log('Script start');
setTimeout(() => {
console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
console.log('Promise');
resolve();
}).then(() => {
console.log('then');
});
// 如果执行下面的无限递归 addMicrotask
// 那么页面上.box textContent 永远也不会变化,渲染 dom 是一个宏任务
// 微任务没有执行完,控制权不会交换给事件循环,因此无法执行宏任务
// function addMicrotask() {
// queueMicrotask(() => {
// console.log('Microtask executed');
// addMicrotask(); // 继续添加微任务
// });
// }
// addMicrotask()
queueMicrotask(() => {
console.log('Microtask 1');
});
const observer = new MutationObserver(() => {
console.log('MutationObserver');
});
observer.observe(document.querySelector('.box'), {
attributes: true,
});
document.querySelector('.box').textContent = +Date.now();
console.log('Script end');
// 执行结果
// Script start
// Promise
// Script end
// then
// Microtask 1
// setTimeout
await
使用 await
时,函数会暂停执行,直到 await
的 Promise 解决( fulfilled 或 rejected ),这段暂停时间允许其他任务执行,不会阻塞主线程。
当 Promise 解决时,await
后面的代码会作为微任务加入微任务队列。
在最新的 W3C(WHATWG) 规范中,其实并没有宏任务的定义,而是直接称之为“任务”,为了容易区分,我们暂时还称为宏任务,但请记住,规范的说法是“任务”。
宏任务( Macrotasks )同样用于处理异步操作,他和微任务的最大区别在于执行时机,宏任务在微任务之后执行。js 中主要有以下宏任务:
事实上,还有一些特殊的任务,他们既不是微任务也不是宏任务,有自己独立的运行机制,不适用于常规的事件循环机制,比如:
任务队列在规范中有明确的说明:
微任务队列是当前 JavaScript 主线程中所有微任务的集合。规范要求每个事件循环都有一个微任务队列,最初是空的。
在所有的同步任务执行完成后,控制权移交事件循环,并获取微任务队列中第一个可运行的任务创建执行上下文后在执行堆栈中运行。
和上面所说的宏任务一样,规范中的准确定义是
任务队列
。
任务队列是当前 JavaScript 主线程中所有微任务的集合。 但和为任务队列不同的是每个事件循环中可以有多个任务队列。
当页面加载或脚本执行时,最初的同步代码被视为一个宏任务。而此时执行栈是空的,微任务队列也是空的,所以这个“宏任务”会优先执行。
其他的情况则是按照 执行同步代码 → 执行微任务 → 执行宏任务的顺序。
使用定时器无法实现精准的定时效果,给他们传入延时参数只是最快执行的时间,而实际上即使计时到了,也必须等待所有的同步代码和微任务执行完成。 另外,定时器嵌套达到 5 层后会延时参数最小值会强制从 0 变为 4 ,这也增大了误差。
规范中定义了多个任务的类型,他们有自己关联的任务队列,比如,可能有以下队列:
setTimeout
和 setInterval
多个任务队列如何保证执行顺序?通常来说是按照进入队列的时间,不过规范中并没有严格定义,允许各家浏览器自行实现内部细节,但用户交互相关任务(如鼠标点击、键盘输入等)的优先级会较高一些,以确保用户操作的响应速度。
规范中的原文如下: For example, a user agent could have one task queue for mouse and key events (to which the user interaction task source is associated), and another to which all other task sources are associated. Then, using the freedom granted in the initial step of the event loop processing model, it could give keyboard and mouse events preference over other tasks three-quarters of the time, keeping the interface responsive but not starving other task queues. Note that in this setup, the processing model still enforces that the user agent would never process events from any one task source out of order.
在浏览器环境中,虽然 JavaScript 是单线程执行的,但每个渲染进程(如每个 Tab 或 Worker )由多个线程组成。这些线程包括:
setTimeout
、setInterval
)。当 JavaScript 代码在主线程的执行栈中运行时,遇到异步 API (如定时器、网络请求、事件监听器等)会触发任务分发:
接下来等待事件循环处理任务队列即可。
在 w3c 标准中称为
Event loops
,而在谷歌的 chromium 中称为Message Loop
,源码中具体的实现方法是MessagePumpDefault::Run
,参考以下代码:
void MessagePumpDefault::Run(Delegate* delegate) {
// 通过 AutoReset 类自动管理 keep_running_的值。构造函数将 keep_running_设置为 true ,
// 析构函数将其恢复为原来的值。
AutoReset<bool> auto_reset_keep_running(&keep_running_, true);
// 无限循环,直到 keep_running_被设置为 false 或遇到 break 语句。
for (;;) {
#if BUILDFLAG(IS_APPLE)
// 在 Apple 平台上,创建一个自动释放池( autorelease pool ),
// 用于管理 Objective-C 对象的内存。
apple::ScopedNSAutoreleasePool autorelease_pool;
#endif
// 调用 delegate 的 DoWork 方法获取下一步工作的信息。
Delegate::NextWorkInfo next_work_info = delegate->DoWork();
// 检查是否有更多的紧急工作需要立即处理。
bool has_more_immediate_work = next_work_info.is_immediate();
// 如果 keep_running_被设置为 false ,退出循环。
if (!keep_running_)
break;
// 如果有更多的紧急工作,继续循环处理,而不进行等待。
if (has_more_immediate_work)
continue;
// 调用 delegate 的 DoIdleWork 方法,处理空闲时的工作。
delegate->DoIdleWork();
// 再次检查 keep_running_,如果为 false ,则退出循环。
if (!keep_running_)
break;
// 根据 next_work_info 中的信息决定是否进行等待。
if (next_work_info.delayed_run_time.is_max()) {
// 如果 next_work_info.delayed_run_time 为最大值,
// 则进行无限等待,直到 event_被触发。
event_.Wait();
} else {
// 否则,等待指定的时间( remaining_delay )后再继续。
event_.TimedWait(next_work_info.remaining_delay());
}
// Since event_ is auto-reset, we don't need to do anything special here
// other than service each delegate method.
// event_是自动重置的,因此我们不需要在这里做特殊处理。
// 只需要继续服务每个 delegate 的方法。
}
}
了解上面的内容后,事件循环就非常容易理解了。整体流程如下:
Javascript 是事件驱动的。
下面这道地狱级别输出题,能答对 70%我相信就能应对大部分面试官了🤓(可以去掉requestAnimationFrame 和 requestIdleCallback ,这两个干扰比较大)
这个案例中从输出结果来看 channel.port2.postMessage 的优先级似乎比 settimeout 要低一些,并没有严格按照代码出现顺序执行
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Macro and Microtasks</title>
<style></style>
</head>
<body>
<textarea name="" id=""></textarea>
<div class="box"></div>
<script>
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
setTimeout(() => {
console.log("3");
}, 1000);
const channel = new MessageChannel();
channel.port1.onmessage = (val) => {
console.log(val);
};
channel.port2.postMessage("4");
setInterval(() => {
console.log("55");
}, 1000);
let a = 1;
while (a < 100000) {
a++;
if (a == 1000 || a == 100000) console.log(a);
}
new Promise(function (resolve, reject) {
console.log("5");
resolve(3);
}).then(function (val) {
console.log("6");
});
console.log(7);
Promise.resolve()
.then(() => {
console.log("8");
})
.then(() => {
console.log("9");
});
const observer = new MutationObserver(() => {
console.log("MutationObserver 10");
});
observer.observe(document.querySelector(".box"), {
attributes: true,
});
document.querySelector(".box").setAttribute("data", Math.random());
setTimeout(() => {
console.log("11");
Promise.resolve().then(() => {
console.log("12");
});
requestAnimationFrame(() => {
console.log("RAF 13");
});
}, 0);
console.log("14");
requestIdleCallback(() => {
console.log("requestIdleCallback 15");
});
queueMicrotask(() => {
console.log("16");
new Promise((resolve, reject) => {
console.log("17");
resolve();
}).then(() => {
console.log("18");
});
Promise.resolve()
.then(() => {
console.log("19");
})
.then(() => {
console.log("20");
});
setTimeout(() => {
console.log("21");
}, 0);
requestAnimationFrame(() => {
console.log("RAF 22");
});
});
Promise.resolve()
.then(() => {
console.log("23");
})
channel.port2.postMessage("24");
requestAnimationFrame(() => {
console.log("RAF 25");
});
console.log("26");
</script>
</body>
</html>
虽然 W3C 曾是 Web 标准的主要制定者,但在 HTML 方面,WHATWG 的“Living Standard”模式更适合快速变化的 Web 环境。W3C 的标准更新较慢,可能无法及时反映最新的技术和浏览器实现。最重要的是,各大浏览器厂商通常使用 WHATWG 的规范来实现 HTML 和 DOM 标准。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.