JavaScript 事件循环(Event Loop)详解

115 阅读6分钟

JavaScript 单线程特性

JavaScript 是一门单线程的语言,这意味着同一时刻只能执行一个任务。让同步任务尽快执行,进行渲染页面,优先响应用户的交互。并且为了出于实际的考虑,避免了多线程编程中的复杂同步问题,如死锁、竞态条件等

JavaScript 中的任务被分为两大类:

  • 同步任务(Synchronous Tasks):立即执行的代码,按照顺序依次执行
  • 异步任务(Asynchronous Tasks):不会立即执行,而是被放入任务队列中等待执行

同步任务与页面渲染

在 JavaScript 的执行过程中,所有同步任务会被优先、尽快地执行完毕。只有当这些同步任务全部执行完后,浏览器才会进行页面的渲染工作,包括"重绘"(repaint)和"重排"(reflow)。

为什么要这样做?

因为 JavaScript 是单线程的,如果在执行同步任务时就去渲染页面,可能会导致页面渲染多次,导致效率低下。浏览器会等到所有同步任务执行完毕后,再统一进行一次页面渲染,这样可以提升性能和用户体验。

事件循环机制

JavaScript有宏任务队列微任务队列,两个队列都遵循先进先出(FIFO)的原则。每次事件循环时进行以下步骤:

  1. 先执行一个宏任务(包括同步代码)
  2. 然后清空所有微任务队列
  3. 微任务队列为空后,浏览器进行渲染
  4. 最后再进入下一个宏任务

如下图演示:

事件循环示意图

任务类型

宏任务队列(Macro Task): 可以理解为耗时性的任务,其包括以下任务: setTimeoutsetIntervalsetImmediate(Node.js)、I/O 操作UI renderingscript(整体代码)

微任务(Micro Task): 可以理解为紧急的或者优先的任务,其包括以下任务:Promise.then()catch()finally()MutationObserverqueueMicrotask()process.nextTick()(Node.js)。

不多说,上代码!

基本事件循环代码

console.log("script start");

// 异步任务,也是宏任务
setTimeout(() => {
  console.log("setTimeout");
}, 0); // 虽然为0,但是不会立即执行

// then 异步 微任务
Promise.resolve().then(() => {
  console.log("promise");
});
console.log("script end");

一个面试的坑就是:Promise.then()才是微任务,如果你回答了Promise,面试官可能会说:回去等通知吧

小知识: Promise.resolve()本身是同步代码,它会立即返回一个Promise对象,但是,通过thencatchfinally等方法注册的回调函数是异步执行的(属于微任务)。

代码执行的输出顺序script startscript endpromise页面渲染setTimeout

控制台输出

微任务

MutationObserver

MutationObserver 是一个微任务,用于监听 DOM 变化:

const target = document.createElement("div");
document.body.appendChild(target);
const observer = new MutationObserver(() => {
  console.log("微任务:MutationObserver");
});
// 监听target 节点的变化
observer.observe(target, {
  attributes: true,
});

target.setAttribute("data-set", "123");
target.setAttribute("style", "background-color:green");

image.png

批量 DOM 操作

const target = document.createElement("div");
document.body.appendChild(target);
const observer = new MutationObserver(() => {
  console.log("微任务:MutationObserver");
});
// 监听target 节点的变化
observer.observe(target, {
  attributes: true,
  childList: true,
});
target.setAttribute("data-set", "123");
target.appendChild(document.createElement("div"));
target.setAttribute("style", "background-color:green");

添加 childList: truetarget.appendChild(document.createElement("div")) 后,我们发现控制台仍然只输出了一个。这说明所有 DOM 变化被合并成一次微任务回调,并且 DOM 的改变在页面渲染前,我们就能够拿到 DOM 有什么改变。

image.png

Node.js 环境下的微任务

process.nextTick 优先级

在 Node.js 环境中,process.nextTick 的优先级比 Promise 更高:

