JavaScript 的事件循环机制

212 阅读10分钟

最近面试经常问的问题,以及一些引申出来的问题,简单介绍一下JavaScript 的单线程特性、Web Workers 的概念、事件循环的工作原理以及宏任务和微任务的区别

1. JavaScript 为什么是单线程的?

对于线程的了解,可能对于我们来说,普遍都知道 JavaScript 是一个单线程的程序,那为什么不像 Java 一样,将其设计为多线程的语言呢? 起初,JavaScript 主要用于网页浏览器中,负责处理用户交互和操作文档对象模型(DOM)。为了提供流畅的用户体验,用户界面更新需要是连续且可预测的。单线程模型帮助保持了这种一致性,避免了多线程可能导致的界面闪烁或不连贯。还有就是在浏览器环境中,如果多个线程同时操作DOM,可能会引发不可预测的结果(比如一个线程在添加元素时,另一个线程可能在删除这个元素,导致数据不一致或异常行为。),单线程确保了对DOM的操作是有序且安全的。 但是,由于这种单线程的模式,也就决定了 JavaScript 的任务需要一个接着一个的执行,在这种排队的情况下,如果上一个任务非常的消耗时间,就会对后面的任务产生阻塞,影响用户的体验。为了解决这种情况,虽然 HTML5 提出了 Web Worker 的解决方案但并没有有效解决,JavaScript 引入了异步处理机制。

2. Web Worker

2.1 Web Worker 是什么?

Web Worker 是一种异步的 JavaScript 线程,它会在当前 JavaScript 的运行主线程中,新开辟一个额外的线程来载入和运行特定的 JavaScript 文件,从而提高应用程序的性能。Web Worker 可以独立于主线程运行,因此可以与主线程并行执行,从而避免了主线程被阻塞的情况。Web Worker 可以与主线程进行通信,通过消息传递来传递数据(如 postMessage 和 onMessage 事件)。但在 Web Worker 中是无法使用document、window、parent这些对象的,但是可以使用可以navigator对象和location对象。而且在在 Web Worker 中也是不能操作DOM的,对于需要操作DOM的任务都要托付给JavaScript主线程来运行。所以尽管引入HTML5引入了Web Worker,但仍然没有改变JavaScript单线程的本质。

2.2 Web Worker 的使用方式

以下只是简单的使用方式,更多详细的使用方式可以参考MDN文档或者一篇很好的博客文章

// 在主线程中
if (window.Worker) {
  const myWorker = new Worker('worker.js');
  // 通过 postMessage 方法向 Worker 线程发送消息,可以发送任何序列化后能跨线程传输的数据类型
  myWorker.postMessage([firstValue, secondValue]);
  // 通过监听 onmessage 事件来接收 Worker 线程发回的消息。
  myWorker.onmessage = function(event) {
    console.log('从 Worker 收到消息:', event.data);
  };
} else {
  console.log('Web Worker 不被支持');
}

// 在 worker.js 中
self.onmessage = function(event) {
  const data = event.data;
  // 处理数据,比如进行复杂计算
  const result = doHeavyComputation(data);

  // 将结果发送回主线程
  self.postMessage(result);
};

function doHeavyComputation(data) {
  // 这里执行耗时操作
}

3. JavaScript 的事件循环机制

3.1 执行栈和任务队列

3.1.1 对于栈、堆、队列的理解

(1)栈(Stack):栈是一种遵循后进先出(LIFO, Last In First Out)原则的线性数据结构。就像现实中的书堆一样,只能从顶部放入(压栈,push)和取出(弹栈,pop)元素。栈的顶部是进行操作的地方,底部不允许操作。在JavaScript中,栈空间相对较小且固定大小,用于存储基本数据类型(这些类型包括String(字符串)、Number(数值)、Boolean(布尔值)、Null、Undefined、Symbol(ES6引入)和BigInt(ES10引入))和函数调用的执行上下文。 (2)堆(Heap):堆是一种动态分配内存的区域,存储的是指向堆内存中实际数据的引用(内存地址)。引用数据类型主要包括Object(对象)、Array(数组)、Function(函数)以及RegExp(正则表达式)等。堆的大小是可变的,并且不限制存储的大小。堆的存储空间较大,可以存储大量的数据,并且可以进行动态的分配和释放。 注:简单来说,基本数据类型因为大小固定且不可变,直接存储在栈中,而引用数据类型由于其大小可变且可变性,其实际内容存储在堆中,变量中仅存储对该内容的引用。 (3)队列(Queue):队列遵循先进先出(FIFO, First In First Out)原则,类似于现实生活中的排队。新元素从队尾加入(enqueue),旧元素从队首移除(dequeue)。

