# EventLoop

本文通过参考多篇文章和视频,根据自己理解的思路进行摘抄整理,补充和总结的关于 js 事件循环的原理和一些浏览器渲染相关的知识点,对自己来说是一次学习总结,记录自己从零开始一步步理解这些知识点的过程。文章末尾会列出参考文章和视频。
github (opens new window)

# 1、进程和线程

# 1-1 概念

进程(process)和线程(thread)是操作系统的基本概念。
进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。
现代操作系统都是可以同时运行多个任务的,比如:用浏览器上网的同时还可以听音乐。
对于操作系统来说,一个任务就是一个进程,比如打开一个浏览器就是启动了一个浏览器进程(准确的说,浏览器是多进程的,这里方便理解只是概括性的描述一下),打开一个 Word 就启动了一个 Word 进程。
有些进程同时不止做一件事,比如 Word,它同时可以进行打字、拼写检查、打印等事情。在一个进程内部,要同时做多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程。
由于每个进程至少要做一件事,所以一个进程至少有一个线程。系统会给每个进程分配独立的内存,因此进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。

扩展阅读 从 8 道面试题看浏览器渲染过程与性能优化 (opens new window)

# 1-2 浏览器的多进程架构

以 chrome 为例,它由多个进程组成,每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能。每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。 浏览器的主要进程

  • 主进程 Browser Process:负责浏览器界面的显示与交互。各个页面的管理,创建和销毁其他进程。网络的资源管理、下载等。
  • 第三方插件进程 Plugin Process:每种类型的插件对应一个进程,仅当使用该插件时才创建。
  • GPU 进程 GPU Process:最多只有一个,用于 3D 绘制等。
  • 渲染进程 Renderer Process:称为浏览器渲染进程或浏览器内核,内部是多线程的。主要负责页面渲染,脚本执行,事件处理等。

我们通常说的 js 引擎(如 v8),负责处理 Javascript 脚本,他所在的线程就是 js 引擎线程,属于渲染进程的一个线程。

# 1-3 js 是单线程的

JavaScript 是一门单线程的语言,因此,JavaScript 在同一个时间只能做一件事,单线程意味着,如果在同个时间有多个任务的话,这些任务就需要进行排队,前一个任务执行完,才会执行下一个任务,比如说下面这段代码:

// 同步代码
function foo() {
  console.log(1);
}
function bar() {
  console.log(2);
}

foo(); // 1
bar(); // 2

代码会依次输出 1,2,因为代码是从上到下依次执行,执行完 foo(),才继续执行 bar(),但是如果 foo()中的代码执行的是读取文件或者 ajax 操作,文件的读取和数据的获取都需要一定时间,这样 bar 就需要等待 foo 执行结束才会执行。

JavaScript 的单线程,与它的用途有很大关系。JavaScript 作为浏览器的脚本语言,主要用来实现与用户的交互,利用 JavaScript,可以实现对 DOM 的各种各样的操作,如果 JavaScript 是多线程的话,一个线程在一个 DOM 节点中增加内容,另一个线程要删除这个 DOM 节点,那么这个 DOM 节点究竟是要增加内容还是删除呢?这会带来很复杂的同步问题,因此,JavaScript 被设计为单线程的。

扩展阅读 js 中的同步和异步 (opens new window)

# 2、同步与异步

# 2-1 同步任务

同步任务是指在主线程上排队执行的任务,只有前一个任务执行完毕,才能继续执行下一个任务,当我们打开网站时,网站的渲染过程,比如元素的渲染,其实就是一个同步任务。在1-3 js是单线程的中的示例代码就是一个同步代码。

# 2-2 异步任务

异步任务是指,当遇到耗时较长的任务时,把它挂起等待执行结束,而主线程不被阻塞继续执行后续代码。这样被挂起等待执行结束的任务称为异步任务。异步任务完成后会把回调函数推入一个执行队列,当主线程当前的同步任务执行完成时会检查这个执行队列来执行异步任务的回调函数。(具体 js 是如何管理和处理这些同步和异步的执行顺序呢?后面会在事件循环小节进行说明)。当我们打开网站时,像图片的加载,音乐的加载,其实就是一个异步任务。

