事件循环
一说到事件循环,大家可能想到的是网上一大堆 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 就会被调度执行(哪怕页面还没开始真正渲染)
第一帧执行流程:
─────────────
1. 同步 JS
2. 微任务清空
3. 执行 requestAnimationFrame 回调
4. 样式计算 + 布局 + 绘制(开始页面渲染)
5. setTimeout(下一轮宏任务)
6. requestIdleCallback(主线程空闲时才执行)
─────────────
小结
- 所以宏任务也是会阻断页面的渲染,只不过它是在下一轮的渲染中阻塞。
- 如果主任务执行时间超过16.7s,也就是主任务执行时间过长,那么 requestAnimationFrame 和 requestIdleCallback 有可能不执行。
案例1
/**
* h1元素并不会闪现
* 只有执行完一个任务,统计结果之后才会去渲染页面
* 根据上面的关系图可知,主线程代码需要全部执行完成之后才会去渲染页面
*/
function createDom() {
const el = document.createElement('h1');
el.innerHTML = 'hello world !';
document.body.appendChild(el);
el.style.display = 'none';
}
案例2
/**
* 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
<body>
<script>
requestAnimationFrame(() => {
console.log("👈 rAF");
});
requestIdleCallback(() => {
console.log("👉 rIC");
});
setTimeout(() => {
console.log("⏰ setTimeout");
}, 0);
console.log("📦 同步 JS 代码");
</script>
</body>
执行的顺序是:
📦 同步 JS 代码
👈 rAF ← 帧渲染前执行
⏰ setTimeout ← 宏任务,下一轮事件循环
👉 rIC ← 页面渲染完,空闲时才执行
setTimeout 早于 rIC 执行,因为它只需要等待主线程空(同步任务清空),而 rIC 要等待浏览器完成渲染后才能执行。 也就是setTimeout 在 rAF 之后、rIC 之前就会执行。
案例4
/**
* 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
/**
* 页面并不会卡死,每次执行一个宏任务,但是并没有阻塞页面渲染
*/
function loop() {
setTimeout(() => {
console.info('并不会卡死,可以正常复制操作dom元素');
loop();
}, 0);
}
loop();
案例6
/**
* 页面会卡死,因为一个宏任务必须清空每一个微任务,而微任务是死循环永远清空不了,导致不能渲染
*/
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。