浏览器的渲染流程与事件循环
进程与线程
进程
程序运行需要开辟一块专属的内存空间,用来存放代码
、运行数据
、执行任务的主线程
,这样的运行环境称为进程
。
特点:
进程之间完全独立,一个进程崩溃不会影响其它进程。进程之间的通信是通过进程通信管道IPC
传递。
线程
负责执行任务的管道,进程将任务分配给一个或多个线程执行。
特点:
一个进程包含多个线程,线程之间并行执行不同的任务,线程崩溃会导致进程崩溃。线程之间可以相互通信。
::: 并发与并行的区别:并发可以理解为 1 个人同时吃 3 个馒头,一个时间点只能吃其中一个馒头的一口。并行可以理解为 3 个人同时吃 3 个馒头,时间点是可以重合的。 :::
浏览器中的进程与线程
为了避免标签页互相影响,Chrome 浏览器为每个标签页开启一个新的进程。
浏览器中的进程主要有:
浏览器进程
GPU 进程
网络进程
插件进程
渲染进程(标签页)
其中渲染进程中包含了多个线程:
渲染主线程
合成线程
计时器线程
网络线程
事件触发线程
...
其中渲染主线程是最繁忙的线程,它的任务包括但不限于:
解析 html
解析 css
计算样式
布局
每 16ms 绘制 1 次页面
执行全局 js 代码
执行事件处理函数
执行计时器和网络请求的回调函数
浏览器渲染页面的流程
解析 html,生成 DOM 树和 CSSOM 树
预解析:解析和下载 html 中用到的 JS 和 CSS
主线程:逐行读取 html,遇到 script 标签 则先停下来执行 js
有影响的 link 和 script 标签属性:
link 的 rel="preload": 提前开始下载资源,设置 href,可以是 js 也可以是 css,as 设置为 style 或 script
script 的 defer: html 全部读取完再执行
script 的 async: 默认情况下会停下来等待下载,设置此项会继续读取后面的 html,等拿到响应结果后再插入执行
link 的 rel="dns-prefetch": dns 预解析
计算样式
声明属性
-> 层叠冲突
-> 使用继承
-> 使用默认值
布局 Layout
宽高、位置、包含块、display、::before、::after
W3C 布局规范:
内容必须在行盒中
行盒和块盒不能相邻
BFC:
块级格式化上下文的定义(待完善)
分层 Layer
zIndex、position、opacity
绘制 Paint
生成一系列的绘制指令
分块 Tiling
生成分块信息。这是为了让浏览器优先处理靠近视口的块
光栅化 Raster
将绘制指令转换为位图(像素块)
画 Draw
将位图转换为像素颜色+位置,传给 GPU。
CSS3 中调用 GPU 能力的属性,如 transform,会直接作用到这一步
屏幕呈现(GPU)
影响浏览器渲染性能的 2 大因素
CPU 瓶颈
重绘、回流、合成、JS 长任务
浏览器是每 16ms 绘制一帧画面,因此在用户连续操作时,超过 16ms 的任务就会阻塞视图渲染,导致画面掉帧。
W3C 将执行时间超过 50ms 的任务定义为长任务,认为 50ms 内的执行时间是可以被用户接受的。
I/O 瓶颈
网络延迟
事件循环
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
在 Chrome 源码中,渲染主线程开启了一个永不结束的 for 循环,每次循环开始的时候,从消息队列中取出第一个任务执行,而其他线程则在合适的时候将任务加入队尾。
根据 W3C 的官方解释,消息队列将任务分为不同的类型,同类型的任务属于一条队列。
队列有优先级,在一次事件循环中由浏览器自行决定取哪一条队列的任务,其中微队列的任务必须优先调度。
以往简单地将任务划分为宏队列和微队列,随着浏览器复杂度的提升,W3C 不再使用宏队列的说法。在 Chrome 浏览器上至少包含了以下队列:延时队列、交互队列、微队列。
结合事件循环理解 JS 的异步
JS 是单线程的语言,它运行在浏览器的渲染主线程中,而浏览器的渲染主线程是特别繁忙的,它除了要执行 JS,还要解析 Html、CSS、布局、分层、每秒绘制 60 次页面,如果采用同步的方式执行 JS,一方面会导致主线程在等待过程中白白浪费时间,导致其他任务无法执行,另一方面导致页面无法及时渲染,给用户造成页面假死的现象。
所以浏览器采用异步的方式来避免,具体做法是当某些任务发生时,主线程将任务交给别的线程去处理,例如网络请求、计时器、事件监听,自身则继续执行后续的代码。当其他线程完成任务时,将事先传递的回调函数包装成任务,加入到消息队列的末尾,等待主线程调度执行。
在这种异步模式下,浏览器永不阻塞,从而最大限度地保证了单线程的流畅运行。
概括如下:
单线程是异步产生的原因,事件循环是异步的实现方式。
结合事件循环理解 setTimeout 的精确性
首先考虑 JS 的计时器本身是否能实现精确计时,实际上它自身也是存在偏差的,原因有以下几点:
计算机硬件没有原子钟,本身无法实现精确计时
操作系统的计时函数本身存在少量偏差,JS 的计时器最终会调用系统的函数,携带了这部分偏差
按照 W3C 的标准,浏览器的计时函数如果超过了 5 层,则会带有 4 毫秒的最少时间,导致了偏差
而从 JS 的单线程和事件循环的角度,又导致了以下影响:
计时任务的回调函数只会在主线程空闲时才会执行,从而导致了无法避免的偏差
事件循环和渲染的关系
关于这个概念,可以通过 4 个问题引出来:
每轮循环都会伴随着渲染吗
requestAnimationFrame 的执行时机
requestIdleCallback 的执行时机
resize 和 scroll 事件的触发时机
我们将到事件循环和浏览器的渲染流程结合一下,得到以下流程
- 宏任务阶段,从队列中取出 1 个宏任务执行
- 微任务阶段,依次执行微任务,需要清空微队列,也就是说新产生的微任务也会立即执行
- 进入渲染阶段,浏览器判断是否需要绘制界面,如果跳过渲染,就进入下一轮事件循环,不会有后续的阶段
原则一:浏览器会尽量按照
30fps
和60pfs
的刷新率进行绘制,这样是为了防止因掉帧导致的体验问题原则二:如果 frame 的上下文,不可见,就降低到
4fps
的刷新率原则三:浏览器认为渲染不会带来任何改变,则跳过渲染
- 处理窗口变化,派发 resize 事件
- 处理滚动页面,派发 scroll 事件(派发指的是将回调函数包装成任务加入到宏队列)
- 执行帧动画回调(requestAnimationFrame),这是最后一次修改 DOM 的机会
- 执行 IntersectionObserver 回调
- 绘制界面
- 关键,判断当前的宏队列和微队列是否为空, 如果为空,则执行闲时调度算法,算出 deadline 传给 requestIdleCallback 的回调函数执行
原则一:deadline 最多只能传 50ms,这是因为浏览器认为一个超过 50ms 的任务为长任务,用户交互的响应时间超过 50ms 会认为页面卡顿。但是开发者可以不遵守。
原则二:requestIdleCallback 可以传入一个 timeout 参数,这个 timeout 时机到了,浏览器会中断其它工作去执行它(防止任务饿死)
- 进入下一轮事件循环
ps:requestIdleCallback
的兼容性不好,react 通过 MessageChanel
和 requestAnimation
自己实现了一套