# 2-3 为什么会有同步和异步

因为 JavaScript 是单线程的,因此同个时间只能处理一个任务,所有任务都需要排队,前一个任务执行完,才能继续执行下一个任务,但是,如果前一个任务的执行时间很长,比如文件的读取操作或 ajax 操作,后一个任务就不得不等着,造成阻塞。拿 ajax 来说,当用户向后台获取大量的数据时,不得不等到所有数据都获取完毕才能进行下一步操作,用户只能在那里干等着,严重影响用户体验。
JavaScript 在设计的时候,就已经考虑到这个问题,主线程可以完全不用等待文件的读取完毕或 ajax 的加载成功,可以先挂起处于等待中的任务,先运行排在后面的任务,等到文件的读取或 ajax 有了结果后,再执行挂起的任务。这样便产生了同步和异步。

扩展阅读 nodejs 中的异步、非阻塞 I/O 是如何实现的? (opens new window)

# 3、事件循环 EventLoop

# 3-1 Call Stack

# 3-1-1 Call Stack

Call Stack 是一个记录当前代码执行到哪里到一个数据结构。js 执行代码时,Call Stack 会记录各个任务的进栈和出栈。 考虑如下代码:

function multiply(a, b) {
  return a * b;
}
function square(n) {
  return multiply(n, n);
}
function printSquare(n) {
  var squared = square(n);
  console.log(squared);
}
printSquare(4);

执行上面的代码,会有一种被称为 main()的方法被执行(在浏览器中也会显示为(anonymous function),可以理解为这段代码本身);然后声明了三个函数,最后调用了 printSquare(4)函数;在它内部又依次调用了 square(n),multiply(n, n)。所以 Call Stack 看起来是这样的:

stack 顺序
multiply(n, n) 4
square(n) 3
printSquare(4) 2
main() 1

然后接下来的执行顺序是:multiply(n, n) return,multiply(n, n)出栈;square(n) return,square(n)出栈;printSquare(4)执行 console.log(squared),console.log(squared)入栈;

stack 顺序
console.log(squared) 3
printSquare(4) 2
main() 1

console.log(squared)完成,出栈;printSquare(4)出栈;main()出栈;结束。

# 3-1-2 死循环

function foo() {
  return foo();
}
foo();

如果写了死循环,call stack 将会一直被推入无限多个 foo(),可能会造成堆栈溢出而卡死。最终当超出一定数量后会导致浏览器报错,杀掉这个进程,提示对应的错误信息。

stack 顺序
foo() Infinity
... ...
foo() 3
foo() 2
main() 1

# 3-2 阻塞 blocking

var foo = $.getSync('//foo.com');
var bar = $.getSync('//bar.com');
var qux = $.getSync('//qux.com');

console.log(foo);
console.log(bar);
console.log(qux);

上面的代码,当执行$.getSync('//foo.com')时,会等待同步的网络请求返回后再执行下一行代码。后面的代码需要等待前面的任务结束才能执行,这就发生了阻塞。
阻塞会造成什么问题呢?
我们的代码是跑在浏览器中的。当发生阻塞时,用户的任何操作都不会被立即响应执行,因为主线程被阻塞了(浏览器也不能 render),用户在页面上的交互操作都需要等待之前在阻塞的任务执行完成才能继续往下执行任务。对用户而言就是页面卡住了,任何操作都没有效果。等阻塞结束后,用户之前的操作反而又会被再继续执行,这样就会比较诡异。
解决阻塞问题的方法是使用异步编程(包括回调函数 callback function、promise 等)。

# 3-3 异步回调 Async Callback

常见的 setTimeout/setInterval、DOM 监听事件、HTTP request 等都是异步的。

异步编程扩展阅读
1、JS 异步编程有哪些方案?为什么会出现这些方案? (opens new window)
2、js 中的同步和异步——三、异步编程 (opens new window)

