深入理解JavaScript单线程模型与事件循环机制

作者:逐光

时间:2026-10-26

浏览:434

本文深入解析JavaScript单线程模型及其核心机制——事件循环。详细阐述宏任务与微任务的区别、执行优先级及队列处理逻辑,并通过耗时DOM操作案例,演示如何利用setTimeout将大任务拆分为多个宏任务,从而避免页面卡顿,实现平滑渲染。

文章目录

一、单线程

JavaScript 采用单线程模型运行,这意味着在执行过程中不会存在其他线程与之竞争资源。那么,为何要设计为单线程呢?

我们可以做一个假设:如果 JavaScript 是多线程的,并且有两个线程同时操作同一个 DOM 元素,会发生什么?

function changeValue() {
  const e = document.getElementById("ele1");
  if (e) {
    e.value = "VALUE";
  }
}

function deleteElement() {
  const e = document.getElementById("ele1");
  if (e) {
    e.remove();
  }
}

假设线程 1 执行 changeValue() 函数,逻辑是若元素存在则修改其值;线程 2 执行 deleteElement() 函数,逻辑是若元素存在则将其删除。在多线程环境下,两个函数并发执行:线程 1 先运行,判断元素存在,正准备执行修改值的代码 e.value = "VALUE";;此时线程 2 抢占 CPU,执行 deleteElement() 函数并完整结束,成功删除了该元素。当 CPU 控制权回到线程 1 时,它继续执行剩余的修改代码 e.value = "VALUE";,但由于元素已被线程 2 删除,获取元素失败,导致修改操作报错或无效。

由此可见,在浏览器环境中,无论有多少线程,它们都共享同一个文档对象模型(Document)。对 DOM 进行频繁的多线程操作会引发极大的不稳定性。而采用单线程模型,可以确保对 DOM 的操作是稳定且可预测的。开发者无需担心其他线程抢占资源或执行意外操作,从而避免了并发冲突带来的问题。

由于单线程的限制,JavaScript 一次只能处理一个任务,在该任务执行完毕之前,后续任务必须等待。这一特性至关重要,也是理解后续事件循环机制的基础。

二、事件循环

如上所述,浏览器执行 Javascript 是单线程的,因此同一时间只能执行一个任务。那么,当存在多个待执行任务时,那些尚未执行的任务在哪里等待呢?

为了解决任务等待的问题,浏览器建立了一个任务队列。所有待执行的任务都被放入这个队列中,当需要执行任务时,引擎从队头取出一个任务执行,在此期间其他任务继续等待。当一个任务执行完毕后,再从队列中取出下一个任务执行,如此循环。

一、单线程

然而,除了开发者编写的 Javascript 代码外,浏览器还会产生各种事件,如点击、鼠标移动、键盘输入以及网络请求等。这些事件也需要被执行,并且为了提供流畅的用户体验,它们需要被尽快处理以更新页面。如果将浏览器产生的事件排入普通任务队列的队尾,那么在前面耗时任务执行完成之前,页面将一直处于堵塞状态,导致用户感知到的严重卡顿。

一、单线程

为了解决这一问题,浏览器引入了第二个队列。这个新队列中的任务会被优先执行,为了与前一个队列区分,我们将前一个队列称为宏任务队列(Macrotask Queue),其中的任务称为宏任务;将新加的队列称为微任务队列(Microtask Queue),其中的任务称为微任务。

宏任务队列的执行方式保持不变,即每次只取出一个宏任务执行。但在执行完一个宏任务后,行为发生了变化:引擎不再检查宏任务队列是否为空,而是检查微任务队列。如果微任务队列不为空,则执行其中的一个微任务;当前微任务执行完成后,继续检查微任务队列,若仍不为空,则再执行一个微任务,直到微任务队列为空。当微任务队列为空后,浏览器才会进行页面渲染,然后回到宏任务队列继续执行,如此循环往复。

一、单线程

通过这种模型,浏览器将需要快速响应的 DOM 事件放入微任务队列,以实现快速执行。当微任务队列执行完毕后,再根据需要进行浏览器渲染,用户就能感觉到自己的操作得到了迅速响应。

这种事件执行机制被称为事件循环(Event Loop)。浏览器中的事件和用户代码都在事件循环模型的驱动下执行。

三、事件循环的应用

根据上述事件循环模型,我们得知浏览器的渲染顺序是:执行完一个宏任务及其后所有的微任务之后,再进行页面渲染。为了保证浏览器渲染顺畅,我们不宜让单个宏任务的执行时间过长,也不应让清空微任务队列的过程过于耗时。在一次事件循环中,只执行一个宏任务,因此对于耗时的宏任务,需要将其分解为尽可能小的多个宏任务;而对于微任务,由于机制是清空整个微任务队列,因此在微任务中不应生成新的微任务。毕竟,微任务队列的使命是尽可能优先处理微任务,随后重新渲染浏览器。

