Vivia Preview

Theme Toggle

前端八股文之Event Loop

Event Loop 是JavaScript中的一种机制, 用于管理和执行异步任务. 本文对微任务和宏任务的执行顺序以及宏任务之间的执行顺序进行了实验.

lastModified: 2024-08-29

前言

最近因为个人原因, 打算从现在的公司辞职了. 要再找工作就要面试(虽然可能没有机会也说不定), 就需要背背一些八股文, 看看一些工作里没有用到, 但面试可能用到的东西.

Event Loop

Event Loop 是什么?

Event Loop 是JavaScript中的一种机制, 用于管理和执行异步任务. 它不断地从消息队列中取出任务并执行, 确保程序不会被阻塞. Event Loop 的核心是消息循环, 它使得JavaScript能够处理异步事件和操作, 是实现异步编程的关键.

以上是来源于网络关于Event Loop的简单介绍.

Event Loop本身的实现是多个优先级不同的队列, 调度程序会按照优先级来决定执行哪个队列中的什么任务.

由于js现在是一个多端语言, 即nodejs和浏览器, 所以Event Loop也有所区别. nodejs的事件循环是通过libuv实现的, 而浏览器的则是自己实现的.

比如chromium的Blink/V8, 火狐的Gecko/SpiderMonkey. Edge是基于chromium的, 所以和chromium的Event Loop是一样的.

以下实验的环境为:

  • Edge 126

国产手机的自带浏览器有可能会页面崩溃.

宏任务与微任务

在大部分的文章中, 都是说浏览器具有宏任务和微任务, 但是在实际代码中, 浏览器只有微任务和任务的说法. 如果下载V8或者webkit的源码, 会发现压根就搜索不到macrotask这个词, 只有microtask.

不过这个概念并不影响理解, 而且我也不会C++, 也读不懂C++的源码, 所以这里依然以宏任务和微任务来理解. 不过就算是宏任务, 在现代浏览器中, 也分为了很多种类型. 比如setTimeout, setInterval这样的定时任务, 以及ajax和用户事件之类的异步任务.

宏任务与微任务的执行顺序

微任务这个概念是在ES6 Promise出现的时候流行的, 在此之前只有MutaionObserver是浏览器原生支持的微任务.

举个例子:

MutaionObserver

javascript

const div = document.createElement('div');
document.body.appendChild(div);
const observer = new MutationObserver(() => {
  out.innerHTML += '<p>mutaion</p>';
});
observer.observe(div, { childList: true });
const mc = new MessageChannel();

mc.port1.start();
mc.port1.addEventListener('message', () => {
  out.innerHTML += '<p>message channel</p>';
});
mc.port2.postMessage('sss');

div.innerHTML = '<p>hello</p>';

我们可以看到, 就算是后执行MutaionObserver也是先输出的是mutation, message channel, 这说明MutationObserver的是在MessageChannel之前触发的,也就是所谓的微任务. 这里为什么不使用setTimeout呢? 是因为setTimeout会有一个最小延时, 浏览器会等待这个延时结束之后再执行setTimeout的回调.

关于微任务

微任务的优先级非常高, 在当前函数执行完成后, 会立刻执行微任务队列的任务. 并且这个过程中, 如果微任务本身又在队列中添加微任务, 这个队列就会一直无法执行完.

同时, 微任务执行早于浏览器的渲染(虽然不太准确), 也就是说, 微任务会在浏览器渲染之前执行, 渲染之后再执行微任务. 除了只需要GPU渲染的以外, 比如transform动画, 滚动之类的, 其他的渲染就会停止. 同时因为微任务队列长期未清空, 所以用户操作的回调也无法执行, 比如点击事件.

被卡住的渲染

javascript

const div = document.createElement('div');
const button = document.createElement('button');
button.innerHTML = 'click';
button.onclick = () => {
  div.innerHTML = '<p>click</p>';
  run();
};
document.body.appendChild(button);
document.body.appendChild(div);
div.className = 'ani';
div.innerHTML = '<p>hello</p>';
let i = 0;
function run() {
  if (i++ < 10000000) {
    Promise.resolve().then(run);
  }
}
会被卡住的动画

css

.ani {
  position: absolute;
  animation: lefts 5s linear infinite;
}
@keyframes lefts {
  0% {
    left: 0;
  }
  100% {
    left: 700px;
  }
}

点击上面的按钮, 我们会发现, 页面卡死了, 点击和选择都无法响应, 同时动画也停止了执行. 等过了一阵后, 界面上的文字才会变成修改后的值, 同时动画发生了跳帧然后继续执行.

宏任务中, 相互之间的执行顺序

点击事件和MessageChannel

微任务会在浏览器渲染之前执行(虽然不太准确), 上面做了一个验证, 那么宏任务之间有没有顺序问题呢?

目前能够直接调用的常用宏任务:

  1. setTimeout
  2. setInterval
  3. MessageChannel
  4. XMLHttpRequest
  5. 用户操作事件

如何验证这个东西, 确实是不太容易, 我们无法保证每一步都是在同一个任务队列执行. 不过前面提到了微任务会阻塞宏任务, 那么也可以利用这一点, 让所有的宏任务按照能够验证的方式执行.