考虑如下代码:

console.log('hi');
setTimeout(function() {
  console.log('there');
}, 5000);
console.log('JSConfEU');

逐步分析一下代码;call stack 首先推入 main();然后 console.log('hi')进栈,console.log('hi')出栈;setTimeout 是异步任务,跳过;console.log('JSConfEU')进栈,console.log('JSConfEU')出栈;main()出栈。5 秒后 call stack 又被推入 console.log('there'),然后 console.log('there')出栈。
那么 setTimeout(cb,5000)是怎么跳过的?它去了哪里?cb 又是如何重新进入 call stack 的呢?看上去 setTimeout 转移到了其他地方等待执行结束,然后把回调函数又返回给 js 主线程了,但是 js 是单线程的,又是谁去处理 setTimeout 异步任务呢?接下来就开始讲一下重点——EVentLoop 事件循环

# 3-4 事件循环 EventLoop

扩展阅读 如何理解 EventLoop,共三篇 (opens new window)

# 3-4-1 WebAPIs

setTimeout/setInterval;DOM(document)对象;XMLHttpRequest 等不存在于 js 引擎 V8 之中,他们是浏览器提供的

浏览器不仅仅为 js 代码提供了运行时环境,还提供了可以进行异步操作的 WebAPIs,如:setTimeout/setInterval;DOM(document)对象;XMLHttpRequest 对象等。这些 WebAPIs 可以让 js 实现异步回调。

# 3-4-2 任务队列 Task queue (宏任务 MacroTask 与微任务 Microtask)

简单来说,js 执行代码时,各种同步或异步任务会被存入任务队列顺序执行。但是实际情况会复杂很多。
任务队列分为 2 种:宏任务 MacroTask 与微任务 Microtask。
常见的宏任务有:同步代码的执行、大多数异步任务(setTimeout、网络请求、MessageChannel、postMessage、setImmediate 等)。
常见等微任务有:MutationObserver、Promise.then(或.reject) 以及以 Promise 为基础开发的其他技术(比如 fetch API), 还包括 V8 的垃圾回收过程。
js 在执行每个宏任务结束之前会检查微任务队列内是否有待执行的任务,如果有,那么就会依次执行微任务队列的任务。当微任务队列没有待执行的任务,那么当前的宏任务就结束了,当前的宏任务从宏任务队列中删除,js 继续执行宏任务队列里的下一个宏任务。js 这样循环地检查宏任务事件队列和微任务事件队列来依次执行各个事件任务的机制,被称做事件循环 EventLoop。当然事件循环涉及不止 js 事件的执行,还有很多其他非 js 事件的执行(如浏览器渲染等

用具体代码分析:

<div class="outer">
  <div class="inner"></div>
</div>
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

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

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

在读 ssh 大佬的进阶指南 (opens new window)时,他推荐了这篇文章 (opens new window),上面的示例代码就来自这篇文章,原文写的很详细,更有事件循环的 step-by-step 的动画演示过程便于理解,下面的分析也是基于对这片文章内容的摘抄和补充。建议先阅读原文以便下面的分析能容易的理解。

下面开始逐步分析代码:当点击 inner div 的时候
1、首先触发 Click 事件(会执行 onClick 这个回调函数),我们记做dispatch click,这是一个宏任务,MacroTasks queue 存入dispatch click任务(注意,dispatch click任务包含执行onClick函数及其内部同步代码)。此时的任务队列大概是这个样子:

队列 任务
宏任务 MacroTask queue dispatch click
微任务 Microtask queue

2、js 执行onClick,Call Stack 推入onClick;Call Stack 推入console.log('click'),console.log('click')出栈;
3、js 执行setTimeoutAPI,Call Stack 推入setTimeoutsetTimeoutAPI 执行,浏览器提供一个 timer 开始计时,Call Stack 推出setTimeoutAPI。同时因为setTimeout是一个宏任务,所以,0 秒后(实际上是 4ms 左右,因为 W3C 在 HTML 中规定,setTimeout 中低于 4ms 的时间间隔算为 4ms)MacroTasks queue 存入setTimeout的回调函数cb(setTimeout)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue

4、js 执行Promise.resolve.then(cb),Call Stack 推入Promise.resolve.then(cb),Promise.resolve.then(cb)是一个微任务,因为是立即resolve了,所以紧接着,Microtask queue 存入Promise.resolve.then(cb)的回调函数cb(Promise then),Call Stack 推出Promise.resolve.then(cb)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(Promise then)

5、js 执行outer.setAttribute(),Call Stack 推入outer.setAttribute(),触发MutationObserver,它也是一个微任务,Microtask queue 存入MutationObserver的回调函数cb(mutation observer)outer.setAttribute()出栈。

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(Promise then),cb(mutation observer)

6、onClick 内同步代码执行完了,宏任务dispatch click不会立即结束,此时会检查微任务队列是否有任务等待执行,此时微任务队列有 2 个任务,依次是cb(Promise then)cb(mutation observer),那么依次执行这 2 个微任务。
7、js 执行cb(Promise then),即执行console.log('promise')。Call Stack 推入console.log('promise'),Call Stack 推出console.log('promise')。微任务队列删除cb(Promise then)

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue cb(mutation observer)

8、js 执行cb(mutation observer),即执行console.log('mutate')。Call Stack 推入console.log('mutate'),Call Stack 推出console.log('mutate')。微任务队列删除cb(mutation observer)。此时微任务队列的任务也执行完了,Call Stack 推出onClick

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout)
微任务 Microtask queue

