Skip to content

事件循环

一说到事件循环,大家可能想到的是网上一大堆 event loop 的执行顺序题,这里就不对这些题目做分析了。本文主要介绍事件循环的原理,以及事件对浏览器的更新渲染的影响。我们都知道js会操作DOM,然后DOM进行更新渲染,那在event loop 每一轮的loop执行完成后,浏览器页面都会更新渲染吗?

浏览器架构模式

要想理清浏览器的渲染原理,得先从浏览器的架构说起。

浏览器的主要进程

早期浏览器的架构设计就是单进程的,浏览器所有的功能模块都是运行在同一个进程里的。浏览器页面行为不当、浏览器错误、浏览器插件错误都会引起整个浏览器或当前运行的标签页关闭;同时通过浏览器插件可以获取操作系统的任意资源,也会引起安全性问题。现在的浏览器是多进程架构,Chrome 的多进程架构设计主要包括:

  • 一个浏览器主进程
  • 一个GPU进程
  • 多个渲染进程
  • 多个插件进程。
进程名称控制
浏览器控制应用中的 “Chrome” 部分,包括地址栏,书签,回退与前进按钮。以及处理 web 浏览器不可见的特权部分,如网络请求与文件访问。
渲染控制标签页内网站展示。
插件控制站点使用的任意插件,如 Flash。
GPU处理独立于其它进程的 GPU 任务。GPU 被分成不同进程,因为 GPU 处理来自多个不同应用的请求并绘制在相同表面。

多进程的架构模式优点

多进程的架构模式相较于单进程架构有了很大的提升,多个进程的设计,避免了某个进程的崩溃影响其他进程或者整个浏览器的崩溃; 同样多进程架构可以使用安全沙箱,操作系统提供了限制进程权限的方法,浏览器就可以用沙箱保护某些特定功能的进程。

渲染进程

通过上面的了解,我们知道在Chrome浏览器中,每次新开一个标签页,都会创建一个新的渲染进程,而渲染进程在标签页中又是扮演着重要的角色,负责标签页内发生的所有事情。其核心工作就是将HTML、CSS和JavaScript转换为用户与之交互的网页。 渲染进程包括多个线程工作:

  • 主线程:运行JavaScript、DOM、CSS、样式布局计算;
  • 工作线程:运行Web Worker,Service Worker;
  • 合成线程:将图层分成图块,并发送绘制命令发送给浏览器进程(生成页面,显示在显示器上);
  • 光栅线程:将图块转换成位图并发送到 GPU。

而在主线程解析HTML时,遇到 script 标记时,就会暂停HTML的解析,开始加载、解析并执行JavaScript代码。这样就造成了HTML解析的阻塞,而JavaScript代码的执行又是为什么会阻塞HTML解析呢?这是因为JavaScript代码里可以通过类似document.write()的方法改写文档,这样就会导致HTML文档整体结构的变化。

事件循环

event loop 翻译出来就是事件循环,可以理解为实现异步的一种方式,我们来看他的定义

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loop 不懂你们看懂了吗,反正我是没看懂这句话的意思。但不影响接下来我们对事件循环的理解。事件循环可以分成宏任务和微任务。

宏任务(Macrotasks)包括:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

宏任务它只会等当前任务队列清空,并排队进入下一轮事件循环。

注意:其实 script标签的 全部代码也是 宏任(同步代码也属于宏任务)

微任务(Microtasks)包括:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

执行流程如下图:

事件循环流程图

执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环,看不懂也没关系,我们接下来用案例来解析。

事件循环和浏览器的渲染关系

需要知道的知识点:

  • 主线程先执行同步任务,执行微任务,再执行宏任务
  • 执行宏任务的时候,判断有没微任务,有就先执行微任务队列,直到清空微任务队列,再去执行宏任务
  • 每次执行完任务之后才会渲染页面
  • 浏览器刷新频率为 1000/60 = 16 ms,意味着每隔16毫秒才会渲染一次页面,
  • 如果在16ms内执行多个任务,那么只会计算最后一次的最终结果渲染到页面

重点来了,事件循环和浏览器的渲染关系图(该图以后简称为关系图),如下

事件循环与浏览器渲染关系图

由上图可知:

  • 主线程同步代码(立即执行)
  • 微任务(如 Promise.then、queueMicrotask)
  • requestAnimationFrame(准备渲染前)
  • setTimeout(下一轮宏任务)
  • requestIdleCallback(空闲时才执行)

