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

JavaScript事件循环机制详解:同步与异步

2022-12-137.6k 阅读

JavaScript 中的同步与异步

在深入了解 JavaScript 的事件循环机制之前,我们首先要清楚同步和异步这两个重要概念。

同步任务

JavaScript 是单线程语言,这意味着它在同一时间只能执行一个任务。同步任务是按照顺序依次执行的任务,只有前一个任务执行完毕,才会执行下一个任务。例如:

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

在这段代码中,console.log('任务1') 会首先执行,接着是 console.log('任务2'),最后是 console.log('任务3')。它们按照编写的顺序依次执行,这就是典型的同步执行方式。如果在 任务1 中有一段非常耗时的操作,比如一个复杂的循环计算:

console.log('任务1开始');
for (let i = 0; i < 1000000000; i++) {
    // 这里是一个空循环,只是为了消耗时间
}
console.log('任务1结束');
console.log('任务2');

任务1 的循环计算完成之前,任务2 是不会开始执行的。这就是同步任务的特性,它的执行过程是阻塞式的,后面的任务必须等待前面的任务完成。

异步任务

异步任务则不同,它不会阻塞代码的执行。当遇到异步任务时,JavaScript 引擎不会等待它完成,而是继续执行后续的同步任务。异步任务通常是一些需要等待外部资源(如网络请求、文件读取、定时器等)的操作。以定时器为例:

console.log('任务1');
setTimeout(() => {
    console.log('定时器任务');
}, 0);
console.log('任务2');

在这段代码中,setTimeout 是一个异步任务。当 JavaScript 引擎遇到 setTimeout 时,它并不会等待定时器的时间到了再执行回调函数,而是继续执行后面的 console.log('任务2')。当所有同步任务执行完毕后,才会回过头来执行 setTimeout 的回调函数。这就体现了异步任务的非阻塞特性,它不会影响同步任务的执行流程,提高了程序的执行效率。

调用栈(Call Stack)

为了更好地理解事件循环机制,我们需要先了解调用栈这个概念。

调用栈的工作原理

调用栈是一种数据结构,用于记录函数的调用关系和执行顺序。当 JavaScript 引擎执行代码时,它会将函数调用依次压入调用栈。当函数执行完毕,它会从调用栈中弹出。例如,有如下代码:

function func1() {
    console.log('func1 开始');
    func2();
    console.log('func1 结束');
}

function func2() {
    console.log('func2 执行');
}

func1();

func1 被调用时,func1 的执行上下文被压入调用栈。在 func1 执行过程中,调用了 func2,于是 func2 的执行上下文也被压入调用栈。func2 执行完毕后,其执行上下文从调用栈中弹出,接着 func1 继续执行剩余的代码,最后 func1 执行完毕,其执行上下文也从调用栈中弹出。整个过程中,调用栈就像一个栈结构,先进后出,记录着函数的调用顺序和执行状态。

调用栈与同步任务

调用栈主要负责执行同步任务。在执行同步任务时,函数按照调用顺序依次进入调用栈,并且只有当调用栈为空时,才表示所有同步任务执行完毕。例如,如果有多个同步函数依次调用:

function funcA() {
    console.log('funcA 执行');
}

function funcB() {
    funcA();
    console.log('funcB 执行');
}

function funcC() {
    funcB();
    console.log('funcC 执行');
}

funcC();

funcC 被调用后,它的执行上下文进入调用栈,接着 funcB 被调用进入调用栈,然后 funcA 被调用进入调用栈。funcA 执行完毕弹出,funcB 继续执行并弹出,最后 funcC 执行完毕弹出。这个过程中,同步任务按照顺序在调用栈中依次执行,调用栈保证了同步任务的正确执行顺序。

任务队列(Task Queue)

除了调用栈,任务队列也是事件循环机制中的重要组成部分。

任务队列的概念

任务队列是一个存储异步任务回调函数的队列。当异步任务的条件满足(比如定时器时间到、网络请求完成等),其回调函数并不会立即执行,而是被放入任务队列中。任务队列遵循先进先出(FIFO)的原则,即先进入任务队列的回调函数会先被处理。例如,有两个 setTimeout 定时器:

setTimeout(() => {
    console.log('第一个定时器任务');
}, 100);

setTimeout(() => {
    console.log('第二个定时器任务');
}, 0);

虽然第二个 setTimeout 的延迟时间为 0,但它的回调函数并不会比第一个 setTimeout 的回调函数先执行。它们都会被放入任务队列中,按照进入任务队列的顺序依次等待执行。

宏任务队列与微任务队列

实际上,任务队列分为宏任务队列(Macro - Task Queue)和微任务队列(Micro - Task Queue)。常见的宏任务有 setTimeoutsetIntervalsetImmediate(Node.js 环境)、I/O 操作、script(整体代码)等;常见的微任务有 Promise.thenprocess.nextTick(Node.js 环境)、MutationObserver 等。