9、那么宏任务dispatch click是不是结束了呢?答案是没有,因为dispatch click还有事件冒泡,会触发 outer 的 Click 事件,因此 onClick 函数再次被 js 执行。于是重复上面的 2-8 步骤。
10、2-8 步骤重复完后,任务队列大概是这样的:

队列 任务
宏任务 MacroTask queue dispatch click,cb(setTimeout),cb(setTimeout)
微任务 Microtask queue

宏任务dispatch click结束。宏任务队列删除dispatch click,由于 2-8 步骤一共执行了 2 次,所以宏任务队列还有 2 个cb(setTimeout)宏任务。于是,继续执行下一个宏任务。
11、js 执行第一个cb(setTimeout),即执行console.log('timeout'),Call stack 推入console.log('timeout'),Call stack 推出console.log('timeout')。此时微任务队列没有待执行的任务。第一个cb(setTimeout)宏任务结束了。宏任务队列删除第一个cb(setTimeout),继续执行第二个cb(setTimeout)
12、js 执行第二个cb(setTimeout),即执行console.log('timeout'),Call stack 推入console.log('timeout'),Call stack 推出console.log('timeout')。此时微任务队列没有待执行的任务。第二个cb(setTimeout)宏任务结束了。宏任务队列删除第二个cb(setTimeout)
13、最终宏任务队列也空了,没有待执行的宏任务了。

再来看一下另一个例子

button.addEventListener('click', () => {
  promise.resolve().then(() => console.log('Microtask 1'));
  console.log('listener 1');
});

button.addEventListener('click', () => {
  promise.resolve().then(() => console.log('Microtask 2'));
  console.log('listener 2');
});

// js调用
// button.click();

button 元素被添加了 2 个事件响应回调函数,根据不同的触发方式,各个事件响应的顺序也不同。

用户点击时,触发了 2 个 click 事件,他们是分别向主线程推入了回调函数,根据前面的介绍我们知道。DOM 事件处理程序是宏任务,所以这 2 个都是宏任务。对应的 log 顺序是:listener 1Microtask 1listener 2Microtask 2。每个宏任务结束后都会执行相应的微任务。

