事件循环

97 阅读6分钟

什么是事件循环

事件循环是一种调度机制,负责协调渲染主线程和消息队列之间的执行顺序,是浏览器实现异步行为的核心机制

本文将从“为什么需要异步”出发,深入理解事件循环的原理、运行过程和应用实例。异步代码复杂,直接使用同步的设计不行吗,我们看看代码

<div class='container'></div>
<script>
  let name = 'joe'
  setInterval(() => {
    name = Math.random()
    document.querySelector('.container').innerHTML = name
  }, 2000)
  document.querySelector('.container').innerHTML = name
</script>

这样就会先执行赋值,然后到了setInterval,就会等到2秒钟,才会设置页面的文字,所以2秒钟页面时空白的,这样就会造成用户极差的体验。

这个时候主线程会处于阻塞状态,页面无法响应用户输入,不能执行渲染更新,所以我们需要异步执行,会直接执行document.querySelector('.container').innterHtml = name,等到2秒之后再执行setInterval里面的东西。

那异步究竟是怎么实现的?那就是要说到我们的重点:事件循环,简单的来说,所有的代码执行都会被包装成一个一个的事件,然后放进去一个队列,通过不断的遍历队列来执行事件从而达到事件循环这个实现

事件循环是怎么样运行

先说一些前置知识,浏览器是一个多进程多线程的程序,每个浏览器页面都是一个进程,然后整个大概分为

  • 浏览器进程:负责浏览器的那些固定的功能,例如前进后退、保存页面、右键呼出
  • 网络进程:负责网络通信这些
  • 渲染进程:这里就是主要为了渲染页面执行页面的东西,这里有一个渲染主线程

渲染主线程干了一个什么事:解析html,css,计算样式、布局、处理涂成、绘制页面,执行js代码,执行回调函数等等这些都是它干的,为了不阻塞页面,所以就需要异步,为了异步就用到了事件循环,具体怎么执行呢,如图所示:

  • 浏览器会把各种的要执行的任务分类,就是消息队列是有多个类型的(不止图里面的3种类型),每个类型的队列里面的都必须是相同类型的任务
  • 然后注意:渲染主线程的任务没有优先级的,就是先进先出,但是任务的队列是有优先级的,规范确立:
    • 微任务队列为最高优先级,其他任务队列会根据不同浏览器的规则而优先级不同
    • chrom的Blink内核把用户交互队列的优先级排在延时队列前面。
  • 渲染主线程会根据任务队列的优先级依次获取任务
    1. 先执行整体代码
    2. 然后清空微任务
    3. 然后进入下一轮循环,获取优先级最高的非空任务队列中取出下一个任务执行
    4. 然后再次清空微任务
    5. 重复c、d的步骤直到所有任务队列为空,主线程进入休眠状态
  • 那我们再想想既然渲染主线程是从消息队列拿到任务去执行,那任务的来源是哪里:
    • 主线程负责注册异步任务
    • 异步任务封装成一个合适主线程使用的任务是依赖其他线程的(定时器线程、网络线程)
    • 任务准备好后,通过事件循环将任务加入到渲染主线程
setTimeout(() => {
  console.log('1111')
})
console.log('222')

像上面的代码,执行结果是:'222' '111'

  • 我们看到渲染主线程执行在timer.js的代码
  • 然后执行到定时器的时候,通知了定时器线程,就继续往下执行222了
  • 定时器线程就会去计算时间,到时间了移交任务队列,然后通知主线程
  • 主线程再执行打印出111

所以主线程也并非生成所有的消息队列的任务,而是委托部分耗时的异步任务分给其他专门的线程,别的线程处理好了,放到消息队列了,我们主线程就负责拿走执行

实战一下

就是那么简单,既然看懂了,那么就来实战一下到底行不行

console.log('start');

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

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

console.log('end');

答案:

  • start
  • end
  • microtask
  • timeout

解析:

  1. 整个所有代码就是一个初始任务,开始执行
  2. 打印start
  3. 遇到setTimeout,委托给其他线程,其他线程会把setTimeout放到延时任务队列
  4. 继续执行代码,碰到.then放到微任务队列
  5. 打印end
  6. 清空微任务队列,打印microtask
  7. 清空延时任务队列,打印timeout
console.log(1);

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

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

console.log(5);

答案:

  • 1
  • 5
  • 3
  • 4
  • 2

解析:

  1. 整个所有代码就是一个初始任务,开始执行
  2. 打印1
  3. 遇到setTimeout,委托给其他线程,其他线程会把setTimeout放到延时任务队列
  4. 继续执行代码,碰到.then放到微任务队列
  5. 打印5
  6. 清空微任务队列,打印第一个.then的3,同时产生一个新的微任务放到微任务队列
  7. 清空微任务队列,打印第二个.then的4
  8. 清空延时任务队列,打印2
async function asyncFunc() {
  console.log('A');
  await Promise.resolve();
  console.log('B');
}

console.log('C');
asyncFunc();
console.log('D');

答案:

  • C
  • A
  • D
  • B

解析:

  1. 整个所有代码就是一个初始任务,开始执行
  2. 打印C
  3. 执行asyncFunc方法
    1. 打印A
    2. 执行Promise.resolve();
    3. 把.then放到微任务队列(注意async/await语法题这里await 表达式会暂停当前 async 函数的执行,并将后续代码作为微任务排入队列,就像是调用了 .then() 一样。
  1. 打印D
  2. 清空微任务队列,打印B
setTimeout(() => {
  console.log('timeout1');
  Promise.resolve().then(() => {
    console.log('microtask1');
  });
}, 0);

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

答案:

  • timeout1
  • microtask1
  • timeout2

解析:

  1. 整个所有代码就是一个初始任务,开始执行
  2. 到setTimeout1,委托给其他线程,其他线程会把setTimeout放到延时任务队列
  3. 到setTimeout2,委托给其他线程,其他线程会把setTimeout放到延时任务队列
  4. 清空延时任务队列执行tiemout1回调:
    1. 打印timeout1
    2. 遇到.then放到微任务队列
    3. 立刻清空微任务队列
    4. 打印microtask1
  1. 清空延时任务队列执行tiemout2打印timeout2

要注意:每轮事件循环只执行一个宏任务接着会立即清空所有微任务,再执行下一个微任务,因此你会看到微任务“插队”在两个 setTimeout 之间。

<div id="box"></div>
<script>
  document.getElementById('box').innerHTML = 'loading...';

  setTimeout(() => {
    while (true) {} // 死循环阻塞
  }, 0);

  Promise.resolve().then(() => {
    document.getElementById('box').innerHTML = 'ready';
  });
</script>

答案:

  • loading
  • ready