彻底讲透浏览器的事件循环,吊打面试官

第一层:幼儿园阶段 ------ 为什么要有 Event Loop?

首先要明白一个铁律JavaScript 在浏览器中是单线程的

想象一下:你是一家餐厅唯一的厨师(主线程)。

  1. 客人点了一份炒饭(同步代码),你马上炒。

  2. 客人点了一份需要炖3小时的汤(耗时任务,如网络请求、定时器)。

如果你只有这一个线程,还要死等汤炖好才能炒下一个菜,那餐厅早就倒闭了(页面卡死)。

所以,浏览器给你配了几个服务员(Web APIs,如定时器模块、网络模块)。

  • 厨师(主线程) :只负责炒菜(执行 JS 代码)。

  • 服务员(Web APIs) :负责看火炖汤(计时、HTTP请求)。汤好了,服务员把"汤好了"这个纸条贴在厨房的**任务板(队列)**上。

  • Event Loop(事件循环) :就是厨师的一个习惯------炒完手里的菜,就去看看任务板上有没有新纸条。如果有,拿下来处理。

总结:Event Loop 是单线程 JS 实现异步非阻塞的核心机制。


第二层:小学阶段 ------ 宏任务与微任务的分类

任务板上的纸条分两种,优先级不同。面试官最爱问这个分类。

1. 宏任务(Macrotask / Task)

这就像是新的客人进店。每次处理完一个宏任务,厨师可能需要休息一下(浏览器渲染页面),然后再接下一个。

  • 常见的

  • script (整体代码 script 标签)

  • setTimeout / setInterval

  • setImmediate (Node.js/IE 环境)

  • UI 渲染 / I/O

  • postMessage

2. 微任务(Microtask)

这就像是当前客人的临时加单 。客人说:"我要加个荷包蛋"。厨师必须 在服务下一个客人之前,先把这个客人的加单做完。不能让当前客人等着你去服务别人。

  • 常见的

  • Promise.then / .catch / .finally

  • process.nextTick (Node.js,优先级最高)

  • MutationObserver (监听 DOM 变化)

  • queueMicrotask


第三层:中学阶段 ------ 完整的执行流程(必背)

这是大多数面试题的解题公式。请背诵以下流程:

  1. 执行同步代码(这其实是第一个宏任务)。

  2. 同步代码执行完毕,Call Stack(调用栈)清空

  3. 检查微任务队列

  • 如果有,依次执行所有微任务,直到队列清空。

  • 注意:如果在执行微任务时又产生了新的微任务,会插队到队尾,本轮必须全部执行完,绝不留到下一轮。

  1. 尝试渲染 UI(浏览器会根据屏幕刷新率决定是否需要渲染,通常 16ms 一次)。

  2. 取出下一个宏任务执行。

  3. 回到第 1 步,循环往复。

口诀:同步主线程 -> 清空微任务 -> (尝试渲染) -> 下一个宏任务


第四层:大学阶段 ------ 常见坑点实战(初级面试题)

这时候我们来看代码,这里有两个经典坑。

坑点 1:Promise 的构造函数是同步的

面试官常考:

javascript 复制代码
new Promise((resolve) => {
    console.log(1); // 同步执行!
    resolve();
}).then(() => {
    console.log(2); // 微任务
});
console.log(3);

JavaScriptCopy

解析 :Promise 构造函数里的代码会立即执行。只有 .then 里面的才是微任务。 输出1 -> 3 -> 2

坑点 2:async/await 的阻塞

javascript 复制代码
async function async1() {
    console.log('A');
    await async2(); // 关键点
    console.log('B');
}
async function async2() {
    console.log('C');
}
async1();
console.log('D');

JavaScriptCopy

解析

  1. async1 开始,打印 A

  2. 执行 async2 ,打印 C

  3. 关键 :遇到 await ,浏览器会把 await 后面 的代码( console.log('B') )放到微任务队列 里,然后跳出 async1 函数,继续执行外部的同步代码。

  4. 打印 D

  5. 同步结束,清空微任务,打印 B 输出A -> C -> D -> B


第五层:博士阶段 ------ 深入进阶(吊打面试官专用)

1. 为什么要有微任务?(设计哲学)

你可能知道微任务比宏任务快,但为什么? 本质原因 :为了确保在下次渲染之前 ,更新应用的状态。 如果微任务是宏任务,那么 数据更新 -> 宏任务队列 -> 渲染 -> 宏任务执行 。这会导致页面先渲染一次旧数据,然后再执行逻辑更新,导致闪屏。 微任务保证了: 数据更新 -> 微任务(更新更多状态) -> 渲染 。所有的状态变更都在同一帧内完成。

2. 微任务的死循环(炸掉浏览器)

因为微任务必须清空才能进入下一个阶段。

