浏览器解析渲染页面

本文最后更新于 2023年6月21日 上午

参考:
https://juejin.cn/post/6844904040346681358
https://juejin.cn/post/6844903815758479374

进程和线程

进程(process)和线程(thread)是操作系统的基本概念。

  • 进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)。
  • 线程是 CPU 调度的最小单位(是建立在进程基础上的一次程序运行单位)。

对操作系统来说,一个任务就是一个进程。在一个进程内部,要同时做多件事就需要同时运行多个子任务,我们把进程内的这些子任务称为线程。

由于每个进程至少要做一件事,所以一个进程至少有一个线程。系统会给每个进程分配独立的内存,因此,进程有它独立的资源。同一进程内的各个线程之间共享该进程的内存空间(包括代码段,数据集,堆等)。

浏览器的多进程架构

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

Chrome 浏览器采用多进程架构,其顶层存在一个 Browser 进程用以协调浏览器的其它进程。

Chrome 浏览器的主要进程

上图只是一个概括,意思是浏览器有这几类的进程和线程,并不是每种只有一个,比如渲染进程就有多个,每个页面都有自己的渲染进程。有时候使用浏览器会遇到某个页面崩溃或者没有响应的情况,这个页面对应的渲染进程可能崩溃了,但是其它页面并没有用这个渲染进程,它们各自有各自的渲染进程,所以其它页面并不会受影响。

最新 Chrome 浏览器包括:1个Browser进程、1个GPU进程、1个网络进程、多个渲染进程、多个插件进程。打开浏览器的一个页面至少需要 4 个进程:1个Browser进程、1个GPU进程、1个网络进程、1个渲染进程。

相较于单进程而言,多进程的优势:

  • 避免单个页面奔溃影响整个浏览器
  • 避免第三方插件奔溃影响整个浏览器
  • 多进程可以充分利用多核优势
  • 方便使用沙盒模型隔离插件等进程,提高浏览器的稳定性

Browser进程

浏览器的主进程(只有一个),负责协调、主控:

  • 负责浏览器的界面显示与用户交互,包括地址栏、书签、前进后退按钮等。
  • 负责各个页面的创建和销毁。
  • 负责与其他进程的协调工作。
  • 文件存储等功能。

Browser进程中的线程:

  • UI线程:该线程是程序运行的主线程,是程序的入口点,用来处理用户交互(如监听用户输入、前进后退等)。同时,会分发任务给其他相应线程去执行。
  • IO线程:处理 Browser 进程与其他进程进行进程间通信,下载 Renderer 进程所需的资源文件等。
  • File线程:读取磁盘文件,下载文件到磁盘等。
  • 数据库线程:进行一些数据库操作,例如保存 Cookie 到数据库。
  • 历史记录线程,http服务代理线程,等等。

Browser进程与Renderer进程间的通信(以开启一个新 tab 为例):

  • Browser进程中的 UI线程 处理用户交互,接收到用户请求,转交给 IO线程
  • Browser进程中的 IO线程 获取页面内容(通过网络请求或本地缓存),随后将该任务通过 RendererHost 接口传递给 Renderer 进程
  • Renderer进程 的 Renderer接口 收到消息,简单解释后交给渲染线程,进行html和css解析,渲染页面,js 执行等任务
  • Renderer 进程将得到的结果传递给 Browser 进程
  • Browser进程 接收到结果并在界面上绘制出图像

GPU进程

负责整个浏览器界面的渲染。Chrome 刚开始发布的时候是没有GPU进程的,而使用GPU的初衷是为了实现3D CSS效果,只是后面网页、Chrome 的 UI 界面都用GPU来绘制,这使GPU成为浏览器普遍的需求,最后 Chrome 在多进程架构上也引入了GPU进程。

网络进程

负责发起和接受网络请求,以前是作为模块运行在浏览器进程里面,后面才独立出来成为一个单独的进程。

音频进程

浏览器的音频管理。

Plugin进程

