Skip to content

浏览器的渲染流程与事件循环

进程与线程

进程

程序运行需要开辟一块专属的内存空间,用来存放代码运行数据执行任务的主线程,这样的运行环境称为进程

特点:

进程之间完全独立,一个进程崩溃不会影响其它进程。进程之间的通信是通过进程通信管道IPC传递。

线程

负责执行任务的管道,进程将任务分配给一个或多个线程执行。

特点:

一个进程包含多个线程,线程之间并行执行不同的任务,线程崩溃会导致进程崩溃。线程之间可以相互通信。

::: 并发与并行的区别:并发可以理解为 1 个人同时吃 3 个馒头,一个时间点只能吃其中一个馒头的一口。并行可以理解为 3 个人同时吃 3 个馒头,时间点是可以重合的。 :::

浏览器中的进程与线程

为了避免标签页互相影响,Chrome 浏览器为每个标签页开启一个新的进程。

浏览器中的进程主要有:

  1. 浏览器进程

  2. GPU 进程

  3. 网络进程

  4. 插件进程

  5. 渲染进程(标签页)

其中渲染进程中包含了多个线程:

渲染主线程

合成线程

计时器线程

网络线程

事件触发线程

...

其中渲染主线程是最繁忙的线程,它的任务包括但不限于:

  1. 解析 html

  2. 解析 css

  3. 计算样式

  4. 布局

  5. 每 16ms 绘制 1 次页面

  6. 执行全局 js 代码

  7. 执行事件处理函数

  8. 执行计时器和网络请求的回调函数

浏览器渲染页面的流程

解析 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 布局规范:

  1. 内容必须在行盒中

  2. 行盒和块盒不能相邻

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 的计时器本身是否能实现精确计时,实际上它自身也是存在偏差的,原因有以下几点:

  1. 计算机硬件没有原子钟,本身无法实现精确计时

  2. 操作系统的计时函数本身存在少量偏差,JS 的计时器最终会调用系统的函数,携带了这部分偏差

  3. 按照 W3C 的标准,浏览器的计时函数如果超过了 5 层,则会带有 4 毫秒的最少时间,导致了偏差

而从 JS 的单线程和事件循环的角度,又导致了以下影响:

计时任务的回调函数只会在主线程空闲时才会执行,从而导致了无法避免的偏差

事件循环和渲染的关系

关于这个概念,可以通过 4 个问题引出来:

  1. 每轮循环都会伴随着渲染吗

  2. requestAnimationFrame 的执行时机

  3. requestIdleCallback 的执行时机

  4. resize 和 scroll 事件的触发时机

我们将到事件循环和浏览器的渲染流程结合一下,得到以下流程

    1. 宏任务阶段,从队列中取出 1 个宏任务执行
    1. 微任务阶段,依次执行微任务,需要清空微队列,也就是说新产生的微任务也会立即执行
    1. 进入渲染阶段,浏览器判断是否需要绘制界面,如果跳过渲染,就进入下一轮事件循环,不会有后续的阶段
    • 原则一:浏览器会尽量按照30fps60pfs的刷新率进行绘制,这样是为了防止因掉帧导致的体验问题

    • 原则二:如果 frame 的上下文,不可见,就降低到4fps的刷新率

    • 原则三:浏览器认为渲染不会带来任何改变,则跳过渲染

    1. 处理窗口变化,派发 resize 事件
    1. 处理滚动页面,派发 scroll 事件(派发指的是将回调函数包装成任务加入到宏队列)
    1. 执行帧动画回调(requestAnimationFrame),这是最后一次修改 DOM 的机会
    1. 执行 IntersectionObserver 回调
    1. 绘制界面
    1. 关键,判断当前的宏队列和微队列是否为空, 如果为空,则执行闲时调度算法,算出 deadline 传给 requestIdleCallback 的回调函数执行
    • 原则一:deadline 最多只能传 50ms,这是因为浏览器认为一个超过 50ms 的任务为长任务,用户交互的响应时间超过 50ms 会认为页面卡顿。但是开发者可以不遵守。

    • 原则二:requestIdleCallback 可以传入一个 timeout 参数,这个 timeout 时机到了,浏览器会中断其它工作去执行它(防止任务饿死)

    1. 进入下一轮事件循环

ps:requestIdleCallback 的兼容性不好,react 通过 MessageChanelrequestAnimation 自己实现了一套