Event Loop事件循环

117 阅读7分钟

在现代Web开发中,理解事件循环机制如同掌握JavaScript的心脏搏动规律。今天我们将通过实战代码,彻底揭开同步代码、微任务、渲染和宏任务之间的执行顺序关系。

一、JavaScript的单线程本质

JavaScript被设计为单线程语言,这意味着它一次只能处理一个任务。这种设计避免了多线程环境中的复杂同步问题,但也带来了问题:如何处理耗时操作而不阻塞主线程?

<script>
console.log('script start'); // 同步任务立即执行

setTimeout(() => console.log('setTimeout'), 0); // 宏任务延后

Promise.resolve().then(() => console.log('promise')); // 微任务优先
</script>

执行顺序

  1. script start(同步)
  2. promise1.then(微任务)
  3. setTimeout(宏任务)

Promise一定记得要.then才是微任务,Promise的创建是同步任务!

二、事件循环的执行流程

事件循环的生命周期可分为四个阶段:

阶段1:同步代码

console.log('start');
console.log('The promise'); // Promise构造函数内的同步代码
console.log('end');

所有同步代码立即执行,形成调用栈的执行顺序。

阶段2:微任务处理

Promise.resolve().then(() => console.log('Promise Resolved'));

queueMicrotask(() => console.log('微任务:queueMicrotask')); // 微任务队列

微任务队列包括:

  • Promise.then() 回调
  • queueMicrotask() 手动添加
  • MutationObserver DOM变更监听
  • Node.js中的 process.nextTick()

process.nextTick(Node.js)优先级高于普通微任务,因为process.nextTick在Node.js早期版本就已存在,早于Promise标准化。

注意:浏览器环境没有process.nextTick,微任务只有Promise等标准实现。

重要规则:当同步代码执行完毕,引擎会清空所有微任务,直到队列为空才进入下一阶段。

阶段3:渲染阶段

target.setAttribute('data-set','123');
target.appendChild(document.createElement('span'));

在微任务执行后,浏览器进行:

  1. 样式计算(Recalc Style)
  2. 布局(Layout)
  3. 绘制(Paint)

关键提示queueMicrotask中的DOM操作无法立即获取最新布局属性:

queueMicrotask(() => {
  console.log('微任务中获取高度:', el.offsetHeight); // 可能不是最新值
});

阶段4:宏任务执行(最后处理)

setTimeout(() => {
  console.log('下一把再见');
  // 内部微任务会插入当前宏任务末尾
  Promise.resolve().then(() => console.log('promise4'));
}, 0);

宏任务队列包括:

  • setTimeout/setInterval
  • I/O操作(文件读写)
  • UI渲染(部分浏览器实现)
  • 事件回调(click等)

三、代码执行顺序

案例1:基础任务队列

console.log('start');

setTimeout(() => console.log('setTimeout'), 0);

Promise.resolve().then(() => console.log('promise1'));

console.log('end');

// 结果:start → end → promise1 → setTimeout

案例2:混合任务嵌套

console.log('start');

setTimeout(() => {
  console.log('setTimeout');
  Promise.resolve().then(() => console.log('inner promise'));
}, 0);

Promise.resolve().then(() => console.log('promise1'));

console.log('end');

/* 执行顺序:
1. start (同步)
2. end (同步)
3. promise1 (微任务)
4. setTimeout (宏任务)
5. inner promise (宏任务中的微任务)
*/

案例3:DOM变更监听

<script>
const target = document.createElement('div');
document.body.appendChild(target);

const observer = new MutationObserver(() => {
  console.log('微任务:MutationObserver');
});

observer.observe(target, { attributes: true });

target.setAttribute('data', 'value'); // 触发微任务
</script>

MutationObserver的回调作为微任务执行,在渲染前触发。

四、Node.js的特殊情况

Node.js的事件循环略有不同,process.nextTick优先级最高:

console.log('start');

process.nextTick(() => console.log('Process Next Tick'));

Promise.resolve().then(() => console.log('Promise Resolved'));

setTimeout(() => console.log('setTimeout'), 0);

/* Node输出:
start
Process Next Tick
Promise Resolved
setTimeout
*/

五、小结

  1. 执行优先级
    同步代码 > 微任务 > 渲染 > 宏任务

  2. 微任务

    • 每个宏任务结束后立即执行
    • 可无限嵌套(谨慎使用!)
    • 适合DOM更新后、渲染前的操作
  3. 渲染时机

    graph LR
    A[同步代码] --> B[微任务队列]
    B --> C[渲染管道]
    C --> D[宏任务队列]
    D --> B
    

六、从浏览器多进程架构分析

看看下面这个例子:

setTimeout(() => 
    console.log('setTimeout2'), 
2000);

setTimeout(() => 
    console.log('setTimeout1'), 
1000);

易知,肯定是下面的setTimeout1先输出,但是不是上面的先进宏任务队列吗?按照队列先进先出的规则应该是上面的先输出啊。其实是,定时器在渲染进程中有自己的线程,等待设定时间完成之后才进入宏队列。

现代浏览器采用多进程架构,每个标签页都是一个独立的渲染进程,这种设计带来了更高的安全性和稳定性:

graph TD
    A[浏览器主进程] --> B[渲染进程1]
    A --> C[渲染进程2]
    A --> D[GPU进程]
    A --> E[网络进程]
    A --> F[插件进程]
    
    B --> G[主线程]
    B --> H[定时器线程]
    B --> I[网络线程]
    B --> J[合成线程]
    
    G --> K[JS引擎]
    G --> L[DOM解析]
    G --> M[样式计算]
    G --> N[布局计算]