负责插件的运行,因为插件可能崩溃,所以需要通过插件进程来隔离,以保证插件崩溃也不会对浏览器和页面造成影响。每种类型的插件对应一个进程,仅当使用插件时才创建插件进程。

Renderer进程

负责控制显示标签页内的所有内容,核心任务是将HTML、CSS、JS转为用户可以与之交互的网页,排版引擎 Blink 和 JS V8 引擎都是运行在该进程中,默认情况下 Chrome 会为每个标签页创建一个渲染进程。渲染进程被称为浏览器渲染进程或浏览器内核,其内部是多线程的。

浏览器的渲染进程

我们平时看到的浏览器呈现出页面过程中,大部分工作都是在渲染进程中完成,对于前端工程师来说,主要关心的还是渲染进程。

GUI渲染线程

GUI渲染线程负责渲染浏览器界面,解析 HTML 和 CSS,构建 DOM Tree、CSSOM Tree、Render Tree,布局和绘制页面。

触发条件:当界面需要重绘(repaint)或由于某种操作引发回流(reflow)时,线程就会执行。

注意,GUI渲染线程与JS引擎线程是互斥的,当JS引擎线程执行时 GUI 线程会被挂起(相当于被冻结了),GUI渲染线程会被保存在一个队列中,等到JS引擎线程空闲时再立即被执行。

JS引擎线程

JS引擎线程是负责执行 JavaScript 的主线程,“JavaScript 是单线程的”就是指的这个线程。Chrome V8 引擎就是在这个线程运行的。JS引擎线程负责解析 Javascript 脚本,运行代码。一个页面只有一个JS引擎线程负责解析和执行 Javascript。

需要注意的是,这个线程跟GUI渲染线程是互斥的。互斥的原因是 JavaScript 也可以操作 DOM 和 CSSOM,如果JS引擎线程和GUI渲染线程同时操作,结果就混乱了,不知道到底渲染哪个结果。互斥带来的后果就是如果 JavaScript 长时间运行,GUI渲染线程就不能执行,造成页面的渲染不连贯,导致页面渲染加载阻塞,整个页面就感觉卡死了。

定时触发器线程

setTimeout 和 setInterval 就运行在这里,它跟JS引擎线程不在同一个地方,因为JS引擎线程是单线程的,所以如果其处于阻塞状态,那么计时器就会不准确,所以需要单独的线程来负责计时器工作,因而“单线程的 JavaScrip”能够实现异步。

定时触发器线程其实只是一个计时的作用,它并不会真正执行时间到了的回调函数,真正执行这个回调函数的还是 JS引擎线程。所以当时间到了,定时触发器线程会将回调函数给到事件触发线程,然后事件触发线程将它加到事件队列里面去。最终JS引擎线程从事件队列取出这个回调函数执行。

HTTP请求线程

HTTP请求线程负责处理异步的 Ajax 请求。当一个请求完成后,如果设置有回调函数,它就会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给JS引擎线程执行。

事件触发线程

事件触发线程主要用来控制事件循环,比如 JavaScript 执行遇到定时器,AJAX 异步请求等,就会将对应任务添加到事件触发线程中,在对应事件符合触发条件触发时,就把事件添加到事件队列,等待JS引擎线程来处理。事件触发线程不仅会将定时器事件放入事件队列,其它满足条件的事件也是它负责放进事件队列。

JavaScript 异步的实现靠的就是浏览器的多线程,当主线程遇到异步任务时,就将这个任务交给对应的线程,当这个异步任务满足回调函数条件时,对应的线程又通过事件触发线程将这个任务放入事件队列,然后主线程从事件队列取出事件继续执行。事件队列(Event Queue)是事件循环(Event Loop)的一部分。

浏览器渲染流程

  • 解析 HTML 文件,构建 DOM Tree
  • 解析 CSS 文件,构建 CSSOM Tree
  • 利用 DOM Tree 和 CSSOM Tree 构建 Render Tree
  • 布局 Render Tree 计算每个节点的位置大小等信息(回流)
  • 绘制 Render Tree 每个节点像素信息渲染到屏幕(重绘)

