浏览器的事件循环

57 阅读6分钟

浏览器事件循环详解

目录

概述

浏览器事件循环是JavaScript异步编程的核心机制,负责协调事件、用户交互、脚本执行、渲染和网络请求等操作。它确保浏览器能够高效地处理各种任务,同时保持用户界面的响应性。

关键要点

  • 每个代理(Agent)都有唯一的事件循环
  • 事件循环包含任务队列和微任务队列
  • 任务按优先级执行,微任务优先于新任务
  • 渲染更新在任务执行后进行

核心概念

事件循环

事件循环是浏览器协调各种操作的核心机制。根据不同的执行环境,事件循环分为:

  • 窗口事件循环:同源窗口代理的事件循环
  • 工作代理事件循环:专用工作代理、共享工作代理或服务工作代理的事件循环
  • 工作集事件循环:工作集代理的事件循环

注意:事件循环不一定与实现线程一一对应。多个窗口事件循环可以在单个线程中协同调度。

任务队列

任务队列是事件循环的核心组件,用于存储待执行的任务。每个事件循环有一个或多个任务队列。

重要特性

  • 任务队列是集合而非队列(按优先级选择任务)
  • 微任务队列不属于任务队列
  • 任务按源分组,确保同源任务按序执行

微任务队列

微任务队列是事件循环的另一个重要组件,具有以下特点:

  • 微任务优先于新任务执行
  • 微任务在任务执行完毕后立即执行
  • 微任务可以产生新的微任务

任务类型

任务封装了各种算法,主要包括:

1. 事件任务

在特定的 EventTarget 对象处分派事件对象。

2. 解析任务

HTML 解析器对字节进行标记和处理。

3. 回调任务

执行各种回调函数。

4. 资源处理任务

处理异步获取的资源。

5. DOM 操作任务

响应 DOM 操作而触发的任务。

任务结构

每个任务包含以下属性:

  • 步骤:指定任务要完成的工作
  • 来源:任务源,用于分组和序列化
  • 文档:关联的文档对象
  • 脚本评估环境设置对象集:跟踪脚本评估的环境

事件循环处理模型

事件循环持续执行以下步骤:

while (true) {
  // 1. 执行任务
  if (taskQueue.hasRunnableTasks()) {
    const task = taskQueue.getNextTask();
    executeTask(task);
    performMicrotaskCheckpoint();
  }
  
  // 2. 执行微任务检查点
  performMicrotaskCheckpoint();
  
  // 3. 更新渲染(仅窗口事件循环)
  if (isWindowEventLoop && noRunnableTasks()) {
    updateRendering();
  }
}

详细执行流程

  1. 任务选择:从任务队列中选择一个可运行的任务
  2. 任务执行:执行任务的步骤
  3. 微任务检查:执行微任务检查点
  4. 渲染更新:更新页面渲染(如果需要)
  5. 空闲处理:处理空闲期回调

微任务检查点

微任务检查点是事件循环的关键机制,确保微任务能够及时执行。

执行步骤

function performMicrotaskCheckpoint() {
  if (isPerformingMicrotaskCheckpoint) return;
  
  isPerformingMicrotaskCheckpoint = true;
  
  while (microtaskQueue.length > 0) {
    const microtask = microtaskQueue.dequeue();
    executeMicrotask(microtask);
  }
  
  // 处理被拒绝的 Promise
  notifyRejectedPromises();
  
  // 清理操作
  cleanupIndexedDatabaseTransactions();
  clearKeptObjects();
  
  isPerformingMicrotaskCheckpoint = false;
}

微任务类型

  • Promise 回调(then/catch/finally)
  • queueMicrotask() 回调
  • MutationObserver 回调
  • process.nextTick()(Node.js)

任务源

通用任务源

  1. DOM 操作任务源

    • 响应 DOM 操作的功能
    • 例如:元素插入文档时的非阻塞操作
  2. 用户交互任务源

    • 响应用户交互的功能
    • 例如:键盘、鼠标输入事件
  3. 网络任务源

    • 响应网络活动的功能
    • 例如:AJAX 请求完成
  4. 导航和遍历任务源

    • 涉及导航和历史遍历的任务
  5. 渲染任务源

    • 专门用于更新渲染