宏任务队列可以有多个,但在 JavaScript 引擎的执行过程中,一次事件循环只会从一个宏任务队列中取出一个任务执行。而微任务队列只有一个,在执行完一个宏任务后,会先清空微任务队列中的所有任务,再去取下一个宏任务。例如:

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

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

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

console.log('同步任务2');

在这段代码中,首先执行同步任务,输出 同步任务1同步任务2。然后,setTimeout 的回调函数被放入宏任务队列,Promise.then 的回调函数被放入微任务队列。由于微任务队列的优先级高于宏任务队列,所以先清空微任务队列,输出 微任务1,最后再从宏任务队列中取出任务执行,输出 宏任务1

事件循环(Event Loop)机制

事件循环是 JavaScript 实现异步操作的核心机制。

事件循环的基本流程

事件循环的基本流程如下:

  1. 首先执行全局的同步任务,这些任务会依次进入调用栈并执行。
  2. 当调用栈为空,即所有同步任务执行完毕后,检查微任务队列。如果微任务队列中有任务,就依次将微任务队列中的任务放入调用栈执行,直到微任务队列为空。
  3. 微任务队列清空后,从宏任务队列中取出一个宏任务放入调用栈执行。
  4. 执行完这个宏任务后,再次检查微任务队列并清空,然后再从宏任务队列中取下一个宏任务执行,如此循环往复。

用伪代码表示事件循环的过程如下:

while (true) {
    // 执行同步任务
    if (callStack.isEmpty()) {
        // 调用栈为空,检查微任务队列
        while (microTaskQueue.length > 0) {
            let microTask = microTaskQueue.shift();
            callStack.push(microTask);
            execute(callStack.pop());
        }
        // 微任务队列清空后,取一个宏任务
        let macroTask = macroTaskQueue.shift();
        callStack.push(macroTask);
        execute(callStack.pop());
    }
}

事件循环与异步操作的结合

以网络请求为例,当发起一个网络请求时,JavaScript 引擎不会等待请求完成,而是继续执行后续的同步任务。当网络请求完成后,其回调函数会被放入宏任务队列(如果是基于 Promise 的网络请求,成功或失败的回调可能会通过 then 方法放入微任务队列)。当事件循环执行到相应阶段时,会从任务队列中取出回调函数执行,从而实现异步操作的处理。例如:

console.log('开始');

fetch('https://example.com/api')
   .then(response => {
        console.log('网络请求成功,微任务');
        return response.json();
    })
   .then(data => {
        console.log('解析数据成功,微任务', data);
    })
   .catch(error => {
        console.log('网络请求失败,微任务', error);
    });

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

console.log('结束');

在这段代码中,首先输出 开始结束,这是同步任务的执行。接着,fetch 发起网络请求,其回调函数通过 then 放入微任务队列,setTimeout 的回调函数放入宏任务队列。当同步任务执行完毕,先清空微任务队列,如果网络请求成功,会依次输出 网络请求成功,微任务解析数据成功,微任务 以及解析后的数据(如果失败则输出错误信息)。然后再执行宏任务队列中的 setTimeout 回调函数,输出 定时器宏任务

浏览器环境与 Node.js 环境中的事件循环

虽然事件循环的基本原理在浏览器环境和 Node.js 环境中是相似的,但在具体实现和任务队列的细节上存在一些差异。

浏览器环境中的事件循环

在浏览器环境中,事件循环主要围绕页面渲染、用户交互等操作。宏任务队列和微任务队列的处理规则与前面所述一致。例如,用户点击按钮、页面滚动等事件会产生宏任务,而 Promise.then 等会产生微任务。浏览器在执行事件循环时,会合理安排任务的执行顺序,以保证页面的流畅性和响应性。比如,当用户频繁点击按钮时,浏览器会将按钮点击事件的回调函数放入宏任务队列,按照事件循环的规则依次执行,同时在执行宏任务之间会清空微任务队列,确保一些关键的微任务(如基于 Promise 的异步操作)能够及时执行。

Node.js 环境中的事件循环

Node.js 的事件循环基于 libuv 库实现。它的事件循环分为多个阶段,每个阶段都有其特定的任务类型和执行逻辑。主要阶段包括:

  1. timers:这个阶段执行 setTimeoutsetInterval 设定的回调函数。
  2. I/O callbacks:处理一些系统 I/O 操作的回调,如文件读取、网络请求等。
  3. idle, prepare:内部使用,一般开发者无需关注。
  4. poll:这个阶段是事件循环的核心阶段,Node.js 会在此等待新的 I/O 事件,同时也会执行一些与 I/O 相关的回调。如果有 setTimeoutsetInterval 到期,也会在这个阶段执行。
  5. check:执行 setImmediate 设定的回调函数。
  6. close callbacks:执行一些关闭操作的回调,如 socket.on('close', ...)

