MST

星途 面试题库

面试题:JavaScript定时器与事件循环的深度剖析

在JavaScript事件循环机制下,setTimeout和setInterval是如何与宏任务、微任务交互的?请结合具体代码示例,详细分析在复杂场景下定时器的执行顺序和可能出现的问题,并提出解决方案。
43.4万 热度难度
编程语言JavaScript

知识考点

AI 面试

面试题答案

一键面试

1. JavaScript 事件循环机制基础

JavaScript 是单线程语言,通过事件循环(Event Loop)机制来处理异步任务。事件循环的基本原理是:在执行栈(Call Stack)为空时,从任务队列(Task Queue)中取出任务放入执行栈执行。任务队列分为宏任务队列(Macro Task Queue)和微任务队列(Micro Task Queue)。常见的宏任务有 setTimeoutsetIntervalscript(整体代码)、I/OUI rendering 等;常见的微任务有 Promise.thenprocess.nextTick(Node.js 环境)、MutationObserver 等。

2. setTimeoutsetInterval 与宏微任务交互

setTimeoutsetInterval 属于宏任务。当调用 setTimeoutsetInterval 时,会在指定时间(最小为 4ms,受浏览器限制)后将其回调函数放入宏任务队列。当执行栈为空时,事件循环会从宏任务队列中取出一个宏任务放入执行栈执行,执行完宏任务后,会清空微任务队列,再进行下一次事件循环。

3. 代码示例及分析

console.log('start');

setTimeout(() => {
    console.log('setTimeout1');
    Promise.resolve().then(() => {
        console.log('promise1 in setTimeout1');
    });
}, 0);

Promise.resolve().then(() => {
    console.log('promise1');
});

setTimeout(() => {
    console.log('setTimeout2');
    Promise.resolve().then(() => {
        console.log('promise1 in setTimeout2');
    });
}, 0);

console.log('end');
  1. 执行顺序分析
    • 首先执行 script 宏任务,打印 start
    • 遇到 setTimeout,将其回调函数放入宏任务队列,由于 setTimeout 延迟为 0,实际上最短延迟 4ms 左右。
    • 遇到 Promise.resolve().then,将其回调函数放入微任务队列。
    • 再次遇到 setTimeout,将其回调函数放入宏任务队列。
    • 打印 endscript 宏任务执行完毕。
    • 开始清空微任务队列,执行 promise1 回调,打印 promise1
    • 第一次事件循环结束,从宏任务队列取出第一个 setTimeout 回调,打印 setTimeout1,然后 Promise.resolve().then 回调放入微任务队列。
    • 该宏任务执行完毕,开始清空微任务队列,打印 promise1 in setTimeout1
    • 第二次事件循环结束,从宏任务队列取出第二个 setTimeout 回调,打印 setTimeout2Promise.resolve().then 回调放入微任务队列。
    • 该宏任务执行完毕,开始清空微任务队列,打印 promise1 in setTimeout2

4. 复杂场景下定时器执行顺序及问题

4.1 嵌套定时器

setTimeout(() => {
    console.log('outer setTimeout');
    setTimeout(() => {
        console.log('inner setTimeout');
    }, 0);
}, 0);

执行顺序:先打印 outer setTimeout,然后再打印 inner setTimeout。因为外层 setTimeout 回调进入宏任务队列执行完毕后,内层 setTimeout 回调才会进入宏任务队列等待执行。

4.2 定时器与微任务复杂嵌套

setTimeout(() => {
    console.log('setTimeout1');
    Promise.resolve().then(() => {
        console.log('promise1 in setTimeout1');
        setTimeout(() => {
            console.log('setTimeout2 in promise1 of setTimeout1');
        }, 0);
    });
}, 0);

执行顺序: - setTimeout1 回调进入宏任务队列执行,打印 setTimeout1。 - Promise.resolve().then 回调进入微任务队列,打印 promise1 in setTimeout1。 - 微任务执行完毕,setTimeout2 in promise1 of setTimeout1 回调进入宏任务队列。 - 下一次事件循环执行该宏任务,打印 setTimeout2 in promise1 of setTimeout1

4.3 可能出现的问题

定时器不准确:由于事件循环机制,当执行栈中有长时间运行的任务时,会导致定时器回调不能按时执行。例如:

setTimeout(() => {
    console.log('setTimeout callback');
}, 100);

for (let i = 0; i < 1000000000; i++) {}

这里 setTimeout 设定 100ms 后执行,但由于 for 循环长时间占用执行栈,setTimeout 回调会延迟执行。

5. 解决方案

使用 requestAnimationFrame:适用于动画相关场景,它会在浏览器下一次重绘之前执行回调,并且会根据页面的可见性和系统资源自动调整执行频率,比 setTimeout 更精准。

function animate() {
    console.log('animation frame');
    requestAnimationFrame(animate);
}
requestAnimationFrame(animate);

将长任务拆分为多个微任务或宏任务:避免长时间占用执行栈。例如,将 for 循环拆分成多个部分,使用 setTimeoutPromise.then 来分段执行:

function longTask() {
    let total = 1000000000;
    let step = 1000000;
    let index = 0;

    function runTask() {
        for (let i = 0; i < step && index < total; i++) {
            index++;
        }
        if (index < total) {
            setTimeout(runTask, 0);
        }
    }

    runTask();
}

setTimeout(() => {
    console.log('setTimeout callback');
}, 100);

longTask();

这样可以在执行 longTask 的同时,让 setTimeout 回调能相对按时执行。