MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript事件循环详解与异步执行

2022-10-124.3k 阅读

JavaScript 中的同步与异步

在深入了解事件循环之前,我们先来明确 JavaScript 编程中的同步和异步概念。

同步编程

JavaScript 是单线程语言,这意味着它在同一时间只能执行一个任务。在同步编程模型中,代码按照顺序依次执行,前一个任务完成后才会执行下一个任务。例如:

console.log('任务1');
console.log('任务2');
console.log('任务3');

上述代码会依次输出 “任务1”“任务2”“任务3”,因为每个 console.log 语句都是一个同步任务,前一个语句执行完毕后,才会执行下一个语句。

如果遇到一个耗时较长的同步任务,例如下面的循环:

console.log('开始');
for (let i = 0; i < 1000000000; i++) {
    // 这里只是一个简单的空循环,模拟耗时操作
}
console.log('结束');

在执行这个循环的过程中,JavaScript 线程会被阻塞,页面可能会出现卡顿,因为在循环结束之前,后续的代码(比如 console.log('结束'))无法执行,用户交互等其他任务也会被暂停。

异步编程

为了避免因长时间运行的任务导致阻塞,JavaScript 引入了异步编程。异步任务不会阻塞主线程,它们会在后台执行,当任务完成时,会通过特定的机制通知主线程。

例如,使用 setTimeout 函数来创建一个异步任务:

console.log('开始');
setTimeout(() => {
    console.log('异步任务');
}, 1000);
console.log('结束');

在这个例子中,setTimeout 函数会在 1 秒后执行回调函数。但是,setTimeout 并不会阻塞主线程。代码首先输出 “开始”,然后遇到 setTimeout,JavaScript 会将这个异步任务交给浏览器的 Web API(后面会详细介绍)处理,主线程继续执行后续代码,输出 “结束”。1 秒后,异步任务完成,相应的回调函数被放入一个队列中等待主线程执行。

执行栈、任务队列与 Web API

要理解 JavaScript 的事件循环机制,我们需要先了解三个重要的概念:执行栈(Execution Stack)、任务队列(Task Queue)和 Web API。

执行栈

执行栈,也称为调用栈(Call Stack),是一种数据结构,用于存储函数调用。当 JavaScript 引擎开始执行代码时,它会创建一个全局执行上下文并将其压入执行栈。每当一个函数被调用时,会为该函数创建一个新的执行上下文并压入栈顶。当函数执行完毕,其对应的执行上下文会从栈顶弹出,控制权返回给调用该函数的代码。

例如,有如下代码:

function add(a, b) {
    return a + b;
}

function multiply(a, b) {
    let result = add(a, b);
    return result * 2;
}

let finalResult = multiply(2, 3);
console.log(finalResult);

在这段代码执行时,首先会将全局执行上下文压入执行栈。当调用 multiply 函数时,multiply 函数的执行上下文被压入栈顶。在 multiply 函数内部调用 add 函数,add 函数的执行上下文又被压入栈顶。add 函数执行完毕返回结果后,其执行上下文从栈顶弹出。multiply 函数继续执行并返回结果,multiply 函数的执行上下文也从栈顶弹出。最后,全局代码继续执行 console.log,全局执行上下文在代码执行完毕后也从栈顶弹出。

任务队列

任务队列,也称为消息队列(Message Queue),是一个存储待执行任务(回调函数)的队列。异步任务完成后(例如 setTimeout 的回调函数、AJAX 请求完成后的回调函数等),其对应的回调函数会被放入任务队列中。任务队列中的任务按照先进先出(FIFO)的顺序排列。

需要注意的是,任务队列有多种类型,我们这里先介绍的是宏任务队列(Macro - Task Queue)。像 setTimeoutsetIntervalscript(整体代码)、I/OUI rendering 等产生的任务都属于宏任务。

Web API

Web API 是由浏览器提供的一些接口,JavaScript 可以通过这些接口与浏览器进行交互,执行一些异步操作。例如 setTimeoutsetIntervalfetch(用于 AJAX 请求)等。当 JavaScript 代码调用这些函数时,实际上是将任务交给了 Web API 来处理。Web API 在后台执行任务,当任务完成时,会将相应的回调函数放入任务队列中。

setTimeout 为例,当 JavaScript 引擎遇到 setTimeout 函数调用时,会将这个任务传递给浏览器的定时器模块(这是 Web API 的一部分)。定时器模块开始计时,1 秒后(假设设置的延迟时间为 1 秒),会将 setTimeout 的回调函数放入任务队列中。