3.1.2 栈内存与堆内存

JavaScript 中的内存分为 堆内存 和 栈内存,基本的存储方式如下图:

1_1.webp 对于栈内存与堆内存的比较如下:

  1. 存储内容 栈内存:存储基本数据类型(如数字、字符串、布尔值等)的实际值。 堆内存:存储引用类型(如对象、数组)的地址,实际的数据存储在这些地址指向的内存空间中。
  2. 内存管理 栈内存:自动管理,速度快,分配和释放效率高,空间有限且固定大小,不易造成内存碎片。 堆内存:需要手动管理(在某些语言中),分配和释放较慢,空间大小不固定,可动态扩展,易产生内存碎片。
  3. 访问速度 栈内存:从高地址向低地址增长,访问速度非常快。 堆内存:从低地址向高地址扩展,访问速度较慢,因为需要通过指针间接访问。
  4. 存储特性 栈内存:存储的数据生命周期与作用域紧密相关,作用域结束时自动释放。 堆内存:数据生命周期取决于程序员何时释放(在自动垃圾回收语言中,则依赖于垃圾回收机制),可能导致内存泄漏问题。

3.1.3 执行栈

执行栈(也称为调用栈或调用堆栈)是JavaScript执行代码时维护的一个数据结构,它记录了当前执行的所有函数调用的顺序。每当一个函数被调用,它就会被添加到栈顶;当函数执行完毕,它就会从栈顶被移除,并且只有当当前执行的函数完成并从栈顶弹出后,下一个函数才能开始执行。JavaScript是基于单线程执行的,这意味着任何时候只有一个函数在执行栈的顶部执行。

3.1.4 任务队列

任务队列是一种数据结构,用于存储待处理的异步任务。当执行栈为空时,JS引擎就会检查任务队列,如果任务队列不为空,便会将第一个任务压入执行栈中运行。

3.2 事件循环的描述

JavaScript 的事件循环机制可以分为3步:

  1. 所有同步任务都在主线程上执行,当遇到同步任务时,会被压入执行栈中进行执行,直到执行栈中所有同步任务执行完毕,主线程才会继续往下执行。
  2. 当遇到异步任务时,异步任务会单独放置在一个异步处理模块,当异步任务有了运行结果后,就会将该函数移入任务队列中。
  3. 当执行栈中的所有同步任务执行完毕后,就会读取任务队列中的任务,然后将任务队列中的第一个任务压入执行栈中运行。

通过下面的代码以及执行,可以更好的了解事件的循环机制(原文地址):

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

执行的大致过程如下:

gif14.1.gif

4 宏任务和微任务

4.1 常见的宏任务与微任务

4.1.1 宏任务(Macro Tasks)

  1. Script:整个JavaScript脚本的执行就是一个宏任务。
  2. setTimeout / setInterval:使用这两个函数设置的延迟执行的函数会在指定时间后作为宏任务执行。
  3. setImmediate(Node.js环境):在Node.js中,setImmediate注册的回调会在当前事件循环的末尾执行,作为宏任务。
  4. I/O操作:如文件读写操作完成后的回调。
  5. UI渲染:DOM操作导致的重新渲染。
  6. 事件处理:如点击、加载等事件的回调(虽然事件监听本身是同步的,但事件处理回调通常作为宏任务处理)。
  7. requestAnimationFrame:用于动画的回调,在下一次浏览器重绘之前执行。

