事件循环机制:JavaScript世界的“时间管理大师”

234 阅读5分钟

单线程的JavaScript如何应对多任务?

JavaScript的世界是一个单线程的世界。这意味着,它一次只能做一件事。然而,现代Web应用的需求却远远超出了“一件事”的范畴:网络请求、用户交互、动画渲染……这些任务都需要同时进行。那么,JavaScript是如何在单线程的限制下,高效地处理这些任务的呢?

答案就是——事件循环(Event Loop)。它是JavaScript世界的“时间管理大师”,通过巧妙的调度机制,让单线程的JavaScript能够同时处理多个任务。


一、进程与线程——浏览器中的“工厂”与“工人”

1. 进程:浏览器的“工厂”

每当我们打开一个新的浏览器标签页,浏览器就会为我们创建一个新的进程。你可以把进程想象成一个独立的工厂,每个工厂都有自己的资源和工人(线程)。这些工厂的任务是完成页面的加载和渲染。

2. 线程:工厂里的“工人”

在工厂里,工人们(线程)分工明确:

  • 渲染线程:负责将HTML、CSS转换成用户看到的页面。
  • JS引擎线程:负责执行JavaScript代码。
  • HTTP线程:负责发送网络请求,获取数据。

然而,JS引擎线程和渲染线程是互斥的,也就是说,他们不能同时工作。这是因为JavaScript可以操作DOM,而渲染线程也会操作DOM。如果两者同时工作,就会导致冲突。因此,当JS引擎线程在执行任务时,渲染线程必须等待。


二、事件循环——JavaScript的“时间管理大师”

1. 同步任务:按部就班的工作

JavaScript的执行从同步任务开始。这些任务就像工人的日常工作清单,按顺序执行,不可打断。例如:

console.log("开始工作");
console.log("完成任务");

这段代码会先输出“开始工作”,再输出“完成任务”,顺序明确,不可更改。

2. 异步任务:巧妙的“分身术”

然而,有些任务非常耗时,比如网络请求或定时器。如果工人一直等着这些任务完成,其他任务就无法进行了。于是,工人学会了“分身术”——异步任务。他会把这些耗时任务先放到一边,也可以说放入了其他队列,自己则继续执行同步代码。

setTimeout(() => {
    console.log("定时任务完成");
}, 1000);
console.log("继续工作");

这段代码中,工人会先把定时任务挂起,放到其他任务队列中,等待事件循环处理,自己则继续“继续工作”,执行同步代码。


三、异步任务:微任务与宏任务

异步任务分为微任务与宏任务。

1. 微任务

在任务队列中,常见的微任务包括:

  • Promise.then()
  • process.nextTick()
  • MutationObserver

事件循环会先执行所有微任务,然后再执行宏任务。例如:

Promise.resolve().then(() => {
    console.log("微任务执行");
});
setTimeout(() => {
    console.log("宏任务执行");
}, 0);

这段代码会先输出“微任务执行”,再输出“宏任务执行”。

2. 宏任务

宏任务包括:

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O操作
  • UI渲染

事件循环会在执行完所有微任务后,再执行宏任务。之后循环往复。


四、事件循环的步骤——JavaScript的“时间管理法则”

事件循环的工作步骤如下:

  1. 执行同步代码:这是事件循环的第一优先级。(同时也是一次宏任务的开始)
  2. 检查并执行微任务:所有微任务会在同步代码执行完毕后立即执行。
  3. 渲染页面:如果需要,事件循环会触发页面渲染。
  4. 执行宏任务:宏任务的执行标志着下一次事件循环的开始。

执行宏任务也是开启了下一次事件循环,这个过程就像一个永不停止的循环,确保JavaScript能够高效地处理各种任务。


五、await的“提前执行”与setTimeout的“时间误差”

1. await的“提前执行”

await后面的代码要当成同步执行,并将后续代码“挤入”微任务队列。例如:

async function test() {
    console.log("开始");
    await Promise.resolve();
    console.log("结束");
}
test();

这段代码会先输出“开始”,随后执行await Promise.resolve(),之后将“结束”放入微任务队列,等待执行。

2. setTimeout的“时间误差”

setTimeout的定时器并不总是准确的。当定时器的时间到达时,如果JS主线程还在执行同步代码或微任务,定时器的回调会被挂起,直到主线程空闲。因此,setTimeout的时间只是一个“最小等待时间”,而不是“精确时间”。


六、小练笔

请看接下来这段代码

console.log('script start');     //1 输出script start
async function async1() {
  await async2()                 //3 执行async2()    //await后面的代码要当成同步执行
  
  console.log('async1 end');     //await下面的代码去到微任务队列  
                                 //7先进入微任务队列,先输出async1 end
}
async function async2() {
  console.log('async2 end');     //4 输出async2 end
}
async1()                         //2 此时调用async1()
setTimeout(() => {               // 去到宏任务队列  //10 输出setTimeout
  console.log('setTimeout');
}, 0)
new Promise((resolve, reject) => {   //注意:后面接的.then才是微任务
  console.log('promise');       //5 输出promise
  resolve()
})
.then(() => {
  console.log('then1');         // 去到微任务队列  //8 输出then1
})
.then(() => {
  console.log('then2');         // 去到微任务队列  //9 输出then2
});
console.log('script end');     //6 输出script end

输出如下:

1-6同步任务执行完毕,接下来执行微任务

7-9执行微任务

10执行宏任务

//同步
script start
async2 end
promise
script end
//微任务
async1 end
then1
then2
//宏任务
setTimeout