那么,宏任务(setTimeout) 会在哪一帧执行?

  • 首帧中,只要主线程空了(即当前 JS 执行栈清空),浏览器就会检查是否可以触发下一轮事件循环。
  • setTimeout 就会被调度执行(哪怕页面还没开始真正渲染)
text
第一帧执行流程:
─────────────
1. 同步 JS
2. 微任务清空
3. 执行 requestAnimationFrame 回调
4. 样式计算 + 布局 + 绘制(开始页面渲染)
5. setTimeout(下一轮宏任务)
6. requestIdleCallback(主线程空闲时才执行)
─────────────

小结

  1. 所以宏任务也是会阻断页面的渲染,只不过它是在下一轮的渲染中阻塞。
  2. 如果主任务执行时间超过16.7s,也就是主任务执行时间过长,那么 requestAnimationFrame 和 requestIdleCallback 有可能不执行。

案例1

js
/**
 * h1元素并不会闪现
 * 只有执行完一个任务,统计结果之后才会去渲染页面
 * 根据上面的关系图可知,主线程代码需要全部执行完成之后才会去渲染页面
 */
function createDom() {
  const el = document.createElement('h1');
  el.innerHTML = 'hello world !';
  document.body.appendChild(el);
  el.style.display = 'none';
}

案例2

js
/**
 * box 的 style 只以最后一行为准
 * 根据上面的关系图可知,主线程代码需要全部执行完成之后才会去渲染页面
 */
button.addEventListener('click', () => {
  box.style.display = 'none';
  box.style.display = 'block';
  box.style.display = 'none';
  box.style.display = 'block';
  box.style.display = 'none';
  box.style.display = 'block';
  box.style.display = 'none';
  box.style.display = 'block';
  box.style.display = 'none'; // 最终结果
});

案例3

html
<body>
  <script>
    requestAnimationFrame(() => {
      console.log("👈 rAF");
    });

    requestIdleCallback(() => {
      console.log("👉 rIC");
    });

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

    console.log("📦 同步 JS 代码");
  </script>
</body>

执行的顺序是:

text

📦 同步 JS 代码
👈 rAF             ← 帧渲染前执行
⏰ setTimeout      ← 宏任务,下一轮事件循环
👉 rIC             ← 页面渲染完,空闲时才执行

setTimeout 早于 rIC 执行,因为它只需要等待主线程空(同步任务清空),而 rIC 要等待浏览器完成渲染后才能执行。 也就是setTimeout 在 rAF 之后、rIC 之前就会执行。

案例4

js
/**
 * setTimeout 其实是在主线程执行完之后的第二轮循环的开头,再把 callback 添加到宏任务
 * setTimeout 的 delay 参数并不是指具体delay多少时间之后执行,具体执行时间是在主线程之后的delay时间触发
 * setTimeout 默认时间为4.7毫秒左右
 * requestAnimationFrame 执行一次16毫秒,而 16/4.7 = 3.5 ,所以 setTimeout 会比 requestAnimationFrame快3.5倍
 * setTimeout 执行完之后计算出样式结果,默认4.7毫秒但是小于16ms,页面还未渲染
 * 然后执行 while (true) {} 死循环,这个循环一直不能执行完,宏任务一直未清空,故而不能渲染页面
 * delay 用  0 10 16 17 测试查看页面是否渲染
 * 小于16毫秒不能渲染页面,且卡死
 * 大于16毫秒能渲染页面,但是会卡死
 */
const delay = 0; // 用 0 10 16 20 测试查看页面是否渲染
setTimeout(() => {
  while (true) {} // 会阻塞页面渲染
}, delay);

案例5

js
/**
 * 页面并不会卡死,每次执行一个宏任务,但是并没有阻塞页面渲染
 */
function loop() {
  setTimeout(() => {
    console.info('并不会卡死,可以正常复制操作dom元素');
    loop();
  }, 0);
}
loop();

案例6

js
/**
 * 页面会卡死,因为一个宏任务必须清空每一个微任务,而微任务是死循环永远清空不了,导致不能渲染
 */
function loop() {
  Promise.resolve().then(loop2);
}
loop();

总结

在一轮 event loop 中多次修改同一dom,只有最后一次会进行绘制。 渲染更新(Update the rendering)会在event loop 中的 macrotasks 和 microtasks 完成后进行,但并不是每轮event loop 都会更新渲染,这取决于是否修改了dom和浏览器觉得是否有必要在此时立即将新状态呈现给用户。如果在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7ms只是估算并不准确)修改了多处 dom,浏览器可能将变动积攒起来,只进行一次绘制,这是合理的。如果希望在每轮event loop都即时呈现变动,可以使用 requestAnimationFrame。