scss 复制代码
function loop() {
    Promise.resolve().then(loop);
}
loop();

JavaScriptCopy

后果 :这会阻塞主线程!浏览器页面会卡死(点击无反应),且永远不会进行 UI 渲染。 对比 :如果是 setTimeout(loop, 0) 无限递归,虽然 CPU 占用高,但浏览器依然可以响应点击,依然可以渲染页面。因为宏任务之间会给浏览器"喘息"的机会。

3. 页面渲染的时机(DOM 更新是异步的吗?)

这是一个巨大的误区。JS 修改 DOM 是同步的(内存里的 DOM 树立刻变了),但视觉上的渲染是异步的。

ini 复制代码
document.body.style.background = 'red';
document.body.style.background = 'blue';
document.body.style.background = 'black';

JavaScriptCopy

浏览器很聪明,它不会画红、画蓝、再画黑。它会等 JS 执行完,发现最后是黑色,直接画黑色。

必杀技问题:如何在宏任务执行前强制渲染? 如果你想让用户看到红色,然后再变黑,普通的 setTimeout(..., 0) 是不稳定的。 标准做法是使用 requestAnimationFrame 或者 强制回流(Reflow) (比如读取 offsetHeight )。

4. 真正的深坑:事件冒泡中的微任务顺序

这是极少数人知道的细节。

场景:父子元素都绑定点击事件。

javascript 复制代码
// HTML: <div id="outer"><div id="inner">Click me</div></div>


const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');


function onClick() {
    console.log('click');
    Promise.resolve().then(() => console.log('promise'));
}


outer.addEventListener('click', onClick);
inner.addEventListener('click', onClick);

JavaScriptCopy

情况 A:用户点击屏幕

  1. 触发 inner 点击 -> 打印 click -> 微任务入队。

  2. 栈空了! (在冒泡到 outer 之前,当前回调结束了)。

  3. 检查微任务 -> 打印 promise

  4. 冒泡到 outer -> 打印 click -> 微任务入队。

  5. 回调结束 -> 检查微任务 -> 打印 promise 结果click -> promise -> click -> promise

情况 B:JS 代码触发 inner.click()

  1. inner.click() 这是一个同步函数!

  2. 触发 inner 回调 -> 打印 click -> 微任务入队。

  3. 栈没空! (因为 inner.click() 还在栈底等着冒泡结束)。

  4. 不能执行微任务

  5. 冒泡到 outer -> 打印 click -> 微任务入队。

  6. inner.click() 执行完毕,栈空。

  7. 清空微任务 (此时队列里有两个 promise)。 结果click -> click -> promise -> promise

面试杀招 :指出用户交互触发程序触发在 Event Loop 中的堆栈状态不同,导致微任务执行时机不同。


第六层:上帝视角 ------ 浏览器的一帧(The Frame)

要理解 React 为什么要搞 Concurrent Mode,首先要看懂**"一帧"**里到底发生了什么。

大多数屏幕是 60Hz,意味着浏览器只有 16.6ms 的时间来完成这一帧的所有工作。如果超过这个时间,页面就会掉帧(卡顿)。

完整的一帧流程(标准管线):

  1. Input Events: 处理阻塞的输入事件(Touch, Wheel)。

  2. JS (Macro/Micro) : 执行定时器、JS 逻辑。这里是性能瓶颈的高发区

  3. Begin Frame: 每一帧开始的信号。

  4. requestAnimationFrame (rAF) : 关键点。这是 JS 在渲染前最后修改 DOM 的机会。

  5. Layout (重排) : 计算元素位置(盒模型)。

  6. Paint (重绘) : 填充像素。

  7. Idle Period (空闲时间) : 如果上面所有事情做完还没到 16.6ms,剩下的时间就是 Idle。

关键冲突 : Event Loop 的微任务(Microtasks)是在 JS 执行完立刻执行的。如果微任务队列太长,或者 JS 宏任务太久,直接把 16.6ms 撑爆了,浏览器就没机会去执行 Layout 和 Paint。 结果就是:页面卡死。


第七层:React 18 Concurrent Mode ------ 时间切片(Time Slicing)

React 15(Stack Reconciler)是递归更新,一旦开始 diff 一棵大树,必须一口气做完。如果这棵树需要 100ms 计算,那这 100ms 内主线程被锁死,用户输入无响应。

React 18(Fiber 架构)引入了 可中断渲染

1. 核心原理:把"一口气"变成"喘口气"

React 把巨大的更新任务切分成一个个小的 Fiber 节点(Unit of Work)

  • 旧模式:JS 执行 100ms -> 渲染。 (卡顿)

  • 新模式 (Concurrent)

  1. 执行 5ms 任务。

  2. 问浏览器:"还有时间吗?有高优先级任务(如用户点击)插队吗?"

  3. 有插队 -> 暂停当前 React 更新,把主线程还给浏览器去处理点击/渲染。

  4. 没插队 -> 继续下一个 5ms。