实际应用

执行顺序示例

console.log('1'); // 同步任务

setTimeout(() => {
  console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3'); // 微任务
});

console.log('4'); // 同步任务

// 输出顺序:1, 4, 3, 2

微任务优先级示例

setTimeout(() => {
  console.log('宏任务 1');
  Promise.resolve().then(() => {
    console.log('微任务 1');
  });
}, 0);

setTimeout(() => {
  console.log('宏任务 2');
}, 0);

// 输出顺序:宏任务 1, 微任务 1, 宏任务 2

渲染更新时机

// 在任务中修改 DOM
setTimeout(() => {
  document.body.style.backgroundColor = 'red';
  // 渲染更新发生在任务执行完毕后
}, 1000);

// 在微任务中修改 DOM
Promise.resolve().then(() => {
  document.body.style.backgroundColor = 'blue';
  // 渲染更新发生在微任务执行完毕后
});

最佳实践

1. 避免阻塞事件循环

// ❌ 错误:阻塞事件循环
function blockingOperation() {
  const start = Date.now();
  while (Date.now() - start < 1000) {
    // 阻塞 1 秒
  }
}

// ✅ 正确:使用异步操作
function nonBlockingOperation() {
  return new Promise(resolve => {
    setTimeout(resolve, 1000);
  });
}

2. 合理使用微任务

// ✅ 适合使用微任务的场景
Promise.resolve().then(() => {
  // 需要在当前任务结束后立即执行
  // 但要在下一个任务开始前完成
  updateUI();
});

// ❌ 不适合使用微任务的场景
Promise.resolve().then(() => {
  // 长时间运行的操作会阻塞微任务队列
  heavyComputation();
});

3. 避免微任务嵌套

// ❌ 避免:微任务嵌套可能导致无限循环
function recursiveMicrotask() {
  Promise.resolve().then(() => {
    recursiveMicrotask(); // 可能导致问题
  });
}

// ✅ 正确:使用 setTimeout 或其他机制
function recursiveTask() {
  setTimeout(() => {
    recursiveTask();
  }, 0);
}

常见问题

Q1: 为什么 Promise 比 setTimeout 先执行?

A: Promise 的回调是微任务,而 setTimeout 的回调是宏任务。微任务在当前任务执行完毕后立即执行,而宏任务需要等待下一个事件循环。

Q2: 如何确保代码在 DOM 更新后执行?

A: 可以使用 queueMicrotask()Promise.resolve().then() 来确保代码在 DOM 更新后执行:

document.body.style.backgroundColor = 'red';
queueMicrotask(() => {
  // 这里的代码会在 DOM 更新后执行
  console.log('DOM 已更新');
});

Q3: 事件循环和线程的关系是什么?

A: 事件循环是逻辑概念,线程是物理概念。多个事件循环可以在单个线程中运行,也可以分布在多个线程中。浏览器的主线程通常运行窗口事件循环。

Q4: 如何调试事件循环问题?

A: 可以使用以下方法:

// 1. 使用 console.log 跟踪执行顺序
console.log('任务开始');
setTimeout(() => console.log('宏任务'), 0);
Promise.resolve().then(() => console.log('微任务'));
console.log('任务结束');

// 2. 使用 Performance API 监控任务执行时间
performance.mark('taskStart');
// 执行任务
performance.mark('taskEnd');
performance.measure('task', 'taskStart', 'taskEnd');

总结

浏览器事件循环是理解 JavaScript 异步编程的关键。掌握事件循环的工作原理有助于:

  1. 编写高效的异步代码
  2. 避免阻塞用户界面
  3. 理解代码执行顺序
  4. 调试异步相关问题

记住核心原则:同步任务 → 微任务 → 宏任务 → 渲染更新,这样就能正确理解和预测代码的执行顺序。