七、关键组件

  1. 浏览器主进程

    • 管理所有子进程
    • 处理用户界面交互
    • 协调各个进程间的通信
  2. 渲染进程(每个标签页一个):

    • 负责页面渲染和脚本执行
    • 包含多个协作线程
    • 一个标签页崩溃不会影响其他标签页
  3. 主线程(渲染进程的核心):

    • 执行JavaScript代码
    • 解析HTML和CSS
    • 处理DOM操作
    • 执行事件循环

八、多线程协作机制

线程类型及其职责

线程类型职责示例
主线程执行JS、DOM操作、样式计算V8引擎执行JS代码
定时器线程管理定时器setTimeout/setInterval
网络线程处理网络请求fetch/XHR
合成线程图层合成与绘制页面渲染
事件触发线程管理DOM事件addEventListener

九、线程协作流程

sequenceDiagram
    participant 主线程
    participant 定时器线程
    participant 网络线程
    participant 宏任务队列
    
    主线程->>定时器线程: setTimeout(callback, 1000)
    定时器线程-->>定时器线程: 等待1000ms
    定时器线程->>宏任务队列: 放入callback
    宏任务队列-->>主线程: 事件循环取出callback执行
    
    主线程->>网络线程: fetch(url)
    网络线程-->>网络线程: 发送请求等待响应
    网络线程->>宏任务队列: 放入onload回调
    宏任务队列-->>主线程: 事件循环取出回调执行

因为定时器有自己的线程,所以一开始不会立即放入宏任务队列中,等待其设定的时间结束之后才会从定时器线程中出来进入宏任务队列

十、事件循环机制的深入解析

为什么setTimeout不准确?

  1. 最小延迟限制:浏览器通常有4ms的最小延迟
  2. 事件循环阻塞:主线程被长时间同步任务阻塞
  3. 标签页非激活状态:浏览器会降低非激活标签页的定时器频率
  4. 系统资源限制:当系统负载高时,定时器可能被延迟执行

JS执行与渲染的互斥性

JavaScript执行和页面渲染都在主线程上进行,因此它们是互斥的:

// 长时间运行的JS会阻塞渲染
function longTask() {
    const start = Date.now();
    while (Date.now() - start < 5000) {
        // 阻塞主线程5秒
    }
    // 在此期间页面会"冻结",无法渲染更新
}

这里只是做一个示例,最好还是不要让JS这样占用主线程

事件队列优先级体系

在事件循环中,任务有明确的优先级顺序:

  1. 用户输入事件:最高优先级(点击、滚动等)
  2. 微任务:Promise回调、queueMicrotask等
  3. 宏任务:setTimeout、XHR回调等
  4. requestAnimationFrame:在渲染前执行
  5. 渲染操作:样式计算→布局→绘制

十一、完整页面渲染流程

graph LR
    A[解析HTML] --> B[构建DOM树]
    C[解析CSS] --> D[构建CSSOM树]
    B --> E[合并DOM和CSSOM]
    D --> E
    E --> F[构建渲染树]
    F --> G[LayOut树]
    G --> H[图层分层]
    H --> I[绘制]
    I --> J[合成显示]

事件循环的完整流程

  1. 执行同步代码

    • 所有同步JS代码立即执行
    • 遇到异步操作交给其他线程处理
  2. 处理微任务队列

    • 执行所有微任务(Promise.then等)
    • 微任务执行过程中可能产生新的微任务
  3. 执行渲染操作

    • 检查是否需要渲染(通常每秒60次)
    • 执行requestAnimationFrame回调
    • 样式计算→布局→绘制→合成
  4. 处理宏任务队列

    • 取出一个宏任务执行
    • 执行过程中可能产生新的微任务和宏任务
    • 宏任务执行完毕返回步骤2

十二、性能优化实践

避免长任务阻塞

// 将长任务分解为多个小任务
function processChunk() {
    // 处理一小部分数据...
    if (items.length) {
        // 使用宏任务拆分
        setTimeout(processChunk, 0);
    }
}

// 使用requestIdleCallback处理低优先级任务
function backgroundTask(deadline) {
    while (deadline.timeRemaining() > 0 && tasks.length > 0) {
        processTask();
    }
    if (tasks.length > 0) {
        requestIdleCallback(backgroundTask);
    }
}
requestIdleCallback(backgroundTask);

requestIdleCallback 是浏览器提供的用于在空闲时期执行低优先级任务的API

浏览器在主线程空闲时(帧之间的空闲期)触发回调

回调接收IdleDeadline对象,包含:

  • timeRemaining():剩余空闲时间(通常≤50ms)

  • didTimeout:是否因超时被强制触发

合理使用任务队列

任务类型适用场景注意事项
微任务DOM更新后、渲染前的操作避免无限递归
宏任务非紧急任务、拆分长任务有最小延迟
requestAnimationFrame动画、渲染相关操作每帧执行一次
requestIdleCallback低优先级后台任务可能不被执行

简单总结

  1. 浏览器多进程架构提供了更好的安全性和稳定性
  2. 多线程协作使浏览器能高效处理各种任务
  3. 事件循环机制协调了JS执行、渲染和各种异步操作
  4. JS执行与渲染互斥要求我们避免长任务阻塞主线程

十三、思考题

以下代码输出顺序是什么?

console.log('start');

setTimeout(() => console.log('timeout1'), 0);

Promise.resolve().then(() => 
{
  console.log('promise1');
  setTimeout(() => console.log('timeout2'), 0);
});

queueMicrotask(() => console.log('microtask'));

console.log('end');

答案
start → end → promise1 → microtask → timeout1 → timeout2

掌握事件循环机制,能让你避免90%的异步陷阱。记住同步 > 微任务 > 渲染 > 宏任务。

计算机学习都是成体系的,CPU->进程->线程->执行栈->同步-> 微任务-> 渲染-> 宏任务

一步步推理学习能更加印象深刻并深入学习。