构建 DOM Tree

浏览器会遵守一套步骤将 HTML 文件转换为 DOM 树。宏观上,可以分为几个步骤:
字节 → 字符 → 令牌 → 节点 → DOM Tree

构建 CSSOM Tree

浏览器会遵守一套步骤将 HTML 文件转换为 CSSOM 树。宏观上,可以分为几个步骤:
字节 → 字符 → 令牌 → 节点 → CSSOM Tree

构建 Render Tree

在这一过程中,不是简单的将 DOM Tree 和 CSSOM Tree 合并。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none,那么它就不会在渲染树中。

布局 Render Tree(回流)

当浏览器生成渲染树以后,就会根据渲染树来进行布局。当渲染树中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流(也称为重排)。

导致回流的操作

  • 页面首次渲染
  • 浏览器窗口大小发生改变
  • 元素尺寸或位置发生改变
  • 元素内容变化(文字数量或图片大小等)
  • 元素字体大小变化
  • 添加或者删除可见的 DOM 元素
  • 激活 CSS 伪类
  • 查询某些属性或调用某些方法

常用且会导致回流的属性和方法

  • clientWidth、clientHeight、clientTop、clientLeft
  • offsetWidth、offsetHeight、offsetTop、offsetLeft
  • scrollWidth、scrollHeight、scrollTop、scrollLeft
  • scrollIntoView()、scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

绘制 Render Tree(重绘)

当浏览器根据渲染树进行布局后开始绘制屏幕。当页面中元素样式的改变并不影响它在文档流中的位置时(例如 background-color、visibility 等),浏览器会将新样式赋予给元素并重新绘制的过程称为重绘。

防止重绘的CSS优化

  • 避免使用 table 布局
  • 尽可能在 DOM 树的最末端改变 class
  • 避免设置多层内联样式
  • 将动画效果应用到 position 属性为 absolute 或 fixed 的元素上
  • 避免使用 CSS 表达式

防止重绘的JavaScript优化

  • 避免频繁操作样式,最好一次性重写 style 属性,或者将样式列表定义为 class 并一次性更改 class 属性
  • 避免频繁操作 DOM,创建一个 documentFragment,在它上面应用所有 DOM 操作,最后再把它添加到文档中。也可以先为元素设置 display: none,操作结束后再把它显示出来。因为在 display 属性为 none 的元素上进行的 DOM 操作不会引发回流和重绘
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流

问题分析

为什么JavaScript是单线程的

这是由 Javascript 这门脚本语言诞生的使命所致。JavaScript 为处理页面中用户的交互,以及操作 DOM 树、CSS 样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。

如果 JavaScript 是多线程的方式来操作这些树,则可能出现操作的冲突。假设存在两个线程同时操作一个 DOM 树(CSS 样式树),一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,Javascript 在最初就选择了单线程执行。

为什么JS文件阻塞页面渲染

渲染过程中遇到 JavaScript 文件就停止渲染,就会转而执行 JavaScript 代码。JavaScript 的加载、解析与执行会阻塞 DOM 的构建,也就是说,在构建 DOM 时,HTML 解析器若遇到了 JavaScript,那么它会暂停构建 DOM,将控制权移交给 JS引擎线程,等 JS引擎线程运行完毕,浏览器再从中断的地方恢复构建 DOM。

就是说,如果想首屏渲染的越快,就越不应该在首屏加载 JS 文件,这也是建议将 script 标签放在 body 标签底部的原因。当然并不是说 script 标签必须放在底部,因为可以给 script 标签添加 defer 或者 async 属性。

JS 文件不只是阻塞 DOM 的构建,它还会导致 CSSOM 也阻塞 DOM 的构建。原本 DOM 和 CSSOM 的构建是互不影响,但是一旦引入 JS 文件,CSSOM 也开始阻塞 DOM 的构建,只有 CSSOM 构建完毕后,才再恢复 DOM 构建。

这是因为 JavaScript 不只是可以改 DOM,它还可以更改样式,也就是可以更改 CSSOM。因为不完整的 CSSOM 是无法使用的,如果 JavaScript 想访问 CSSOM 并更改它,那么在执行 JavaScript 时,必须要能拿到完整 CSSOM。所以这就导致了一个现象,若浏览器尚未完成 CSSOM 的构建,而我们却想在此时运行脚本,那么浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的构建。就是说,在这种情况下,浏览器会先构建 CSSOM,然后再执行 JavaScript,最后再继续构建 DOM。

CSS加载会阻塞页面渲染吗

DOM 和 CSSOM 通常是并行构建的,所以 CSS 加载不会阻塞 DOM 的构建。

然而,由于 Render Tree 是依赖于 DOM Tree 和 CSSOM Tree 的,所以 Render Tree 必须等待到 CSSOM Tree 构建完成,也就是 CSS 资源加载结束后,才能开始渲染。因此,CSS 加载会阻塞 Dom 的渲染。

因为浏览器 GUI渲染线程JS引擎线程 的关系为互斥。因此,CSS 会在后面的 JS 执行前先加载执行完毕,所以, CSS 会阻塞 JS 的执行。

script标签中defer和async的区别

当浏览器碰到 script 标签的时候,有三种情况:

script 标签 JS 代码执行顺序 是否阻塞解析 HTML
<script src="script.js"> 在 HTML 中的顺序 阻塞
<script async src="script.js"> 请求返回顺序 可能阻塞,也可能不阻塞
<script defer src="script.js"> 在 HTML 中的顺序 不阻塞

<script src="script.js">

浏览器在解析 HTML 时,如果遇到一个没有任何属性的 script 标签,就会立即暂停解析 HTML,改为先请求获取 JS 代码内容,然后让 JS引擎线程执行 JS 代码,等 JS 代码执行完毕后恢复解析 HTML。

script 标签阻塞了浏览器对 HTML 的解析,如果获取 JS 代码的网络请求迟迟得不到响应,或者 JS 代码执行时间过长,都会导致白屏,用户看不到页面内容。

<script async src="script.js">

async 表示异步,当浏览器遇到带有 async 属性的 script 标签时,获取 JS 代码的请求是异步的,即不会立即暂停解析 HTML,而是解析 HTML 的同时,请求获取 JS 代码内容。当请求获取到 JS 代码内容后,如果此时 HTML 还没被解析完,会立即暂停解析 HTML,然后让 JS引擎线程执行 JS 代码,等 JS 代码执行完毕后恢复解析 HTML。

所以带有 async 属性的 script 标签是不可控的,因为执行时间不确定,如果在异步 JS 代码中获取某个 DOM 元素,有可能获取到也有可能获取不到。且存在多个带有 async 属性的 script 标签时,它们之间的执行顺序也不确定,完全依赖于网络传输结果,谁先到执行谁。

<script defer src="script.js">

defer 表示延迟,当浏览器遇到带有 defer 属性的 script 标签时,获取 JS 代码的请求是异步的,即不会立即暂停解析 HTML,而是解析 HTML 的同时,请求获取 JS 代码内容。除此外,当请求获取到 JS 代码内容后,如果此时 HTML 还没被解析完,不会立即暂停解析 HTML,而是等待 HTML 都被解析完毕后再执行 JS 代码。JS 代码的执行要在 HTML 解析完成之后、DOMContentLoaded 事件触发之前完成。

如果存在多个带有 defer 属性的 script 标签,浏览器(IE9及以下的除外)会保证按照它们在 HTML 中出现的顺序执行,不会破坏 JS 代码之间的依赖关系。


浏览器解析渲染页面
https://xuekeven.github.io/2021/09/12/浏览器解析渲染页面/
作者
Keven
发布于
2021年9月12日
更新于
2023年6月21日
许可协议