Promise.resolve().then(() => {
  console.log('promise1');
  process.nextTick(() => {
    console.log('nextTick in promise');
  });
});

process.nextTick(() => {
  console.log('nextTick1');
});

console.log('end');

输出顺序

end
nextTick1
promise1
nextTick in promise

在 promise1 里注册的 process.nextTick会立即插队到下一个 nextTick 队列,所以 nextTick in promise 也会在下一个微任务前执行

混合任务

console.log("Start");
// node 微任务
process.nextTick(() => {
  console.log("Process Next Tick");
});
// 微任务
Promise.resolve().then(() => {
  console.log("Promise Resolved");
});
// 宏任务
setTimeout(() => {
  console.log("haha");
  Promise.resolve().then(() => {
    console.log("inner Promise");
  });
}, 0);
console.log("end");

输出顺序

Start
end
Process Next Tick
Promise Resolved
开始新的一轮轮询
haha
inner Promise

浏览器渲染机制

queueMicrotask 与渲染

console.log("同步");
// 批量更新
// dom 树, cssom,layout 树 图层合并
queueMicrotask(() => {
  // DOM 更新了,但不是渲染完了
  // 一个元素的高度 offsetHeight scrollTop getBoundingClientRect()
  // 立即重绘重排 耗性能
  console.log("微任务:queueMicrotask");
});
console.log("同步结束");

执行顺序

  1. 输出"同步"
  2. 注册一个微任务(不会立刻执行)
  3. 输出"同步结束"
  4. 当前宏任务(同步代码)全部执行完毕后,立刻执行微任务,输出"微任务:queueMicrotask"
  5. 微任务队列清空后,浏览器才会进行页面渲染

批量渲染优化

浏览器会把 DOM 树、CSSOM、布局树等的更新合并起来,批量处理,减少渲染次数,提高性能。怎么理解?浏览器在执行 JavaScript 时,不会因为DOM或CSS每次的修改就立刻去渲染页面,而是把这些修改"暂存"起来,等到合适的时机(比如本轮宏任务和所有微任务都执行完)再统一进行页面的渲染(重排和重绘)

事件循环总结

执行顺序规则

  1. 同步代码:立即执行
  2. 微任务:在当前宏任务执行完毕后立即执行
  3. 宏任务:等待下一轮事件循环
  4. 页面渲染:在所有微任务执行完毕后进行

优先级

  • 同步任务 > 微任务 > 页面渲染 > 宏任务

实际开发中的应用场景

性能优化

// 使用微任务进行批量DOM操作
function batchDOMUpdate() {
  const elements = document.querySelectorAll(".item");

  // 使用微任务确保DOM操作在下一个渲染周期前完成
  queueMicrotask(() => {
    elements.forEach((el) => {
      el.classList.add("updated");
    });
  });
}

异步操作控制

// 确保异步操作的执行顺序
async function sequentialOperations() {
  console.log("开始操作");

  // 使用 await 确保顺序执行
  await new Promise((resolve) => {
    setTimeout(() => {
      console.log("操作1完成");
      resolve();
    }, 1000);
  });

  await new Promise((resolve) => {
    setTimeout(() => {
      console.log("操作2完成");
      resolve();
    }, 1000);
  });

  console.log("所有操作完成");
}

避免阻塞 UI

// 将耗时操作分解为多个微任务
function processLargeData(data) {
  const chunkSize = 1000;
  let index = 0;

  function processChunk() {
    const chunk = data.slice(index, index + chunkSize);

    // 处理数据块
    chunk.forEach((item) => {
      // 处理逻辑
    });

    index += chunkSize;

    if (index < data.length) {
      // 使用微任务继续处理下一块
      queueMicrotask(processChunk);
    }
  }

  processChunk();
}

总结:JavaScript 事件循环是理解异步编程的核心概念。通过掌握同步任务、微任务、宏任务的执行顺序,以及浏览器渲染机制,可以编写出更加高效、响应迅速的应用程序。在实际开发中,合理利用事件循环机制可以显著提升用户体验和程序性能。