在 Node.js 中,宏任务和微任务的执行顺序也遵循一般的规则,即先执行同步任务,然后清空微任务队列,再按照事件循环的阶段依次执行宏任务。例如:

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

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

setImmediate(() => {
    console.log('setImmediate 宏任务');
});

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

console.log('同步任务2');

在这段代码中,首先输出 同步任务1同步任务2,然后清空微任务队列,输出 微任务1。接着,由于 setTimeout 的优先级高于 setImmediate(在 poll 阶段 setTimeout 到期会先执行),所以先输出 定时器宏任务,最后输出 setImmediate 宏任务

事件循环机制对 JavaScript 性能的影响

理解事件循环机制对于优化 JavaScript 代码性能至关重要。

避免阻塞调用栈

由于 JavaScript 是单线程的,长时间运行的同步任务会阻塞调用栈,导致页面卡顿或 Node.js 服务无响应。例如,一个复杂的循环计算:

function heavyCalculation() {
    for (let i = 0; i < 1000000000; i++) {
        // 复杂计算
    }
    console.log('计算完成');
}

console.log('开始');
heavyCalculation();
console.log('结束');

在这段代码中,heavyCalculation 函数中的循环计算会占用调用栈很长时间,期间页面无法响应用户操作(在浏览器环境),或者 Node.js 服务无法处理新的请求。为了避免这种情况,可以将耗时操作分解为多个小任务,通过 setTimeoutrequestAnimationFrame(在浏览器环境)等方式将任务放入任务队列,让调用栈有机会处理其他任务。例如:

function heavyCalculation() {
    let total = 0;
    let step = 1000000;
    let count = 0;

    function calculate() {
        for (let i = 0; i < step; i++) {
            total += i;
        }
        count++;
        if (count < 1000) {
            setTimeout(calculate, 0);
        } else {
            console.log('计算完成', total);
        }
    }

    setTimeout(calculate, 0);
}

console.log('开始');
heavyCalculation();
console.log('结束');

在这个改进的代码中,通过 setTimeout 将复杂计算分解为多个小任务,每次计算一部分后将下一次计算任务放入任务队列,这样调用栈不会被长时间阻塞,页面或服务可以继续处理其他任务。

合理使用微任务和宏任务

在编写异步代码时,要根据实际需求合理选择使用微任务和宏任务。微任务的执行优先级较高,如果在微任务中执行过多或耗时的操作,可能会影响宏任务的执行,导致页面响应不及时(如动画卡顿)。例如,如果在 Promise.then 回调中进行大量的 DOM 操作:

Promise.resolve().then(() => {
    for (let i = 0; i < 10000; i++) {
        let element = document.createElement('div');
        document.body.appendChild(element);
    }
});

这段代码在微任务中进行大量 DOM 操作,可能会导致浏览器在执行完这些微任务之前无法进行页面渲染等宏任务,从而出现卡顿。此时,可以考虑将部分操作放入宏任务中,比如使用 setTimeout

Promise.resolve().then(() => {
    setTimeout(() => {
        for (let i = 0; i < 10000; i++) {
            let element = document.createElement('div');
            document.body.appendChild(element);
        }
    }, 0);
});

这样,先执行完微任务,让浏览器有机会进行页面渲染等宏任务,然后再通过宏任务执行 DOM 操作,提高页面的流畅性。

总结事件循环机制的重要性与应用场景

事件循环机制是 JavaScript 实现异步编程的基础,它使得 JavaScript 能够在单线程环境下高效地处理异步任务,如网络请求、定时器、用户交互等。在实际开发中,无论是前端的网页开发,还是后端的 Node.js 服务开发,深入理解事件循环机制都有助于编写更高效、更健壮的代码。例如,在前端开发中,合理利用事件循环机制可以优化页面性能,避免卡顿,提升用户体验;在后端开发中,可以使 Node.js 服务更好地处理高并发请求,提高服务的稳定性和响应速度。总之,掌握事件循环机制是成为一名优秀 JavaScript 开发者的必备技能。

在实际应用场景中,比如实时通信应用(如 WebSocket 聊天),事件循环机制确保了消息的及时接收和处理。当有新的消息通过 WebSocket 到达时,其回调函数会被放入任务队列,按照事件循环的规则被执行,从而实现实时消息的展示。又比如在图片懒加载场景中,当图片进入视口时,触发的事件回调可以通过事件循环机制合理安排任务执行,实现图片的加载,而不会影响页面其他任务的正常进行。

通过深入理解同步与异步、调用栈、任务队列以及事件循环的原理和机制,开发者可以更好地驾驭 JavaScript 这门语言,编写出性能卓越、体验良好的应用程序。无论是小型的网页脚本,还是大型的 Node.js 应用,事件循环机制始终在背后默默地发挥着关键作用,保障着程序的稳定运行和高效执行。