各位前端的观众老爷们,晚上好!我是你们的老朋友,今天咱们聊聊 JavaScript 的心脏——事件循环(Event Loop)。这玩意儿听起来玄乎,但其实理解了之后,就能明白 JavaScript 为什么能“一心多用”,还能避免一些奇奇怪怪的 Bug。准备好了吗?咱们开讲!
一、JavaScript 的单线程故事
首先,咱们得明确一个大前提:JavaScript 是一门单线程的语言。啥意思?简单说,它就像一个只有一个脑子的程序员,同一时间只能处理一件事情。如果同时来了好几件事,它就得排队处理,一件一件来。
那问题就来了:既然是单线程,那 JavaScript 怎么还能同时处理用户点击、网络请求、定时器等等一堆事情呢?要是都排队等着,那页面早就卡成 PPT 了!
这就是事件循环大显身手的地方了。它就像一个聪明的管家,负责安排 JavaScript 引擎的日常工作,让它既能高效地处理各种任务,又能保证页面流畅。
二、事件循环的工作原理:管家婆的日常
事件循环的核心思想是:利用异步机制,将耗时的操作放到后台执行,主线程继续处理其他任务,等后台操作完成后,再通知主线程来处理结果。
这个过程可以简化成以下几个步骤:
-
执行栈(Call Stack): JavaScript 引擎首先会执行当前线程中的同步代码,这些代码会被压入执行栈中,按照先进后出的顺序执行。执行栈就像一个叠盘子的架子,后放的盘子先拿走。
console.log('开始'); function foo() { console.log('foo'); } foo(); console.log('结束');
在这个例子中,
console.log('开始')
、foo()
和console.log('结束')
会依次被压入执行栈并执行。 -
任务队列(Task Queue): 当遇到异步操作(比如
setTimeout
、Promise.then
、addEventListener
等),JavaScript 引擎会将这些操作交给相应的模块(比如浏览器提供的 Web API),这些模块会在后台执行这些操作,并将回调函数放入任务队列中。任务队列就像一个等待处理的任务列表,按照先进先出的顺序排列。console.log('开始'); setTimeout(function() { console.log('setTimeout'); }, 0); console.log('结束');
在这个例子中,
setTimeout
的回调函数会被放入任务队列中,等待执行栈为空时再执行。 -
事件循环(Event Loop): 事件循环会不断地检查执行栈是否为空。如果执行栈为空,它就会从任务队列中取出一个任务(也就是一个回调函数),将其压入执行栈中执行。这个过程不断循环,直到任务队列为空。
所以,整个过程就像一个管家婆,不停地检查执行栈,看看有没有空闲时间,如果有空闲时间,就从任务队列里拿一个任务来执行。
三、宏任务(MacroTask)和微任务(MicroTask):任务也分三六九等
任务队列中的任务并不是一视同仁的,它们被分成了两类:宏任务(MacroTask) 和 微任务(MicroTask)。
-
宏任务: 宏任务是由宿主环境(比如浏览器或 Node.js)发起的任务,包括:
setTimeout
setInterval
setImmediate
(Node.js)requestAnimationFrame
- I/O 操作 (比如文件读写)
- UI 渲染
-
微任务: 微任务是由 JavaScript 引擎自身发起的任务,包括:
Promise.then
Promise.catch
Promise.finally
MutationObserver
process.nextTick
(Node.js)
宏任务和微任务的区别在于它们的执行时机:
- 每执行完一个宏任务,事件循环都会检查微任务队列,并将所有微任务都执行完毕,然后再执行下一个宏任务。
这个规则非常重要,理解了它才能真正理解事件循环的工作方式。
四、执行顺序:先宏后微,循环往复
现在,咱们来总结一下事件循环的执行顺序:
- 执行栈执行全局脚本代码(这可以看作一个宏任务)。
- 执行栈清空。
- 检查微任务队列,依次执行队列中的所有微任务。
- 微任务队列清空。
- 取出宏任务队列中的一个宏任务,压入执行栈执行。
- 回到第 2 步,循环往复。
可以用一个表格来更清晰地表示这个过程:
步骤 | 内容 |
---|---|
1 | 执行全局脚本代码(宏任务) |
2 | 执行栈清空 |
3 | 执行所有微任务 |
4 | 微任务队列清空 |
5 | 取出一个宏任务,压入执行栈 |
6 | 回到步骤 2,循环 |
五、代码示例:眼见为实
光说不练假把式,咱们来看几个例子,加深理解。
例子 1:setTimeout
和 Promise
的较量
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2')
});
console.log('script end');
输出结果:
script start
script end
promise1
promise2
setTimeout
解释:
- 首先,执行全局脚本代码,输出
script start
和script end
。 setTimeout
的回调函数被放入宏任务队列。Promise.resolve().then()
的回调函数被放入微任务队列。- 全局脚本代码执行完毕,执行栈清空。
- 事件循环检查微任务队列,发现有微任务,依次执行
promise1
和promise2
。 - 微任务队列清空。
- 事件循环从宏任务队列中取出一个宏任务(
setTimeout
的回调函数),压入执行栈执行,输出setTimeout
。
例子 2:多个 Promise
的嵌套
console.log('start');
Promise.resolve().then(() => {
console.log('promise1');
Promise.resolve().then(() => {
console.log('promise2');
});
}).then(() => {
console.log('promise3');
});
console.log('end');
输出结果:
start
end
promise1
promise3
promise2
解释:
- 首先,执行全局脚本代码,输出
start
和end
。 Promise.resolve().then()
的第一个回调函数被放入微任务队列。- 全局脚本代码执行完毕,执行栈清空。
- 事件循环检查微任务队列,发现有微任务,执行
promise1
。 - 在
promise1
的回调函数中,又创建了一个Promise.resolve().then()
,它的回调函数被放入微任务队列。 promise1
的回调函数执行完毕,返回undefined
,触发外层then()
的第二个回调函数,被放入微任务队列。- 事件循环继续执行微任务队列,先执行
promise3
,再执行promise2
。
例子 3:MutationObserver
的应用
MutationObserver
是一种监听 DOM 变化的 API,它的回调函数也是一个微任务。
<!DOCTYPE html>
<html>
<head>
<title>MutationObserver Example</title>
</head>
<body>
<div id="myDiv">Hello</div>
<script>
const div = document.getElementById('myDiv');
const observer = new MutationObserver(function(mutations) {
console.log('MutationObserver callback');
mutations.forEach(function(mutation) {
console.log(mutation.type);
});
});
observer.observe(div, {
attributes: true,
childList: true,
subtree: true
});
div.setAttribute('data-name', 'world');
console.log('setAttribute done');
</script>
</body>
</html>
输出结果:
setAttribute done
MutationObserver callback
attributes
解释:
- 首先,执行全局脚本代码,创建
MutationObserver
实例,并监听div
元素的变化。 div.setAttribute('data-name', 'world')
触发 DOM 变化,MutationObserver
的回调函数被放入微任务队列。- 输出
setAttribute done
。 - 全局脚本代码执行完毕,执行栈清空。
- 事件循环检查微任务队列,执行
MutationObserver
的回调函数,输出MutationObserver callback
和attributes
。
六、实际应用:避免阻塞,优化性能
理解事件循环机制,对于编写高性能的 JavaScript 代码至关重要。
-
避免长时间的同步操作: 如果某个操作需要花费很长时间才能完成,应该将其放到异步任务中执行,避免阻塞主线程,导致页面卡顿。
// 糟糕的代码:同步读取大文件 const data = fs.readFileSync('large_file.txt'); console.log(data); // 更好的代码:异步读取大文件 fs.readFile('large_file.txt', function(err, data) { if (err) { console.error(err); } else { console.log(data); } });
-
合理使用
Promise
和async/await
:Promise
和async/await
可以让异步代码更加易于理解和维护,同时也能更好地利用微任务机制,提高代码执行效率。// 使用回调函数的异步操作 function getData(callback) { setTimeout(function() { callback('data'); }, 1000); } getData(function(data) { console.log(data); }); // 使用 Promise 的异步操作 function getDataPromise() { return new Promise(function(resolve) { setTimeout(function() { resolve('data'); }, 1000); }); } getDataPromise().then(function(data) { console.log(data); }); // 使用 async/await 的异步操作 async function getDataAsync() { const data = await getDataPromise(); console.log(data); } getDataAsync();
-
注意微任务的执行时机: 微任务会在每个宏任务执行完毕后立即执行,所以要避免在微任务中执行过于耗时的操作,否则可能会导致页面卡顿。
// 避免在微任务中执行耗时操作 Promise.resolve().then(function() { // 糟糕的代码:循环执行大量计算 for (let i = 0; i < 1000000000; i++) { // ... } console.log('微任务执行完毕'); }); setTimeout(function() { console.log('宏任务执行完毕'); }, 0);
在这个例子中,由于微任务中的循环计算非常耗时,会导致
setTimeout
的回调函数延迟执行。
七、总结:掌握事件循环,成为 JavaScript 大师
事件循环是 JavaScript 运行机制的核心,理解了它,就能更好地掌握 JavaScript 的异步编程,编写出高性能、高可靠性的代码。希望今天的讲座能够帮助大家更好地理解事件循环,成为真正的 JavaScript 大师!
记住,JavaScript 的世界里,一切皆是任务,而事件循环就是那个默默无闻,却又至关重要的管家婆。掌握了它,你就掌握了 JavaScript 的“命运”。
今天的讲座就到这里,感谢各位的收听!下次再见!