Skip to content

题目

请详细说明 JavaScript 事件循环机制(Event Loop)的工作原理,以及宏任务和微任务的执行顺序。

🎯 面试速答

TIP

事件循环是 JavaScript 处理异步操作的核心机制。因为 JavaScript 是单线程的,同一时间只能做一件事,所以需要事件循环来协调各种异步任务的执行,避免阻塞主线程。

简单来说,事件循环的工作流程是这样的:首先执行所有的同步代码,然后检查有没有微任务需要执行,如果有就把所有微任务都执行完,接着再执行一个宏任务,然后又回到检查微任务这一步,这样不断循环。

这里面涉及到三个核心概念:调用栈、宏任务队列和微任务队列。调用栈就是存放当前正在执行的代码的地方,是一个后进先出的结构。当遇到异步操作时,比如 setTimeout 或者 Promise,它们的回调函数会被放到对应的任务队列里等待执行。

宏任务和微任务的区别很重要。宏任务包括 setTimeout、setInterval、I/O 操作这些,而微任务主要是 Promise 的 then、catch、finally 回调,还有 queueMicrotask 这些。关键点是微任务的优先级比宏任务高,每次事件循环都会把所有微任务清空后,才会执行一个宏任务。

举个例子,如果代码里既有 setTimeout 又有 Promise,那执行顺序一定是:先执行同步代码,然后执行 Promise 的回调,最后才执行 setTimeout 的回调。这就是为什么即使 setTimeout 设置延迟为 0,它也不会立即执行,因为它要等微任务队列清空后才轮到它。

另外需要注意的是,浏览器的页面渲染通常发生在宏任务之间,所以如果微任务太多或者执行时间太长,也会影响页面渲染,导致页面卡顿。

在实际开发中,理解事件循环可以帮助我们更好地处理异步操作的执行顺序,避免一些意外的 bug,也能帮助我们优化性能,比如把耗时的操作分片执行,避免阻塞主线程。

📝 标准答案

核心要点

  1. 单线程特性

    • JavaScript 在同一时间只能做一件事
    • 长时间运行的任务会阻塞其他任务
  2. 事件循环核心组件

    • 调用栈(Call Stack):存储代码执行位置
    • 任务队列(Task Queue):存放待执行的回调函数
    • 事件循环(Event Loop):协调调用栈和任务队列
  3. 任务分类

    • 宏任务(Macro Tasks):setTimeout、setInterval、I/O、UI 渲染
    • 微任务(Micro Tasks):Promise、queueMicrotask、MutationObserver
  4. 执行顺序

    • 执行同步代码
    • 清空所有微任务
    • 执行一个宏任务
    • 重复上述过程

详细说明

1. 调用栈(Call Stack)

调用栈是一个后进先出(LIFO)的数据结构,用于存储代码执行的上下文。

javascript
function first() {
  console.log('first');
  second();
  console.log('first end');
}

function second() {
  console.log('second');
}

first();

// 调用栈变化:
// 1. first() 入栈
// 2. console.log('first') 入栈 → 执行 → 出栈
// 3. second() 入栈
// 4. console.log('second') 入栈 → 执行 → 出栈
// 5. second() 出栈
// 6. console.log('first end') 入栈 → 执行 → 出栈
// 7. first() 出栈

2. 任务队列(Task Queue)

任务队列分为宏任务队列和微任务队列,用于存放异步操作的回调函数。

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

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

console.log('同步代码');

// 输出顺序:
// 同步代码
// Promise
// setTimeout

3. 事件循环流程

javascript
console.log('1');

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

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

console.log('4');

// 执行流程:
// 1. 执行同步代码:输出 '1' 和 '4'
// 2. setTimeout 回调进入宏任务队列
// 3. Promise 回调进入微任务队列
// 4. 同步代码执行完,清空微任务队列:输出 '3'
// 5. 执行宏任务队列:输出 '2'

// 最终输出:1 4 3 2

🧠 深度理解

宏任务 vs 微任务

宏任务(Macro Tasks)

宏任务是由宿主环境(浏览器或 Node.js)提供的异步任务。

javascript
// 常见的宏任务
setTimeout(() => {}, 0);
setInterval(() => {}, 0);
setImmediate(() => {}); // Node.js
requestAnimationFrame(() => {}); // 浏览器
I/O 操作
UI 渲染

微任务(Micro Tasks)

微任务是由 JavaScript 引擎提供的异步任务,优先级高于宏任务。

javascript
// 常见的微任务
Promise.resolve().then(() => {});
queueMicrotask(() => {});
MutationObserver(() => {}); // 浏览器
process.nextTick(() => {}); // Node.js(优先级最高)

执行顺序详解

javascript
console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');

