页面解析渲染

本文最后更新于 2025年8月25日 晚上

参考:
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 个渲染进程。

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

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

Browser Precess

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

  • UI(包括地址栏、书签等)
  • 各个 tab 页面的创建和销毁
  • 协调和管理其它进程
  • 文件存储等功能

Browser Precess 中的线程(部分):

  • UI Thread:主线程,程序的入口点,用来处理用户交互。同时分发任务给其他相应线程执行
  • IO Thread:处理 Browser Process 与其他进程进行进程间通信,下载请求的调度等
  • File Thread:处理文件系统访问,读取磁盘文件、下载文件到磁盘等
  • DB Thread:进行数据库操作,例如保存 Cookie 到数据库等
  • History Thread:访问历史记录等

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

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

GPU Precess

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

Network Precess

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

Audio Precess

浏览器的音频管理。

Plugin Precess

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

Renderer Precess

负责控制显示标签页内的所有内容,核心任务是将 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 异步的实现靠的就是浏览器的多线程,当主线程遇到异步任务时,就将这个任务交给对应的线程,当这个异步任务完成、满足回调函数执行的条件后,对应的线程又通过事件触发线程将这个任务放入事件队列,最终 JS 引擎线程从事件队列中取出事件继续执行。事件队列(Event Queue)是事件循环(Event Loop)的一部分。

浏览器渲染流程

  • 解析 HTML 文件,构建 DOM Tree;并行解析 CSS 文件,构建 CSSOM Tree
  • 等 DOM Tree 和 CSSOM Tree 都完成后,根据两者开始构建 Render Tree
  • 布局计算 Render Tree 中每个节点的尺寸、位置等信息
  • 绘制 Render Tree 中每个节点的像素信息并渲染到屏幕
  • 将默认图层和复合图层交给 GPU 进程进行合成,最后显示出页面

构建 DOM 和 CSSOM Tree

浏览器会遵守一套步骤将 HTML 文件转换为 DOM 树。宏观上,可以分为几个步骤:
字节数据 → 字符串 → Token → Node → DOM

浏览器会遵守一套步骤将 CSS 文件转换为 CSSOM 树。宏观上,可以分为几个步骤:
字节数据 → 字符串 → Token → Node → CSSOM

构建 Render Tree

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

布局 Render Tree(回流)

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

导致回流的操作有:

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

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

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

绘制 Render Tree(重绘)

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

回流比重绘的代价更高。有时即使仅仅回流一个单一的元素,它的父元素以及任何跟随它的元素也会产生回流。浏览器会对频繁的回流或重绘操作进行优化:浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值,浏览器就会将队列清空并进行一次批处理,这样可以把多次回流和重绘变成一次。当访问以下属性或方法时,浏览器会立刻清空队列:

  • width、height
  • clientWidth、clientHeight、clientTop、clientLeft
  • offsetWidth、offsetHeight、offsetTop、offsetLeft
  • scrollWidth、scrollHeight、scrollTop、scrollLeft
  • getComputedStyle()、getBoundingClientRect()

防止重绘的 CSS 优化:

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

防止重绘的 JavaScript 优化:

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

渲染层合并

https://juejin.cn/post/6844904040346681358#heading-38

常见问题

为什么多进程架构还会有单页面卡死后所有页面崩溃

Chrome 的默认策略是每个标签对应一个渲染进程。但是如果从一个页面打开新页面,而新页面和当前页面属于同一站点,那么新页面会复用父页面的渲染进程(process-per-site-instance 策略)。

简单来说,就是如果多个页面符合同一站点,这几个页面会分配到一个渲染进程中去,所以有这样的情况:一个页面崩溃了,导致同一个站点的其他页面也奔溃,这是因为它们使用的同一个渲染进程。

为什么 JavaScript 是单线程的

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

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

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

因为 JavaScript 可以修改 DOM 节点和 CSS 样式。如果在修改元素的同时渲染界面(即 JavaScript 引擎线程和 GUI 渲染线程同时运行),那么渲染线程前后获得的元素数据可能不一致。因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JavaScript 引擎线程互斥。

构建 DOM 时,如果 HTML 解析器遇到了 JavaScript,那么它会暂停构建 DOM,并将控制权移交给 JS 引擎线程,等 JS 引擎线程运行完毕(进行 JavaScript 的加载、解析与执行),浏览器再继续构建 DOM。

而且,因为 JS 代码在执行时可能需要访问或修改样式(即访问 CSSOM),浏览器必须确保 CSSOM 已构建完成才能执行 JS,否则 JS 操作的样式数据不完整。所以,当 HTML 解析器遇到 <script>,在此位置之前的 CSS 还没下载并构建好,浏览器会先暂停解析 HTML,等 CSSOM 完成再执行 JS。

所以这就导致了一个现象,如果浏览器尚未完成 CSSOM 的构建,而我们却想在此时运行脚本,那么浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的构建。就是说,在这种情况下,浏览器会先构建 CSSOM,然后再执行 JavaScript,最后再继续构建 DOM。而原本的 DOM 和 CSSOM 构建是互不影响的。

所以 阻塞性 CSS + 同步 JS 双重原因累加阻塞页面渲染。

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

CSS 加载会阻塞页面渲染吗

DOM Tree 和 CSSOM Tree 通常是并行构建的,所以 CSS 加载不会阻塞 DOM 的构建。然而,由于 Render Tree 依赖 DOM Tree 和 CSSOM Tree,所以 Render Tree 必须等 CSSOM Tree 构建完成,也就是加载 CSS 资源结束后才能开始渲染。因此,如果 DOM Tree 已构建完而 CSSOM Tree 还没有构建完,CSS 加载会阻塞页面渲染。

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

DOMContentLoaded 与 Load 的区别

  • 当 DOMContentLoaded 事件触发时,页面上只有 DOM 解析完成,不含样式表、图片等资源
    当文档中没有脚本时,浏览器解析完文档便能触发 DOMContentLoaded 事件。如果文档中包含脚本,则脚本会阻塞文档的解析,而脚本需要等 CSSOM 构建完成才能执行。在任何情况下,DOMContentLoaded 的触发不需要等待图片等其他资源加载完成。
  • 当 Load 事件触发时,页面上所有的 DOM、样式表、脚本、图片等资源已经加载完毕

什么是关键渲染路径 CRP

关键渲染路径(Critical Rendering Path)是浏览器将 HTML、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日
更新于
2025年8月25日
许可协议