宏任务队列和微任务队列都是独立于事件循环存在的。也就是说,在执行 Javascript 代码时,任务队列的添加行为也在同步发生,即使当前正在清空微任务队列。这是为了避免在执行代码期间发生的事件被忽略。由此可知,即使我们分解一个耗时任务,也不能因为微任务会被优先执行而将其分解为多个微任务,因为这会阻塞浏览器的重新渲染。更好的做法是将任务分解为多个宏任务,这样执行一个分解后的宏任务耗时较短,可以尽快触发浏览器的渲染机制。

在浏览器进行渲染之前,会先清空微任务队列,因此,对浏览器 DOM 的修改和更新操作,适合放在微任务中执行。

浏览器的渲染频率大约为每秒 60 次,即约每 16ms 渲染一次。在浏览器渲染页面的过程中,任何任务都无法再对页面进行修改。这意味着,为了保持页面的平滑顺畅,我们的代码中,单个宏任务以及当前微任务队列中的所有微任务,都应在 16ms 内执行完成。否则,就会导致页面卡顿。

四、使用代码来说明

下面将通过一些简单但有效的代码示例,说明事件循环如何影响页面效果。以下代码量很少,建议读者亲手编写并体验。

首先看下面的代码,我定义了一个 foo() 函数,它将一次性向元素中添加 5 万个子元素,并将在页面加载完成后立即执行它。

function foo() {
  const d = document.getElementById("container");
  for (let index = 0; index < 50000; index++) {
    const e = document.createElement("div");
    e.textContent = "NEW";
    d.appendChild(e);
  }
}

可见这是一个耗时的操作。如果你的电脑性能较好,可能体验不到明显的卡顿,可以尝试将循环次数增加到 50 万次以观察效果。

经过一段时间的卡顿后,页面一次性出现了大量子元素。虽然添加元素的目的达到了,但出现前的卡顿体验令人难以接受。根据事件循环原理,我们可以知道,这是因为执行了一个非常耗时的宏任务,导致阻塞了页面的渲染。下面用一张图来说明这一过程。

一、单线程

上图展示了本次事件循环的执行过程。初始时,浏览器将 foo() 放入宏任务队列。从 0ms 开始,宏任务队列中有任务,事件循环取出一个宏任务,即 foo(),执行该任务并添加 5 万个子元素。该执行过程非常耗时,假设需要 2000ms。foo() 执行完毕后,执行微任务,假设清空微任务队列需要 5ms。此时时间来到 2005ms,才开始重新渲染浏览器。经过这一次完整的事件循环,竟然耗时了 2015ms!

为了改善体验,我们期望获得平滑的渲染效果。由于浏览器页面的变化仅在事件循环中的“重新渲染浏览器”这一步发生,因此我们的目标是尽可能快地到达这一步。为此,我们需要将 foo() 分解为多个宏任务。

为什么不能分解成微任务?因为微任务会在宏任务完成后全部执行。假设我们将添加 5 万个元素分解为:宏任务添加 1000 个,微任务添加 49000 个。那么事件循环仍必须执行完添加 1000 个元素的宏任务后,再执行添加 49000 个元素的微任务,最后才能渲染页面。因此,我们必须将任务分解为宏任务。

假设我们将任务分解为 200 个宏任务,每个宏任务添加 250 个元素。在事件循环执行时,任务队列中有 200 个宏任务。取出一个执行,该宏任务仅添加 250 个元素,耗时约 10ms。当前宏任务完成后,清空微任务队列,耗时 5ms,时间来到 15ms,此时即可渲染浏览器。这一次事件循环,在渲染浏览器前仅耗时 15ms!

接着,渲染浏览器后,页面上出现了 250 个元素,事件循环继续从宏任务队列中取出下一个宏任务执行。

一、单线程

如上图所示,接连不断的事件循环使浏览器渲染看起来平滑顺畅。

接下来,我们改造代码,使其分解为多个宏任务。

五、setTimeout()

setTimeout() 函数用于延迟执行一个函数,是我们重点讨论的方法。

你应该很熟悉这个函数的用法:setTimeout() 接收两个参数,第一个是回调函数,第二个是数字,用于指示延迟的时间,单位为毫秒(ms)。

这里主要介绍第二个参数。许多人误以为它是指延迟多少毫秒后执行传入的函数,但其实,它的真正含义是:延迟多少毫秒后,该任务进入宏任务队列!

假设如下代码:

setTimeout(() => {
  console.log("execute setTimeout()");
}, 10);

下面我用一张图说明这段代码的执行过程,图中上方代表时间轴,下方代表宏任务队列。

一、单线程

在 0ms 时,注册 setTimeout 函数,第一个参数中的方法将在 10ms 后加入宏任务队列。此时,宏任务队列中并没有我们代码里的任务。