// 执行流程分析:
// 1. 执行同步代码:输出 '1' 和 '6'
// 2. 清空微任务队列:
//    - 执行 Promise.then:输出 '4'
//    - setTimeout 进入宏任务队列
// 3. 执行第一个宏任务:
//    - 输出 '2'
//    - Promise.then 进入微任务队列
// 4. 清空微任务队列:输出 '3'
// 5. 执行第二个宏任务:输出 '5'

// 最终输出:1 6 4 2 3 5

复杂示例

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

async function async2() {
  console.log('async2');
}

console.log('script start');

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

async1();

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

console.log('script end');

// 执行流程:
// 1. 同步代码:
//    - 'script start'
//    - 'async1 start'
//    - 'async2'
//    - 'promise1'
//    - 'script end'
// 2. 微任务队列:
//    - 'async1 end' (await 后面的代码)
//    - 'promise2'
// 3. 宏任务队列:
//    - 'setTimeout'

// 最终输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

async/await 与事件循环

javascript
async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');
}

async function bar() {
  console.log('bar');
}

foo();
console.log('script end');

// await 相当于:
function foo() {
  console.log('foo start');
  return Promise.resolve(bar()).then(() => {
    console.log('foo end');
  });
}

// 输出:
// foo start
// bar
// script end
// foo end

Node.js 中的事件循环

Node.js 的事件循环分为 6 个阶段:

javascript
// Node.js 事件循环阶段
┌───────────────────────────┐
┌─>│           timers          │  // setTimeout、setInterval
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │  // 系统操作的回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │  // 内部使用
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │  // I/O 回调
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │  // setImmediate
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │  // socket.on('close')
   └───────────────────────────┘

// process.nextTick 和 Promise 在每个阶段之间执行

Node.js 特殊 API

javascript
// process.nextTick(优先级最高)
process.nextTick(() => {
  console.log('nextTick');
});

// setImmediate(check 阶段)
setImmediate(() => {
  console.log('setImmediate');
});

// setTimeout(timers 阶段)
setTimeout(() => {
  console.log('setTimeout');
}, 0);

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

// 输出顺序:
// nextTick
// Promise
// setTimeout
// setImmediate

浏览器渲染时机

javascript
console.log('1');

setTimeout(() => {
  console.log('2');
  // 浏览器可能在这里渲染
}, 0);

Promise.resolve().then(() => {
  console.log('3');
  // 不会在这里渲染,因为微任务还没清空
});

console.log('4');

// 渲染时机:
// 1. 执行同步代码
// 2. 清空所有微任务
// 3. 浏览器渲染(如果需要)
// 4. 执行下一个宏任务

常见误区

误区 1:setTimeout(fn, 0) 立即执行

javascript
console.log('1');

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

console.log('3');

// 输出:1 3 2
// setTimeout 的回调会在下一个宏任务执行,不是立即执行

误区 2:微任务会阻塞渲染

javascript
// ❌ 错误:大量微任务会阻塞渲染
Promise.resolve().then(function loop() {
  // 无限循环创建微任务
  Promise.resolve().then(loop);
});

// 页面会卡死,因为微任务队列永远清不完

误区 3:所有异步操作都是宏任务

javascript
// Promise 是微任务,不是宏任务
Promise.resolve().then(() => {
  console.log('微任务');
});

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

// 输出:微任务 宏任务

实际应用场景

1. 优化性能

javascript
// ❌ 每次操作都触发渲染
for (let i = 0; i < 1000; i++) {
  element.style.left = i + 'px';
  // 浏览器可能每次都渲染
}

// ✅ 批量更新,只渲染一次
requestAnimationFrame(() => {
  for (let i = 0; i < 1000; i++) {
    element.style.left = i + 'px';
  }
});

2. 确保执行顺序

javascript
// 确保在 DOM 更新后执行
element.textContent = 'new text';

// 使用微任务确保在下次渲染前执行
Promise.resolve().then(() => {
  console.log(element.textContent); // 'new text'
});

// 使用宏任务在渲染后执行
setTimeout(() => {
  console.log('渲染完成');
}, 0);

3. 避免阻塞

javascript
// ❌ 长时间运行的同步代码会阻塞
function heavyTask() {
  for (let i = 0; i < 1000000000; i++) {
    // 耗时操作
  }
}

heavyTask(); // 阻塞主线程

// ✅ 分片执行
function heavyTaskAsync(start, end) {
  for (let i = start; i < end; i++) {
    // 处理一部分
  }
  
  if (end < 1000000000) {
    setTimeout(() => {
      heavyTaskAsync(end, end + 10000);
    }, 0);
  }
}

heavyTaskAsync(0, 10000);

💡 面试回答技巧

🎯 一句话回答(快速版)

事件循环是 JS 处理异步的机制:先执行同步代码,然后清空所有微任务(Promise),再执行一个宏任务(setTimeout),循环往复。微任务优先于宏任务。

📣 口语化回答(推荐)

面试时可以这样回答:

