前端学习17 事件循环(Event Loop)机制
JavaScript作为一门单线程语言,却能够高效处理异步任务,这一特性使其在处理网络请求、用户交互等场景下表现出色。而这一切的背后,都离不开Event Loop(事件循环)这一核心机制。
1.JavaScript的单线程
JavaScript是一门单线程语言,这意味着它只有一个主线程用于执行代码。这一设计决策主要是为了简化DOM操作的复杂性,避免多线程可能导致的并发问题。然而,单线程也带来了一个明显的问题:如果某个任务执行时间过长,会导致整个应用"卡住",无法响应用户交互。
为了解决这个问题,JavaScript引入了异步编程模型,而Event Loop就是这个模型的核心机制。
1.1 核心组件
JavaScript运行时环境主要由以下几个部分组成:
- 调用栈(Call Stack):用于追踪函数调用的栈结构,记录当前执行的代码位置。
- 堆(Heap):用于存储对象、数组等复杂的数据机构的内存区域
- 任务队列(Task Queues):存储待执行的回调函数 宏任务队列和微任务队列
- Web API/Node API:由运行环境(浏览器/Node.js)提供的API,如定时器、网络请求等
而是事件循环(Event Loop):协调调用栈和任务队列之间的关系
1.2 调用栈
调用栈是一种LIFO(后进先出)的数据结构,用于跟踪代码的执行位置。当调用一个函数时,会将其压入栈顶;当函数执行完毕,会从栈顶弹出。
function multiply(a, b) { return a * b; } function square(n) { return multiply(n, n); } function printSquare(n) { const result = square(n); console.log(result); } printSquare(5);
- 将printSquare(5)压入栈
- 在printSquare内部,将square(5)压入栈
- 在square内部,将multiply(5, 5)压入栈
- multiply计算结果并返回,从栈中弹出
- square获得结果并返回,从栈中弹出
- printSquare打印结果并结束,从栈中弹出
此时,调用栈为空,标志着同步代码执行完毕。
2. 事件循环核心机制
2.1 事件循环流程
- 执行同步代码,这些代码会立即进入调用栈执行
- 调用栈清空后,检查微任务队列,依次执行所有微任务
- 微任务队列清空后,取出一个宏任务执行
- 宏任务执行完毕后,再次检查微任务队列,执行所有微任务
- 重复步骤3和4,形成一个循环
2.2 宏任务(Macrotask)和微任务(Microtask)
理解宏任务(Macrotask)和微任务(Microtask)的区别是掌握事件循环的关键。
宏任务(Macrotask)包括:
- setTimeout和setInterval回调
- setImmediate回调(Node.js环境)
- I/O操作回调
- UI交互事件
- requestAnimationFrame(浏览器环境)
- MessageChannel回调
微任务(Microtask)包括:
- Promise的
then
、catch
和finally
回调 queueMicrotask
回调MutationObserver
回调(浏览器环境)process.nextTick
回调(Node.js环境,优先级高于其他微任务)
微任务优先级高于宏任务,即当前宏任务执行完后,会先清空微任务队列,再执行下一个宏任务。
console.log('1. 同步代码开始'); setTimeout(() => { console.log('2. 宏任务(setTimeout回调)'); new Promise(resolve => { console.log('3. 宏任务中的同步代码'); resolve(); }).then(() => { console.log('4. 宏任务中的微任务'); }); }, 0); new Promise(resolve => { console.log('5. 同步代码中的Promise'); resolve(); }).then(() => { console.log('6. 微任务'); }); console.log('7. 同步代码结束');
输出顺序为:
- 1. 同步代码开始
- 5. 同步代码中的Promise
- 7. 同步代码结束
- 6. 微任务
- 2. 宏任务(setTimeout回调)
- 3. 宏任务中的同步代码
- 4. 宏任务中的微任务
执行过程分析:
- 首先执行同步代码,输出"1. 同步代码开始"
- 遇到setTimeout,将其回调放入宏任务队列
- 遇到Promise构造函数,其内部代码是同步执行的,输出"5. 同步代码中的Promise"
- 将Promise的then回调放入微任务队列
- 输出"7. 同步代码结束"
- 同步代码执行完毕,检查微任务队列,执行Promise的then回调,输出"6. 微任务"
- 微任务队列清空,从宏任务队列取出setTimeout回调执行
- 在setTimeout回调中,输出"2. 宏任务(setTimeout回调)"
- 遇到新的Promise,输出"3. 宏任务中的同步代码"
- 将新Promise的then回调放入微任务队列
- setTimeout回调执行完毕,检查微任务队列,执行Promise的then回调,输出"4. 宏任务中的微任务"
3.浏览器的事件循环
浏览器环境中的事件循环有其独特的特性,特别是与渲染管道的交互。
在浏览器环境中,渲染步骤(样式计算、布局、绘制等)通常发生在宏任务之间,且在所有微任务执行完毕之后。这意味着,如果你想在下一次渲染前操作DOM,应该使用微任务或requestAnimationFrame(用于在下一次浏览器重绘之前执行指定的回调函数。它的作用是告诉浏览器我们希望执行一段动画,并在动画执行时进行优化,以获得更流畅的效果)。
// 不建议的写法:可能导致多次不必要的重排 button.addEventListener('click', () => { box.style.width = '100px'; box.style.height = '100px'; box.style.margin = '20px'; }); // 优化的写法:所有DOM操作合并到下一帧执行 button.addEventListener('click', () => { requestAnimationFrame(() => { box.style.width = '100px'; box.style.height = '100px'; box.style.margin = '20px'; }); });
浏览器环境中的setTimeout和setInterval并不保证在指定时间后精确执行,只能保证在指定时间后将回调胶乳宏任务队列。如果调用栈或其他宏任务占用主线程,定时器回调会被延迟执行。
此外,大多数浏览器对不活跃标签页中的定时器有最小间隔限制(通常为1000ms),以节省系统资源。
4.事件循环与异步模式
4.1 回调地狱
// 回调地狱示例 getData(function(a) { getMoreData(a, function(b) { getEvenMoreData(b, function(c) { getFinalData(c, function(result) { console.log(result); }, handleError); }, handleError); }, handleError); }, handleError);
解决方案:
1、使用Promise链:将嵌套回调转换为扁平的链式调用
getData() .then(a => getMoreData(a)) .then(b => getEvenMoreData(b)) .then(c => getFinalData(c)) .then(result => console.log(result)) .catch(handleError);
2、使用async/await:使异步代码看起来像同步代码
async function fetchAllData() { try { const a = await getData(); const b = await getMoreData(a); const c = await getEvenMoreData(b); const result = await getFinalData(c); console.log(result); } catch (error) { handleError(error); } } fetchAllData();