事件循环机制

事件循环的基本概念

事件循环(Event Loop)是 JavaScript 实现异步的核心机制。它是一个持续运行的循环,不断检查执行栈和任务队列。当执行栈为空时,事件循环会从任务队列中取出一个任务(回调函数),将其压入执行栈中执行。执行完该任务后,执行栈再次为空,事件循环又会从任务队列中取出下一个任务,如此循环往复。

可以用下面的伪代码来描述事件循环的基本逻辑:

while (true) {
    if (执行栈为空) {
        if (任务队列不为空) {
            从任务队列中取出一个任务并压入执行栈;
        }
    }
    执行执行栈中的当前任务;
}

事件循环的详细过程

  1. 初始化:JavaScript 引擎启动,创建全局执行上下文并压入执行栈,开始执行全局代码。
  2. 同步任务执行:在执行全局代码过程中,遇到同步任务会立即执行,并将函数调用产生的执行上下文压入执行栈,执行完毕后弹出。
  3. 异步任务处理:遇到异步任务时,例如 setTimeoutfetch,JavaScript 引擎会将其交给 Web API 处理,然后继续执行后续的同步代码。
  4. 任务队列填充:当 Web API 完成异步任务后,会将相应的回调函数放入任务队列中。
  5. 事件循环迭代:当执行栈为空时,事件循环开始工作。它会检查任务队列,如果任务队列中有任务,就取出队列头部的任务(宏任务),将其压入执行栈并执行。执行完毕后,执行栈再次为空,事件循环继续检查任务队列,重复上述过程。

例如,有如下代码:

console.log('全局任务1');

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

console.log('全局任务2');

在这段代码执行过程中:

  • 首先输出 “全局任务1”,这是同步任务。
  • 遇到 setTimeout,将其交给 Web API 处理,setTimeout 并不会立即执行回调函数,而是在 0 毫秒(这里 0 毫秒只是表示尽快执行,但实际上有一定的最小延迟)后将回调函数放入任务队列。
  • 继续输出 “全局任务2”。
  • 此时全局同步代码执行完毕,执行栈为空。事件循环开始工作,从任务队列中取出 setTimeout 的回调函数并压入执行栈,输出 “setTimeout 任务”。

宏任务与微任务

在 JavaScript 中,任务队列实际上分为宏任务队列(Macro - Task Queue)和微任务队列(Micro - Task Queue)。

宏任务:前面提到的 setTimeoutsetIntervalscript(整体代码)、I/OUI rendering 等产生的任务都属于宏任务。每次事件循环从宏任务队列中取出一个宏任务执行。

微任务:常见的微任务包括 Promise.thenMutationObserverprocess.nextTick(在 Node.js 环境中)等。微任务的特点是在当前宏任务执行结束后,下一个宏任务执行之前执行。也就是说,当一个宏任务执行完毕,执行栈为空时,事件循环会先检查微任务队列。如果微任务队列中有任务,会依次执行微任务队列中的所有任务,直到微任务队列为空,然后再从宏任务队列中取出下一个宏任务执行。

例如,下面的代码展示了宏任务和微任务的执行顺序:

console.log('全局任务1');

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

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

console.log('全局任务2');

执行结果为:

  1. 输出 “全局任务1”。
  2. 输出 “全局任务2”。
  3. 执行 Promise.resolve().then 的回调函数,输出 “Promise.then 微任务”,因为微任务在当前宏任务(这里是全局代码这个宏任务)执行结束后,下一个宏任务(setTimeout 的任务)执行之前执行。
  4. 执行 setTimeout 的回调函数,输出 “setTimeout 宏任务”。

异步执行的应用场景

AJAX 请求

AJAX(Asynchronous JavaScript and XML)是一种在不重新加载整个页面的情况下,与服务器进行数据交换的技术。在 JavaScript 中,使用 fetchXMLHttpRequest 对象来发起 AJAX 请求,这些操作都是异步的。

例如,使用 fetch 来获取数据:

fetch('https://example.com/api/data')
   .then(response => response.json())
   .then(data => {
        console.log(data);
    })
   .catch(error => {
        console.error('请求出错:', error);
    });
console.log('AJAX 请求已发起,继续执行其他代码');