"事件循环是 JavaScript 处理异步操作的核心机制。因为 JS 是单线程的,不能同时做多件事,所以需要事件循环来协调异步任务。

核心概念有三个:调用栈存放正在执行的代码,任务队列存放待执行的回调,事件循环负责把任务队列里的任务放到调用栈执行。

任务分两种:宏任务微任务。宏任务包括 script 整体代码、setTimeout、setInterval、I/O 等;微任务包括 Promise.then、MutationObserver、queueMicrotask 等。

执行顺序是这样的:

  1. 先执行同步代码(这本身就是一个宏任务)
  2. 同步代码执行完,清空所有微任务队列
  3. 然后执行一个宏任务
  4. 再清空所有微任务
  5. 循环往复

关键点是微任务优先于宏任务,而且每次会清空所有微任务,但只执行一个宏任务。

所以 Promise.then 的回调一定比 setTimeout 先执行,即使 setTimeout 是 0 毫秒。"

推荐回答顺序

  1. 先说是什么

    • "事件循环是 JavaScript 处理异步操作的核心机制"
    • "由于 JavaScript 是单线程,事件循环使其能够处理非阻塞操作"
  2. 再说核心组件

    • "调用栈:存储代码执行位置"
    • "任务队列:存放待执行的回调函数"
    • "事件循环:协调调用栈和任务队列"
  3. 然后说执行流程

    • "执行同步代码"
    • "清空所有微任务"
    • "执行一个宏任务"
    • "重复上述过程"
  4. 最后说应用

    • "理解异步代码的执行顺序"
    • "优化性能,避免阻塞"
    • "正确处理 Promise 和 setTimeout"

重点强调

  • 微任务优先于宏任务执行
  • 每次清空所有微任务,但只执行一个宏任务
  • 浏览器渲染在宏任务之间进行
  • 长时间运行的同步代码会阻塞事件循环

可能的追问

Q1: 为什么微任务优先于宏任务?

A:

  • 微任务是 JavaScript 引擎提供的,优先级更高
  • 微任务通常用于处理 Promise 等需要尽快执行的操作
  • 设计上保证了 Promise 的及时响应

Q2: setTimeout(fn, 0) 的延迟是多少?

A:

  • 浏览器最小延迟是 4ms(HTML5 规范)
  • Node.js 最小延迟是 1ms
  • 实际延迟取决于当前任务队列的情况

Q3: 如何避免事件循环阻塞?

A:

  • 避免长时间运行的同步代码
  • 使用 Web Worker 处理耗时计算
  • 分片执行大任务
  • 使用 requestIdleCallback 在空闲时执行

Q4: Node.js 和浏览器的事件循环有什么区别?

A:

  • Node.js 有 6 个阶段,浏览器只有宏任务和微任务
  • Node.js 有 process.nextTick 和 setImmediate
  • Node.js 的 setTimeout 和 setImmediate 执行顺序不确定

💻 代码示例

综合练习题

javascript
console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

new Promise((resolve) => {
  console.log('4');
  resolve();
}).then(() => {
  console.log('5');
  setTimeout(() => {
    console.log('6');
  }, 0);
});

setTimeout(() => {
  console.log('7');
  Promise.resolve().then(() => {
    console.log('8');
  });
}, 0);

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

console.log('10');

// 执行流程分析:
// 1. 同步代码:1 4 10
// 2. 微任务队列:5 9
// 3. 宏任务 1:2 → 微任务:3
// 4. 宏任务 2:7 → 微任务:8
// 5. 宏任务 3:6

// 最终输出:1 4 10 5 9 2 3 7 8 6

async/await 练习题

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

async function async2() {
  console.log('async2');
}

async function async3() {
  console.log('async3');
}

console.log('script start');

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

async1();

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

console.log('script end');

// 输出:
// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// async3
// promise3
// async1 end 2
// setTimeout

实战:实现一个任务调度器

javascript
class Scheduler {
  constructor(limit) {
    this.limit = limit;
    this.queue = [];
    this.running = 0;
  }
  
  add(task) {
    return new Promise((resolve) => {
      this.queue.push({ task, resolve });
      this.run();
    });
  }
  
  run() {
    while (this.running < this.limit && this.queue.length) {
      const { task, resolve } = this.queue.shift();
      this.running++;
      
      task().then((result) => {
        resolve(result);
        this.running--;
        this.run();
      });
    }
  }
}

// 使用
const scheduler = new Scheduler(2);

const timeout = (time) => {
  return new Promise((resolve) => {
    setTimeout(resolve, time);
  });
};

const addTask = (time, order) => {
  scheduler.add(() => timeout(time)).then(() => {
    console.log(order);
  });
};

addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');

// 输出:2 3 1 4
// 解释:最多同时执行 2 个任务

🔗 相关知识点

📚 参考资料

基于 MIT 许可发布 | 隐私政策