JavaScript(五):事件循环

66 阅读4分钟

单线程的 JavaScript 是怎么实现异步操作的?

本质就是通过 JavaScript 的事件循环来实现的,也可以将此理解为 JavaScript 的执行机制。

为什么被设计成单线程

简化并发问题,避免了多线程的并发问题、避免线程之间的通信,减少复杂度。

单线程设计有什么缺点

阻塞、难以利用多核CPU、处理大量并发请求效率低。

执行顺序

  1. 首先,执行同步代码(包括脚本的初始部分)。

  2. 当同步代码执行完毕后,事件循环开始。

  3. 事件循环(Event Loop)会首先检查微任务队列。如果微任务队列中有任务,它会一直执行这些任务,直到微任务队列为空。

    • 如果微任务中又遇到了新的微任务,会把新的微任务也加入到微任务队列
  4. 微任务队列为空后,事件循环会从宏任务队列中取出一个宏任务并执行它。

  5. 在执行宏任务的过程中,如果遇到新的微/宏任务,它们会被添加到微/宏任务队列中,但不会立即执行。

  6. 当宏任务执行完毕后,事件循环会再次检查微任务队列并执行其中的所有任务。这个过程会不断重复。

console.log('1'); // 1. 同步代码,立即执行  
  
setTimeout(() => {  
  console.log('2'); // 5. 宏任务,稍后执行  
    
  Promise.resolve().then(() => {  
    console.log('3'); // 7. 微任务,在宏任务中创建,稍后执行  
  });  
    
  console.log('4'); // 6. 宏任务中的同步代码,紧随Promise之前执行  
}, 0);  
  
Promise.resolve().then(() => {  
  console.log('5'); // 3. 微任务,放入微任务队列,在当前宏任务之后执行
  
  Promise.resolve().then(() => {  
    console.log('6'); // 4. 微任务中的微任务
  });  
  
  setTimeout(() => { 
    console.log('7'); // 8. 微任务中的宏任务
  }, 0); 
});  
  
console.log('8'); // 2. 同步代码,立即执行 
  
// 输出顺序:1 8 5 6 2 4 3 7

任务队列(Task Queue)

任务队列用于存储待处理的任务。这些任务可以是宏任务(MacroTask)或微任务(MicroTask)。

宏任务(Macrotask)微任务(Microtask)
setTimeoutrequestAnimationFrame(有争议)
setIntervalMutationObserver(浏览器环境)
MessageChannelPromise.[ then/catch/finally ]
I/O,事件队列process.nextTick(Node环境)
setImmediate(Node环境)queueMicrotask
script(整体代码块)

注意⚠️:

  • 整体代码块也是一个宏任务,在整体代码执行的过程中你看到的延迟任务(例如 setTimeout)将被放到下一轮宏任务中来执行。

  • 宏任务和微任务都是先进先出

    console.log('1');
    
    new Promise(function(resolve){
        console.log('2');
        resolve()
    }).then(function(){
        console.log('3')
        setTimeout(function(){
            console.log('4')
        });
    });
    
    setTimeout(function(){
        console.log('5')
    });
    
    // 1 2 3 5 4
    

setTimeout 注意事项

调用 setTimeout 函数时,您实际上是在告诉浏览器:在未来的某个时间点,将这个函数添加到宏任务队列中以供执行,而不是立即加入到宏任务队列,在x秒后开始执行代码。

//代码1:
setTimeout(()=>{
    console.log(1);
}, 5000);

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

setTimeout(()=>{
    console.log(3);
}, 0);
// 先输出 3,5秒钟后几乎同时输出 1 和 2

//代码2:
Promise.resolve().then(() => {  
  // 假设这里的操作需要5秒  
  const startTime = Date.now();  
  while (Date.now() - startTime < 5000) {}  
  console.log('1'); // 微任务  
});

Promise.resolve().then(() => {  
  // 假设这里的操作需要5秒  
  const startTime = Date.now();  
  while (Date.now() - startTime < 5000) {}  
  console.log('2'); // 微任务  
});

Promise.resolve().then(() => {  
  console.log('3'); // 微任务  
});
// 等5秒输出 1,再等5秒输出 2,然后几乎同时输出 3

为什么我们在实际开发中,使用 axios 调用各个接口时几乎是同时进行的?

当你使用 axios 或其他 HTTP 客户端库发起一个HTTP请求时,HTTP 请求本身不会被视为一个宏任务或微任务。HTTP 请求会在浏览器的网络层(或在 Node.js 的 HTTP 客户端)中开始执行。这些请求是异步的,因此 JavaScript 会继续执行后续的代码,而不会等待请求完成。

当 HPPT 请求完成时,如果使用的是 axios,浏览器会返回一个 Promise 对象,Promise 会被解决(resolve),并传递一个响应对象给 .then() 方法的回调函数。如果请求失败(例如,网络错误或服务器返回了错误状态码),Promise 会被拒绝(reject),并传递一个错误对象给 .catch() 方法的回调函数。Promise 的解决和拒绝会触发微任务的执行。具体来说,当 Promise 被解决或拒绝时,对应的 .then() 或 .catch() 回调函数会被添加到微任务队列中。

这就是各个接口时几乎是同时进行的原因。

async/await

在很多时候 async 和 Promise 的解法差不多,又有些不一样。

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

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

async1();

console.log('start')

答案:

'async1 start'
'async2'
'start'
'async1 end'

可以理解为:紧跟着 await 后面的语句相当于放到了 new Promise 中,下一行及之后的语句相当于放在 Promise.then 中。

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

async function async2() {
  setTimeout(() => {
    console.log('timer')
  }, 0)
  console.log("async2");
}

async1();

console.log("start")

答案:

'async1 start'
'async2'
'start'
'async1 end'
'timer'

没错,定时器始终还是最后执行的,它被放到下一条宏任务的延迟队列中。