在这段代码中,fetch 发起一个异步请求到指定的 URL。在等待服务器响应的过程中,主线程不会被阻塞,会继续执行 console.log('AJAX 请求已发起,继续执行其他代码')。当服务器响应返回后,fetch 返回的 Promise 被解决(resolved),then 回调函数被放入微任务队列。当前宏任务(全局代码)执行完毕后,事件循环会执行微任务队列中的 then 回调函数,处理服务器返回的数据。如果请求出错,catch 回调函数会被执行。

处理用户交互

在浏览器环境中,处理用户交互(如点击按钮、滚动页面等)通常是异步的。例如,为按钮添加点击事件监听器:

const button = document.getElementById('myButton');
button.addEventListener('click', () => {
    console.log('按钮被点击了');
});
console.log('等待按钮点击');

当用户点击按钮时,点击事件的回调函数会被放入任务队列(这里是宏任务队列)。主线程继续执行 console.log('等待按钮点击')。当执行栈为空时,事件循环会从任务队列中取出点击事件的回调函数并执行,输出 “按钮被点击了”。

动画与定时器

在实现动画效果或定时执行任务时,setTimeoutsetInterval 经常被使用。

例如,使用 setInterval 实现一个简单的计数器:

let count = 0;
const intervalId = setInterval(() => {
    count++;
    console.log('计数器:', count);
    if (count === 5) {
        clearInterval(intervalId);
    }
}, 1000);
console.log('定时器已启动');

在这个例子中,setInterval 会每隔 1 秒将回调函数放入宏任务队列。主线程继续执行 console.log('定时器已启动')。每次事件循环从宏任务队列中取出 setInterval 的回调函数执行,更新计数器并输出。当计数器达到 5 时,使用 clearInterval 清除定时器,停止回调函数继续放入任务队列。

深入理解事件循环的细节

事件循环与页面渲染

在浏览器环境中,事件循环与页面渲染密切相关。通常情况下,浏览器会在宏任务执行完毕后,并且在执行下一个宏任务之前进行页面渲染。

例如,有如下代码:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF - 8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>事件循环与页面渲染</title>
</head>

<body>
    <div id="box" style="width: 100px; height: 100px; background - color: red;"></div>
    <script>
        const box = document.getElementById('box');
        setTimeout(() => {
            box.style.backgroundColor = 'blue';
        }, 0);
        console.log('开始');
    </script>
</body>

</html>

在这段代码中,setTimeout 的回调函数会在宏任务队列中等待。全局代码(宏任务)执行完毕后,浏览器会先进行页面渲染(此时 box 是红色)。然后,事件循环从宏任务队列中取出 setTimeout 的回调函数执行,将 box 的背景颜色改为蓝色。如果没有这个渲染时机的控制,页面可能会出现闪烁等不稳定的情况。

执行栈深度限制

执行栈有一定的深度限制。当函数调用层级过深,超过了执行栈的最大深度时,会抛出 “栈溢出”(Stack Overflow)错误。

例如,下面的递归函数会导致栈溢出:

function recursiveFunction() {
    recursiveFunction();
}
recursiveFunction();

在这个例子中,recursiveFunction 不断调用自身,执行上下文不断被压入执行栈,最终超过执行栈的最大深度,抛出错误。

优化异步代码与事件循环

  1. 减少不必要的异步任务:虽然异步可以避免阻塞,但过多的异步任务会增加任务队列的负担,影响事件循环的效率。例如,尽量合并一些可以同步执行的操作,避免频繁创建微任务或宏任务。
  2. 合理使用微任务和宏任务:根据业务需求,选择合适的任务类型。如果需要在当前操作结束后尽快执行一些逻辑,使用微任务;如果可以稍后执行,使用宏任务。例如,在处理 DOM 变化时,如果需要立即响应 DOM 变化后的操作,可以使用 MutationObserver(微任务);如果是一些相对不那么紧急的操作,可以使用 setTimeout(宏任务)。
  3. 避免长时间运行的同步任务:长时间运行的同步任务会阻塞主线程,导致事件循环无法正常工作,页面出现卡顿。可以将一些耗时操作拆分成多个小任务,通过异步方式执行。例如,使用 requestIdleCallback 在浏览器空闲时间执行一些低优先级的任务。

事件循环在 Node.js 中的实现

Node.js 事件循环概述

Node.js 同样基于 V8 引擎,也采用了事件循环机制来处理异步操作。不过,Node.js 的事件循环与浏览器中的事件循环在细节上有一些不同。