2. 实现手段:如何"暂停"和"恢复"?(MessageChannel 的妙用)

React 必须要找一个宏任务来把控制权交还给浏览器。

  • 为什么不用 setTimeout(fn, 0)

  • 因为这货有 4ms 的最小延迟(由于 HTML 标准遗留问题,嵌套层级深了会强制 4ms)。对于追求极致的 React 来说,4ms 太浪费了。

  • 为什么不用 Microtask

  • 死穴 :微任务会在页面渲染全部清空。如果你用微任务递归,主线程还是会被锁死,根本不会把控制权交给 UI 渲染。

  • 最终选择: MessageChannel

  • React Scheduler 内部创建了一个 MessageChannel

  • 当需要"让出主线程"时,React 调用 port.postMessage(null)

  • 这会产生一个宏任务

  • 因为是宏任务,浏览器有机会在两个任务之间插入 UI 渲染响应用户输入

  • MessageChannel 的延迟极低(接近 0ms),优于 setTimeout

简化的 React Scheduler 伪代码:

ini 复制代码
let isMessageLoopRunning = false;
const channel = new MessageChannel();
const port = channel.port2;


// 这是一个宏任务回调
channel.port1.onmessage = function() {
    const currentTime = performance.now();
    let hasTimeRemaining = true;


    // 执行任务,直到时间片用完(默认 5ms)
    while (workQueue.length > 0 && hasTimeRemaining) {
        performWork(); 
        // 检查是否超时(比如超过了 5ms)
        if (performance.now() - currentTime > 5) {
            hasTimeRemaining = false;
        }
    }


    if (workQueue.length > 0) {
        // 如果还有活没干完,但时间片到了,
        // 继续发消息,把剩下的活放到下一个宏任务里
        port.postMessage(null);
    } else {
        isMessageLoopRunning = false;
    }
};


function requestHostCallback() {
    if (!isMessageLoopRunning) {
        isMessageLoopRunning = true;
        port.postMessage(null); // 触发宏任务
    }
}

JavaScriptCopy


第八层:Vue 3 的策略对比 ------ 为什么 Vue 不需要 Fiber?

这是一个极好的对比视角。

  • React:走的是"全量推导"路线。组件更新时,默认不知道哪里变了,需要遍历树。为了不卡顿,只能用 Event Loop 切片。

  • Vue:走的是"精确依赖"路线。响应式系统(Proxy)精确知道是哪个组件变了。更新粒度很细,通常不需要像 React 那样长时间的计算。

Vue 的 Event Loop 应用: nextTick Vue 依然大量使用了 Event Loop,主要是为了批量更新(Batching)

ini 复制代码
count.value = 1;
count.value = 2;
count.value = 3;

JavaScriptCopy

Vue 检测到数据变化,不会渲染 3 次。它会开启一个队列,把 Watcher 推进去。然后通过 Promise.then (微任务) 或 MutationObserver 在本轮代码执行完后,一次性 flush 队列。

应用场景:当你修改了数据,想立刻获取更新后的 DOM 高度。

arduino 复制代码
msg.value = 'Hello';
console.log(div.offsetHeight); // 还是旧高度!因为 DOM 更新在微任务里
await nextTick(); // 等待微任务执行完
console.log(div.offsetHeight); // 新高度

JavaScriptCopy


第九层:实战中的"精细化调度"

除了框架内部,我们在写复杂业务代码时,如何利用 Event Loop 管线进行优化?

1. requestAnimationFrame (rAF) 做动画

  • 错误做法setTimeout 做动画。

  • 原因: setTimeout 也是宏任务,但它的执行时机和屏幕刷新(VSync)不同步。可能会导致一帧里执行了两次 JS,或者掉帧。

  • 正确做法rAF

  • 它保证回调函数严格在下一次 Paint 之前执行。

  • 浏览器会自动优化:如果页面切到后台,rAF 会暂停,省电。

2. requestIdleCallback 做低优先级分析

  • 场景:发送埋点数据、预加载资源、大数据的后台计算。

  • 原理:告诉浏览器,"等我不忙了(帧末尾有剩余时间)再执行这个"。

  • 注意:React 没直接用这个 API,因为它的兼容性和触发频率不稳定,React 自己实现了一套类似的(也就是上面说的 MessageChannel 机制)。

3. 大数据列表渲染(时间切片实战)

假设后端给你返回了 10 万条数据,你要渲染到页面上。

  • 直接渲染ul.innerHTML = list -> 页面卡死 5 秒。

  • 微任务渲染:用 Promise 包裹 -> 依然卡死!因为微任务也会阻塞渲染。

  • 宏任务分批(时间切片)