其他未知的 JavaScript 代码执行了 10ms。

到了 10ms 时,setTimeout 到期,第一个参数中的方法加入宏任务队列。

一、单线程

上图中,10ms 已到,任务加入了宏任务队列。但要注意,此时事件循环可能正在执行一个宏任务,或者正在清空微任务队列,或者正在渲染浏览器。因此,新增加的宏任务不会立即执行,只有当又一次循环到执行宏任务的阶段时,才会从宏任务队列中获取并执行(因为 JS 是单线程的)。假设这段时间耗时了 5ms,情况如下所示。

一、单线程

如上图所示,在 15ms 时,我们才从宏任务队列里取出在 10ms 时放入的宏任务并执行。与代码对比,尽管 setTimeout 的第二个参数是 10ms,但实际却在 15ms 才执行。

当理解了 setTimeout 的原理之后,便可以使用 setTimeout 将一个耗时任务分解成多个宏任务,以充分给予浏览器渲染的时间。

我修改了 foo 函数,如下所示:

function foo() {
  const d = document.getElementById("container");
  const total = 50000;
  const size = 250;
  const chunk = total / size;
  let i = 0;
  setTimeout(function render() {
    for (let index = 0; index < size; index++) {
      const e = document.createElement("div");
      e.textContent = "NEW";
      d.appendChild(e);
    }
    i++;
    if (i < chunk) {
      setTimeout(render, 0);
    }
  }, 0);
}

在 foo 方法中,首先获取了要添加子元素的 DOM 元素,并定义了各种变量。total 表示一共有几个元素要添加,因为电脑性能较差,所以设为 5 万,你可以修改为你喜欢的值;size 是指我们分解后每个宏任务要添加的元素数量;chunk 是指分解后一共有几个宏任务,通过简单计算得到;i 是用于标记执行到了第几个宏任务。

接下来是重点:注册了 setTimeout,在 0ms 后将传入的 render 函数放进宏任务队列。然后这个 foo 函数执行结束,事件循环继续往下执行,清空微任务队列,渲染浏览器。等到下一个事件循环时,才会从宏任务队列里拿出由 setTimeout 放入的 render 函数(如果是第一个的话)并执行。

一、单线程

如上图所示,当前的事件循环正在执行 foo() 函数,此时 render() 在宏任务队列中等待。

一、单线程

假设这次事件循环需要的时间是 10ms,那么到了 10ms 后,事件循环开始了新的一轮,从宏任务队列里获取一个新的宏任务,获取到了 render() 任务并执行。来看 render() 函数里的代码:

function render() {
  for (let index = 0; index < size; index++) {
    const e = document.createElement("div");
    e.textContent = "NEW";
    d.appendChild(e);
  }
  i++;
  if (i < chunk) {
    setTimeout(render, 0);
  }
}

代码执行了 for 循环,添加 size 次数的子元素。在示例中 size 定义为 250,即添加 250 个子元素,数量不多,添加过程非常快。在执行完 for 循环后,将外部的 i 变量加 1。我们将使用 i 判断所有的子元素是否添加完毕,如果是则结束函数;如果不是,则再次通过 setTimeout 注册一个 render() 函数,然后结束当前函数。

一、单线程

如上图,在 15ms 的时候,render() 函数添加了 250 个子元素,然后使用 setTimeout 注册了一个新的宏任务,在 0ms 后进入宏任务队列。注意此时,尽管 render() 函数添加了 250 个子元素,但是事件循环还没有到渲染浏览器这一步,所以页面没有出现 250 个新元素。

事件循环继续执行:

一、单线程

到了 15ms,执行微任务队列,假设需要执行 5ms。到了 20ms,清空了微任务队列,开始渲染浏览器,假设渲染需要 5ms,界面上出现了 250 个新元素。这次,只花费了 15ms,就让页面上渲染出了元素,而不是一开始那样卡顿了 2000ms 后才渲染页面!

接下来的事件循环就是一直重复从 10ms 开始到 25ms 的动作了,直到所有子元素都渲染完毕。

通过改造后的 foo() 函数,我们将卡顿的页面优化成了观感良好且顺畅的页面。从新旧 foo() 函数的代码量来看,代码数量的多少跟页面顺畅与否没有太大关系。重点是理解事件循环中发生的事。

六、思考:劣质的优化

如果我将 foo() 函数改写成如下的形式,会怎么样?请亲自试一试,思考执行的事件循环和宏任务队列中发生了什么。

function foo() {
  const d = document.getElementById("container");
  const size = 1000;
  const chunk = 50000 / size;
  for (let index = 0; index < chunk; index++) {
    setTimeout(() => {
      const e = document.createElement("div");
      e.textContent = "NEW";
      d.appendChild(e);
    }, 0);
  }
}

到此这篇关于 JavaScript 单线程和事件循环的文章就介绍到这了,更多相关 JS 单线程内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章