Node.js 的事件循环有 6 个阶段,每个阶段都有一个 FIFO 队列来执行回调函数。这 6 个阶段分别是:

  1. timers:这个阶段执行 setTimeoutsetInterval 设定的回调函数。
  2. I/O callbacks:执行几乎所有的回调函数,除了 close 事件的回调函数、setTimeoutsetInterval 设定的回调函数以及 process.nextTick 的回调函数。
  3. idle, prepare:仅在内部使用,一般开发者无需关心。
  4. poll:这个阶段主要用于等待新的 I/O 事件,Node.js 会在此阶段阻塞等待 I/O 操作完成。当有新的 I/O 事件时,相应的回调函数会被加入队列并执行。同时,如果 setTimeoutsetInterval 设定的时间到了,也会在此阶段执行相应的回调函数。
  5. check:执行 setImmediate 设定的回调函数。
  6. close callbacks:执行 sockethandle 对象的 close 事件的回调函数。

示例代码分析

setTimeout(() => {
    console.log('setTimeout 回调');
}, 0);

setImmediate(() => {
    console.log('setImmediate 回调');
});

console.log('开始');

在这段代码中,setTimeout 的回调函数会被放入 timers 阶段的队列,setImmediate 的回调函数会被放入 check 阶段的队列。全局代码(宏任务)首先执行,输出 “开始”。然后事件循环进入 timers 阶段,执行 setTimeout 的回调函数,输出 “setTimeout 回调”。接着事件循环进入 check 阶段,执行 setImmediate 的回调函数,输出 “setImmediate 回调”。

如果将代码改为:

const fs = require('fs');

fs.readFile('test.txt', 'utf8', (err, data) => {
    setImmediate(() => {
        console.log('setImmediate 回调');
    });
    setTimeout(() => {
        console.log('setTimeout 回调');
    }, 0);
});

console.log('开始');

在这个例子中,fs.readFile 是一个异步 I/O 操作,其回调函数会在 I/O callbacks 阶段执行。当 fs.readFile 操作完成后,进入 I/O callbacks 阶段执行其回调函数。在回调函数中,setImmediate 的回调函数会被放入 check 阶段的队列,setTimeout 的回调函数会被放入 timers 阶段的队列。全局代码先输出 “开始”,然后执行 fs.readFile 的回调函数。由于 setImmediate 会在 check 阶段执行,而 setTimeout 会在 timers 阶段执行,且 check 阶段在 timers 阶段之后,所以先输出 “setImmediate 回调”,再输出 “setTimeout 回调”。

Node.js 中的微任务

Node.js 同样支持微任务,如 Promise.thenprocess.nextTickprocess.nextTick 是 Node.js 特有的微任务,它的优先级比 Promise.then 更高。当一个宏任务执行完毕后,会先执行 process.nextTick 队列中的所有任务,然后再执行 Promise.then 队列中的任务。

例如:

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

process.nextTick(() => {
    console.log('process.nextTick 微任务');
});

console.log('开始');

执行结果为:

  1. 输出 “开始”。
  2. 输出 “process.nextTick 微任务”。
  3. 输出 “Promise.then 微任务”。

通过深入理解 Node.js 中的事件循环机制,开发者可以更好地编写高效、稳定的 Node.js 应用程序,合理利用异步操作,提高应用程序的性能和响应能力。

综上所述,JavaScript 的事件循环机制是其实现异步编程的关键,无论是在浏览器环境还是 Node.js 环境中,深入理解事件循环对于编写高效、无阻塞的代码至关重要。通过掌握同步与异步的概念、执行栈、任务队列、Web API 以及宏任务和微任务的执行顺序等知识,开发者能够更好地优化代码,提升用户体验,打造出更加流畅和高效的应用程序。无论是处理复杂的前端交互,还是构建高性能的后端服务,事件循环的知识都将是开发者的有力武器。在实际开发中,我们需要根据具体的业务场景,合理运用异步操作,避免阻塞主线程,充分发挥 JavaScript 事件循环机制的优势。同时,注意宏任务和微任务的使用,确保代码按照预期的顺序执行,提高代码的可维护性和可读性。随着 JavaScript 技术的不断发展,事件循环机制也可能会有一些细微的变化和改进,开发者需要持续关注相关的规范和文档,以便及时掌握最新的知识和技巧,更好地服务于项目开发。