scss 复制代码
function renderList(list) {
    if (list.length === 0) return;


    // 每次取 20 条
    const chunk = list.slice(0, 20); 
    const remaining = list.slice(20);


    // 渲染这 20 条
    renderChunk(chunk);


    // 关键:用 setTimeout 把剩下的放到下一帧(或之后的宏任务)去处理
    // 这样浏览器就有机会在中间进行 UI 渲染,用户能看到列表慢慢变长,而不是卡死
    setTimeout(() => {
        renderList(remaining);
    }, 0);
}

JavaScriptCopy

  • 进阶 :使用 requestAnimationFrame 替代 setTimeout ,虽然 rAF 主要是为动画服务的,但在处理 DOM 批量插入时,配合 DocumentFragment 往往比 setTimeout 更流畅,因为它紧贴渲染管线。

第十层:未来的标准 ------ scheduler.postTask

浏览器厂商发现大家都在自己搞调度(React 有 Scheduler,Vue 有 nextTick),于是 Chrome 推出了原生的 Scheduler API

这允许你直接指定任务的优先级,而不需要玩 setTimeout MessageChannel 的黑魔法。

php 复制代码
// 只有 Chrome 目前支持较好
scheduler.postTask(doImportantWork, { priority: 'user-blocking' }); // 高优
scheduler.postTask(doAnalytics, { priority: 'background' }); // 低优

JavaScriptCopy

总结:如何回答"实际应用场景"

如果面试官问到这里,你可以这样收网:

  1. 管线视角:先说明 JS 执行、微任务、渲染、宏任务的流水线关系。

  2. React 案例 :重点描述 React 18 如何利用 宏任务 ( MessageChannel ) 实现时间切片,从而打断长任务,让出主线程给 UI 渲染

  3. 对比 Vue :解释 Vue 利用 微任务 ( Promise ) 实现异步批量更新,避免重复计算。

  4. 业务落地

  • 高性能动画 :必用 requestAnimationFrame 保持与帧率同步。

  • 海量数据渲染 :手动分片,利用 setTimeout rAF 分批插入 DOM,避免白屏卡顿。

  • 后台计算/埋点 :利用 requestIdleCallback 在浏览器空闲时处理。

终极回答策略:从机制到架构的四维阐述

1. 核心定性(不仅是单线程)

"Event Loop 是浏览器用来协调 JS 执行DOM 渲染用户交互 以及 网络请求 的核心调度机制。它解决了 JS 单线程无法处理高并发异步任务的问题,实现了非阻塞 I/O。"

2. 标准流程(精确到微毫秒的执行顺序)

"标准的流程是:执行栈为空 -> 清空微任务队列 (Microtasks) -> 尝试进行 UI 渲染 -> 取出一个宏任务 (Macrotask)执行。 这里的关键点是:微任务拥有最高优先级插队权 ,必须全部清空才能进入下一阶段;而UI 渲染穿插在微任务之后、宏任务之前,通常由浏览器的刷新率(60Hz)决定是否执行。"

3. 进阶:与渲染管线的结合(展示物理层面的理解)

"在性能优化中,我们要关注**'一帧'(16.6ms)**的生命周期。 如果微任务队列太长,或者宏任务执行太久,都会阻塞浏览器的 LayoutPaint,导致掉帧。

4. 降维打击:框架原理与调度实战(这是加分项!)

"深刻理解 Event Loop 是理解现代框架源码的基石:


速记核心关键词

如果面试紧张,脑子里只要记住这 4 个关键词,就能串联起整个知识网:

  1. 单线程 (起点)

  2. 微任务清空 (Promise, Vue 原理)

  3. 渲染管线 (16ms, 动画流畅度)

  4. 宏任务切片 (React Fiber, 大数据分片)

相关推荐
来自上海的这位朋友2 小时前
从零打造一个无依赖的Canvas图片编辑器
javascript·vue.js·canvas
OpenTiny社区2 小时前
揭秘!TinyEngine低代码源码如何玩转双向转换?
前端·vue.js·低代码
用户8168694747252 小时前
beginWork 与 completeWork 的内部职责分工
前端·react.js
3秒一个大2 小时前
从后端模板到响应式驱动:界面开发的演进之路
前端·后端
三喵2232 小时前
跨域 iframe 内嵌的同源策略适配方案-Youtube举例
前端·爬虫
灰灰勇闯IT2 小时前
RN跨端适配与沉浸式体验:适配不同设备与系统
javascript·react native·react.js
无敌暴龙兽2 小时前
Android 布局多次测量
前端
Moe4882 小时前
Elasticsearch 8.1 Java API Client 客户端使用指南(索引、文档操作篇)
java·后端·面试
wordbaby2 小时前
React Native 数据同步双重奏:深度解析“页面级聚焦”与“应用级聚焦”的区别
前端·react native