4.1.2 微任务(Micro Tasks)

  1. Promise:.then()、.catch()、.finally()等Promise的回调函数。
  2. process.nextTick(Node.js环境):在Node.js中,用于将回调函数排在当前事件循环的微任务队列的末尾。
  3. MutationObserver:观察DOM树变化的回调。
  4. queueMicrotask(现代浏览器环境):直接将一个函数排入微任务队列的API。
  5. async/await:虽然async/await基于Promise,但它们的最终回调也是作为微任务处理的。

当JavaScript执行时,它会先执行当前所有同步代码,在同步任务执行结束后,执行所有已排队的微任务。微任务队列清空后,才开始执行宏任务。下面是典型的示例:

示例1:

console.log("start");

setTimeout(() => {
    console.log("children2")
    Promise.resolve().then(() => {
        console.log("children3")
    })
}, 0);

new Promise(function(resolve, reject) {
    console.log("children4");
    setTimeout(function() {
        console.log("children5");
        resolve("children6");
    }, 0);
}).then(res => {
    console.log("children7");
    setTimeout(() => {
        console.log(res);
    }, 0);
});

执行顺序:

同步代码执行

  1. console.log("start");:

    • 立即执行,输出 start
  2. setTimeout(() => { ... }, 0);:

    • 设置一个宏任务,在 0 毫秒后执行回调。
    • 回调内包含 console.log("children2") 和一个 Promise.then 回调。
  3. new Promise(function(resolve, reject) { ... }).then(res => { ... });:

    • 创建并立即执行 Promise 构造函数中的代码。
    • console.log("children4"); 立即执行,输出 children4
    • 设置一个宏任务,在 0 毫秒后执行回调,回调内包含 console.log("children5")resolve("children6")

初始同步代码执行完毕,输出顺序:

start
children4

执行宏任务队列中的任务

  1. 执行第一个 setTimeout 回调:

    • 输出 children2
    • 设置一个微任务(Promise.then 回调),输出 children3
  2. 执行第二个 setTimeout 回调(来自 Promise 构造函数中的代码):

    • 输出 children5
    • 执行 resolve("children6"),触发 Promise.then 回调,将其放入微任务队列中。

执行微任务队列中的任务

  1. 执行 Promise.then 回调:

    • 输出 children7
    • 设置一个宏任务,在 0 毫秒后执行回调,输出 children6

最后,执行宏任务队列中剩余的任务

  1. 执行 Promise.then 中的 setTimeout 回调:

    • 输出 children6

最终输出顺序:

start
children4
children2
children5
children3
children7
children6

示例2:

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}

async function async2() {
    new Promise(function (resolve) {
        console.log('promise1');
        resolve();
    }).then(function () {
        console.log('promise2');
    });
}

console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0);

async1();

new Promise(function (resolve) {
    console.log('promise3');
    resolve();
}).then(function () {
    console.log('promise4');
});

console.log('script end');

执行顺序:

同步代码执行

  1. console.log('script start');:

    • 立即执行,输出 script start
  2. setTimeout(() => { ... }, 0);:

    • 设置一个宏任务,在 0 毫秒后执行回调。
    • 回调内包含 console.log("setTimeout")
  3. async1();:

    • 调用 async1 函数。
    • 进入 async1 函数,输出async1 start
  4. await async2():

    • 调用 async2 函数。
    • 进入 async2 函数,输出promise1
    • 创建的 Promise 会立即执行,设置一个微任务,在 then 中输出 promise2
    • 由于 awaitasync1 暂停执行,等待 async2 完成。
  5. new Promise(function(resolve) { ... }).then(function () => { ... });:

    • 执行另一个 Promise,输出promise3
    • 创建的 Promise 会立即执行,设置一个微任务,在 then 中输出 promise4
  6. console.log('script end');:

    • 立即执行,输出 script end

处理微任务队列

  1. 执行async2的微任务 then 的回调 :
    • console.log('promise2');,输出promise2
  2. await async2() 完成后,继续执行 async1
    • console.log('async1 end');,输出async1 end
  3. 执行来自 Promise 创建的 promise3的微任务 then 回调:
    • console.log('promise4');,输出promise4

处理宏任务队列

  1. 执行宏任务 setTimeout 回调:

    • console.log('setTimeout');,输出setTimeout

最终输出顺序

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout