浏览器事件循环机制

876 阅读11分钟

引言

本文详述 js event loop ,浏览器渲染,网络请求,DOM 事件响应、requestIdleCallback等执行的顺序。

Event Loop 定义:

  • JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务,源自MDN
  • 为了协调事件、用户交互(DOM 事件响应)、脚本、UI 渲染、网络请求等执行顺序,用户代理必须使用 Event Loop,源自HTML Standard

JavaScript 是单线程运行的,这意味着程序在一个时刻只能做一个事情,那么异步任务、定时器、网络请求、UI 渲染都是如何工作的,它们如何实现不阻塞线程,又在合适的时机被执行?这是本文主要分析的问题。鉴于 JavaScript 有两种运行时,浏览器和 nodejs 环境,两者略有不同,本文只分析浏览器环境的事件循环机制。

本质上事件循环机制是对 ECMAScript 的事件规范(比如Job Queue)的实现,HTML Standard 又规范了其在浏览器的实现行为,而浏览器,作为 user agent(渣翻为用户代理),给开发者提供了其对于规范实现的可执行、可调试环境,有时会出现 Chrome、Safari、Firefox 等浏览器实现行为不一致的情况,此时应以规范协议为准。本文在解释原理时,会搭配使用 Chrome devtools 的 performance 工具,尽量可视的描述规范里名词的定义以及相互之间的关系。

promise、setTimeout 示例

<button id="btn">button</button>
var btn = document.querySelector("#btn");

function btnClick() {
  console.log("click start");

  setTimeout(setTimeoutCallback, 0);

  Promise.resolve().then(promiseCallback);

  console.log("click end");
}

function setTimeoutCallback() {
  console.log("setTimeout");
}

function promiseCallback() {
  console.log("promise");
}

btn.addEventListener("click", btnClick);

以上代码演示了最简单情况的 promise、setTimeout 执行顺序示例,在 id 为 btn 的按钮注册 click 事件,在点击该按钮后,console.log 输出顺序依次是click startclick endpromisesetTimeout

浏览器基础知识

想要理解以上代码执行顺序的原因,首先得了解浏览器运行的机制:事件循环需要协调的事件,都是在浏览器渲染引擎的主线程(main thread)执行。

  • 每个线程都有自己的事件循环,事件循环持续不间断的运转,一旦有任务等待执行,它就会被调度到浏览器主线程执行栈,遵循先进入的后执行原则被执行。事件循环有多种任务队列,每个任务队列内任务执行顺序是一定的,但是浏览器可以在每次循环中选择要从哪个队列执行任务。这是为了浏览器能够确保高优先级任务会优先被执行。(这里可以类比为操作系统对进程的调度,为了更好的调度不同进程,优化 CPU 的使用,执行不同任务的进程会被分类,被放到各自类别的队列里,等待被 CPU 唤醒执行。)更加形象一点描述,就是这里的 promise 的 callback 和 setTimeout 的 callback 是被分为两个队列排队的,并且优先级、调度顺序,执行规范不同。

  • 浏览器从其内部的 JavaScript 区或 DOM 区(可简单理解为 WebAPI)获取任务(task),并且确保任务被按顺序调度。在 task 执行中间,浏览器可能会渲染更新。任务可能是事件点击、事件回调、HTML 解析或者上例的 setTimeout、promise 的 then 回调。

事件执行顺序分析

  • 当按钮点击时,btnClick 被推入主线程执行栈,然后依次执行代码:
  1. 执行语句console.log("click start")
  2. 执行setTimeout(setTimeoutCallback, 0),由于 setTimeout 是由 WebAPI 实现,主线程会将其推入 WebAPI 模块,倒数计时,等到计时时间完成,再将其回调推入 Macrotasks 队列。
  3. 主线程在将setTimeout(setTimeoutCallback, 0)推入 WebAPI 后,不会阻塞自身等待其计数结束,它遇到计时器,需要做的就是将其放到正确的执行模块,现在它完成了这项工作,所以它会继续往下执行Promise.resolve().then(promiseCallback);语句。需要注意的是,此例在 resolve 里没有做任何操作,这里是同步执行的代码,Promise.then(callback),这里的 then 传入的 callback 才是异步执行的。主线程执行到本条语句,首先执行Promise.resolve()代码,接下来就是实行 ECMAScript 规范,将 then 的回调推入 Job Queue(Microtasks)内。
  4. 执行语句console.log("click end")
  5. 到此处,主线程的执行栈为空,但是 Macrotasks 队列和 Microtasks 队列都不为空,都在等待被主线程调用执行。浏览器遵循规范,先执行 Microtasks,于是把 promiseCallback 推入主线程执行栈,执行console.log("promise");语句
  6. 主线程执行栈再次置空,且此时 Microtasks 队列为空,于是将 Macrotasks 队列内的 setTimeoutCallback 推入主线程执行栈执行。

微任务(Microtasks)与宏任务(Macrotasks)

如上所述,微任务和宏任务都是异步执行的任务,但是两者仍然有很大差别:

  • 微任务(Microtasks,也称为 Job Queue):在当前执行栈为空后被立即调度执行,比如对一组用户行为作出响应、或是让事件异步但是又不至于推迟到下次的新 task 执行。典型的微任务事件:promise 的 then 回调方法,mutation 的 observer 回调。以 promise 为例,一旦 promise 的状态被确定或者已经被确定,then 回调都会被推入微任务队列,等待主线程调用。
  • 宏任务(Macrotasks):当前执行栈为空后,在新的 task 被调用执行。典型的宏任务事件:setTimeout、requestAnimationFrame、I/O 等。所以 promise 通常会在 setTimeout 前执行。

以上文字描述比较抽象,打开 Chrome devtools 的 performance 工具,记录鼠标点击事件的 Timeline:

img

对照图片,可以明显对比出微任务与宏任务的区别,微任务在当前 task 立刻被调用执行,而宏任务则会在新的 task 被调用执行。当然,这两者的区别不止于此,修改 click 事件代码:

function btnClick() {
  console.log("click start");

  setTimeout(setTimeoutCallback, 0);

  Promise.resolve().then(promiseCallback).then(promiseCallback);

  console.log("click end");
}

微任务详解

增加 promise 回调,那么回调的回调会在哪个 task 执行呢?答案还是当前 task。执行第一个 then 回调后,将第二个 promiseCallback 推入微任务队列,主线程执行栈为空后,再去挑选一个任务执行,会选择新加入的 promiseCallback 推入执行栈执行。那微任务队列会区分不同回调再进行先后排序吗?用 mutation observer 进行实验如下:

var test = document.querySelector("#test");

new MutationObserver(function () {
  console.log("mutate");
}).observe(test, {
  attributes: true,
});

function btnClick() {
  console.log("click start");

  setTimeout(setTimeoutCallback, 0);

  Promise.resolve().then(promiseCallback).then(promiseCallback);
  //修改元素attribute
  test.setAttribute("data-radom", Math.random());
  console.log("click end");
}

最终打印结果:

img

可以推论得出,微任务队列内,没有根据事件类型进行细分,当主线程第一次执行时,promiseCallback 和 MutationObserver 被推入微任务队列,按照先到先服务原则,主线程先执行 promise 的 第一个 then 回调,此时微任务队列中还剩下 MutationObserver,向队列再推入一次 then 回调,其排在 MutationObserver 之后,等到 MutationObserver 执行完成,会调用第二次 then 回调执行。执行 Timeline 如下:

img

由上例可以猜想,微任务队列如果一直不为空,那么主线程将一直执行该队列任务,从而导致主线程阻塞,以下代码示例:

function btnClick() {
  loopSetTimeout();
  //loopPromise();
}

function loopSetTimeout() {
  setTimeout(loopSetTimeout, 0);
}

function loopPromise() {
  Promise.resolve().then(loopPromise);
}

// 添加click事件测试主线程是否被阻塞
test.addEventListener("click", function () {
  alert("success");
});

当 btnClick 执行 loopSetTimeout 方法时,主线程不会被阻塞,每次调用该函数,在下次的 task 执行定时器的回调,循环往复。但是当执行 loopPromise 时,主线程阻塞在一个 task 内,对 click 不会响应,本次的 UI rendering 也不会执行,渲染进程占用电脑 100%CPU,浏览器卡死 。

如何区分微任务与宏任务

归纳事件循环里的异步执行事件,以 promise 和 setTimeout 为参照时间点,可以很轻松区分出它们分别属于那种任务类型。可以给 promise 写两次 then 回调,再第二次回调执行前完成的,都是微任务,其它都则都宏任务。当然,也可以直接看函数在 performance 的执行顺序,在新开的 task 一次执行一个的是宏任务,在本次任务结束前执行的则是微任务。下例测试了 Promises、MutationObserver、setTimeout、requestAnimationFrame 、网络请求、UI rendering 以及 react 异步渲染使用到的浏览器闲置时调用的函数回调 requestIdleCallback。用 nodejs 创建 server,模拟 ajax 请求,server 代码地址

function btnClick() {
  console.log("click start");

  setTimeout(setTimeoutCallback, 0);

  Promise.resolve().then(promiseCallback).then(promiseCallback);

  requestAnimationFrame(rafCallback);

  requestIdleCallback(ricCallback);

  ajax();

  test.setAttribute("style", "font-size:60px");
  console.log("click end");
}