而 js 调用时,又不太一样,它 log 的顺序是:listener 1listener 2Microtask 1Microtask 2。这是因为,整个script同步代码作为宏任务,当执行到button.click()时,会继续执行 2 个事件监听的回调函数,这 2 个回调函数也属性本次宏任务,只有 2 个回调函数都被执行完后,script才可以退出。这时才会检查微任务事件队列,去执行微任务。

# 4、浏览器渲染

除去网络资源获取的步骤,我们理解的 Web 页面的展示,一般可以分为 构建 DOM 树构建渲染树布局绘制渲染层合成 几个步骤。 关于这些步骤的细节,推荐阅读下面的扩展阅读,推荐的文章都介绍的比较好。

扩展阅读: 1、从浏览器多进程到 js 单线程,js 运行机制最全面的一次梳理 (opens new window) 2、从 8 道面试题看浏览器渲染过程与性能优化 (opens new window) 3、浏览器层合成与页面渲染优化 (opens new window) 4、神三元——浏览器渲染 (opens new window)

下面来介绍一下和事件循环有关的一个动画 API——requestAnimationFrame

# 4-1 requestAnimationFrame

通常浏览器渲染页面时,他的频率不会超过显示器刷新的频率,一般都是保持和显示器刷新频率一致(通常 60 次/秒,大约 16.67ms 一次)。也就是说,每次显示器刷新画面时,浏览器也更新一次页面。这样保证页面更新与显示器刷新保持同步,到达最流畅的显示效果。
在浏览器渲染进程中,周期性更新页面时,会调用 js 线程计算最新的页面,然后进行渲染流程。requestAnimationFrame 就是在渲染前执行的动画。 定义:window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
用示例说明:

<div id="box"></div>
var box = document.getElementById('box');
var maxWidth = window.innerWidth;
var count = 0;
function moveBoxForwardOnePixel() {
  if (count >= maxWidth - box.offsetWidth) {
    count = 0;
  } else {
    ++count;
  }
  box.style.left = count + 'px';
}
function callback() {
  moveBoxForwardOnePixel();
  requestAnimationFrame(callback);
  // setTimeout(callback, 0);
}
callback();

上面的代码是循环调用 callback 来让 Box 向前移动 1 个像素。
requestAnimationFrame 是在每次渲染前移动一次,所以它看起来很流畅。显示器每刷新一次,浏览器更新一次页面,同时 requestAnimationFrame 在浏览器更新页面前让 box 向前移动 1 个像素。他们的频率都保持一致。
setTimeout 虽然时间间隔是 0,但是实际上是 4ms 左右,它的调用频率是大于浏览器和显示器刷新频率的(通常 60 次/秒,大约 16.67ms 一次)。每当浏览器想要更新页面时,setTimeout 执行了好几次,计算 box 的位置时,box 已经向前移动了好几个像素,这样 box 的移动动画就会不流畅,出现跳跃的现象。
requestAnimationFrame 在每次渲染前进行计算,显示器每一帧都是先计算更新再进行渲染,即使 requestAnimationFrame 内的任务耗时较长推迟了渲染,他们的顺序是固定的,效果比 setTimeout 的不确定性更好,因为不同情况下 setTimeout 在每一帧出现的次数都不固定。因此,当我们在做动画时,推荐使用 requestAnimationFrame。

参考文章:

  1. js 中的同步和异步 (opens new window)
  2. nodejs 中的异步、非阻塞 I/O 是如何实现的? (opens new window)
  3. JS 异步编程有哪些方案?为什么会出现这些方案? (opens new window)
  4. 如何理解 EventLoop,共三篇 (opens new window)
  5. 前端高级进阶指南 (opens new window)
  6. Tasks, microtasks, queues and schedules (opens new window)
  7. 从浏览器多进程到 js 单线程,js 运行机制最全面的一次梳理 (opens new window)
  8. 从 8 道面试题看浏览器渲染过程与性能优化 (opens new window)
  9. 浏览器层合成与页面渲染优化 (opens new window)
  10. 浏览器渲染 (opens new window)

参考视频:

  1. JSConf 一 (opens new window)
  2. JSConf 二 (opens new window)