我们验证一下用户操作事件和MessageChannel的顺序:

点击事件和MessageChannel的执行顺序

javascript

const button = document.createElement('button');
button.innerText = 'Click me';

let start = 0;
function run() {
  if (Date.now() - start < 10000) {
    Promise.resolve().then(run);
  }
}

const mc = new MessageChannel();
mc.port1.start();
mc.port1.addEventListener('message', () => {
  out.innerHTML += 'MessageChannel!<br />';
});
let flag = false;
button.onclick = () => {
  if (flag) {
    out.innerHTML += 'Clicked!<br />';
    return;
  }
  start = Date.now();
  flag = true;
  mc.port2.postMessage({});
  run();
};
document.body.appendChild(button);

我们快速点击两下按钮, 会发现输出的顺序是: click -> message channel. 在代码中, 第一次执行的时候是先触发了message channel的postMessage, 然后再次点击的时候因为微任务卡住了, 没有响应. 如果是顺序执行, 那么理论上应该是先执行message channel, 再执行click.

这个执行结果代表点击事件的优先级是高于message channel的.

点击事件和XMLHttpRequest

点击事件和xhr的执行顺序

javascript

const button = document.createElement('button');
button.innerText = 'Click me';

let start = 0;
function run() {
  if (Date.now() - start < 2000) {
    Promise.resolve().then(run);
  }
}

function xhr() {
  const xhr = new XMLHttpRequest();
  xhr.open('GET', 'https://dog.ceo/api/breeds/image/random');
  xhr.onload = () => {
    out.innerHTML += 'XHR!<br />';
  };
  xhr.send();
}

let flag = false;
button.onclick = () => {
  if (flag) {
    out.innerHTML += 'Clicked!<br />';
    return;
  }
  start = Date.now();
  flag = true;
  xhr();
  setTimeout(() => {
    run();
  }, 100);
};
document.body.appendChild(button);

这里使用setTimout延迟了微任务的执行, 否则会导致xhr的请求无法发出. 第一次点击后间隔一秒再次点击按钮:

network

从结果上能看到, xhr的也是在点击回调之后执行了, 这一点可以从控制台的网络/Network模块看到. 点击后请求立即发了出去, 但是在响应后, 依然是点击事件的回调先进行了执行.

网络没有排队, 但是显示顺序依然是click -> xhr.

由此证明了点击事件的优先级是比XMLHttpRequest高的.

MessageChannel和Timeout

MessageChannel和Timeout

javascript

const button = document.createElement('button');
button.innerText = 'Click me';

let start = 0;
function run() {
  if (Date.now() - start < 2000) {
    Promise.resolve().then(run);
  }
}

const mc = new MessageChannel();
mc.port1.start();
mc.port1.addEventListener('message', () => {
  out.innerHTML += 'MessageChannel!<br />';
});
let flag = false;
button.onclick = () => {
  if (flag) {
    return;
  }
  start = Date.now();
  flag = true;
  setTimeout(() => {
    out.innerHTML += 'Timeout!<br />';
  });
  mc.port2.postMessage({});
  run();
};
document.body.appendChild(button);

这里是按照进入队列的顺序执行的, 哪个先插入就先输出. 因此可以认为两者执行优先级是一样的, 也代表Timeout的优先级是比点击事件低的.

规范的描述

前端规范组织中, 关于fetch, dom, 事件循环等浏览器API是由一个叫做whatwg的组织指定的.

whatwg

其中描述了关于任务队列的一些信息.

whatwg event loop

有说到存在多个任务队列的事情, 因此结合各种网络文章可以认为:

  • 浏览器执行过程中存在多个队列, 比如交互队列, 延时队列
  • 任务没有优先级, 但是队列有
  • 上面的实验证明, 交互队列的优先级在宏任务中是最高的

原先规范中貌似是有宏任务的说法的, 但是后来随着浏览器复杂度越来越高, 就移除了. 现在只有微任务和任务的概念.

结论

本文通过对Event Loop机制的探讨, 不仅阐述了其在JavaScript异步编程中的重要性, 还通过一系列实验验证了不同类型的异步任务(包括宏任务与微任务)之间的执行顺序。研究发现:

  • 微任务相较于宏任务拥有更高的优先级, 在每个宏任务执行完毕后立即得到处理, 这有助于确保某些关键操作能及时完成.
  • 宏任务内部也有其自身的执行队列顺序, 用户交互的队列要比其他宏任务队列先执行.
  • Timeout与MessageChannel的执行顺序取决于它们进入队列的时间, 表明两者在优先级上处于同一级别.

这个内容本身基本都是理论内容, 平时业务开发也基本用不上的东西. 不过理解这个过程还是挺有意思, 为此还稍微翻了翻webkit和v8/blink的源码(虽然没看懂多少), 算是一次有趣的体验?

© 9999 Vivia Name

Powered by Nextjs & Theme Vivia

主题完全模仿 Vivia 主题, 有些许差异, 及使用nextjs乱写

Theme Toggle