function ajax() {
  var xmlhttp = new XMLHttpRequest();
  xmlhttp.open("get", "/getData");
  xmlhttp.send();

  xmlhttp.onreadystatechange = function () {
    if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
      console.log("ajax", xmlhttp.responseText);
    }
  };
}

function rafCallback() {
  console.log("requestAnimationFrame");
}
function ricCallback() {
  console.log("requestIdleCallback");
}
function xtrCallback(res) {
  console.log("xtrCallback", res);
}

控制台打印信息如下:

img

Timeline 记录信息如下:

img

综合两种分析方法,对 task、macrotasks 和 microtasks 有更加理性和直观的认识。第二次 promise 回调前的,都属于微任务。在这之后的,都是宏任务,同时它们也都是在各自的新的 task 执行。主线程的一次 task 会从待执行队列挑选一个任务进入 call stack,一个 task 内不会处理两个宏任务。requestAnimationFrame 固定的浏览器下次刷新前执行,准确描述为在 Recalculate Style 前调用。setTimeout 在新的 task 执行,对比它与 requestAnimationFrame 执行的时机,不难得出 requestAnimationFrame 更适合制作动画,setTimeout 的执行频次由开发者控制,抛开控制时间会不准的因素不谈,它可能会在某一帧过于频繁执行但浏览器却没有执行 UI rendering,造成资源浪费。再往后是 XHR Ready State Change 回调,由于是状态变化函数,不难推测 state 经过三次变化后成功返回 4 执行回调。最后是 requestIdleCallback,这个方法会在浏览器空闲时段调用,所以在主线程以及所有异步队列全部清空后,执行该事件。

任务执行流程图:

img

常见的微任务、宏任务:

  • macrotasks(宏任务):setTimeout、setInterval、setImmediate、requestAnimationFrame、I/O、UI rendering
  • microtasks(微任务/Job Queue):process.nextTick、Promises、queueMicrotask、MutationObserver

nodejs 的事件循环机制和浏览器在 js 模块实现都是遵循统一规范,对任务的分类不会有太大区别。但是 nodejs 有一些新增方法,之后会再详细分析 nodejs 的事件循环机制。

扩展:setTimeout、requestAnimationFrame 方法对比

  • requestAnimationFrame:在浏览器下次重绘前调用传入的回调函数。回调函数执行次数通常与浏览器屏幕刷新次数相匹配,每秒执行 60 次。当其运行在后台标签页或隐藏的 iframe 里时,requestAnimationFrame 会被暂停调用以提升性能和电池寿命。

以上优点使得 requestAnimationFrame 在制作动画时,对比 setTimeout 有着天然的优势,但是,如果要实现倒计时功能呢?如果使用 requestAnimationFrame,则需要解决标签页在后台执行的问题,目前网页大部分还是采用 setTimeout 来实现。

总结

研究浏览器事件循环机制,本质就是区分出在浏览器渲染引擎主线程执行的微任务、宏任务。比较难理解的地方,在于 main thread 一次循环只选择一个 task 推入 call stack 执行,而此时可能宏任务、微任务队列都有事件在排队等待被调度执行。两者主要区别:

  1. 执行时间点:微任务在本次 task 的 call stack 清空后被调用,宏任务则会在新的 task 被执行。一个 task 对应一个宏任务。
  2. 执行规范:微任务一旦开始执行,会执行至该队列为空,如果链式调用一直存在(添加速度大于队列清空速度),其会阻塞主线程;而宏任务执行完一个,主线程会进入新循环,再次执行挑选任务逻辑,所以即使有很多 setTimeout 回调在排队,主线程依旧能响应用户输入、点击等行为。
  3. 队列内回调事件排序:微任务没有再根据其回调类型是 mutation observer 或 promise 进行细分,对它们一视同仁;宏任务队列内,RAF、setTimeout、XHR Ready State Change、requestIdleCallback 的简单调用可能遵循一定的运行规律,但是并不绝对。

开始本文前,猜想 nodejs 会和浏览器事件循环机制差异很大,但是两者都是基于同一规范实现,nodejs 去除了 UI rendering、DOM 事件响应,增加了一些专属方法,两者在公共部分,不会相悖,具体区别,在nodejs 事件循环机制一文中详述。

参考文献

Tasks, microtasks, queues and schedules

菲利普·罗伯茨:到底什么是 Event Loop 呢? | 欧洲 JSConf 2014

Jake Archibald: 在循环 - JSConf.Asia

JavaScript Event Loop And Call Stack Explained

Difference between microtask and macrotask within an event loop context