浏览器原理学习笔记05—浏览器中的页面渲染
Write By CS逍遥剑仙
我的主页: www.csxiaoyao.com
GitHub: github.com/csxiaoyaojianxian
Email: [email protected]
1. DOM 树
1.1 DOM 树的生成
DOM 是表述 HTML 的内部数据结构,它会将 Web 页面和 JavaScript 脚本连接起来,并过滤一些不安全的内容。HTML 解析器 (HTMLParser) 模块负责将 HTML 字节流转换为 DOM 结构。网络进程接收到响应头后会根据响应头中的 content-type
字段来判断文件的类型,若为 text/html
,则为该请求创建一个渲染进程。渲染进程准备好后,网络进程和渲染进程之间会建立一个共享数据的管道,HTML 解析器并不是等整个文档加载完成之后再解析,而是网络进程加载了多少数据,HTML 解析器便解析多少数据。
<html>
<body>
<div>1</div>
<div>test</div>
</body>
</html>
字节流转换为 DOM,和 V8 编译 JavaScript 过程中词法分析相似,首先通过分词器将字节流转换为 Token,分为 Tag Token 和文本 Token:
然后需要将 Token 解析为 DOM 节点并添加到 DOM 树中,HTML 解析器开始工作时,会默认创建一个根为 document 的空 DOM 结构,同时会将一个 StartTag document 的 Token 压入栈底,通过不断压栈出栈,最终栈空完成解析。
1.2 JavaScript 阻塞 DOM 解析
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'test1'
let div2 = document.getElementsByTagName('div')[1]
div2.innerText = 'test2'
</script>
<div>2</div>
</body>
</html>
页面解析的结果为显示 test1 和 2。因为解析 HTML 过程中遇到 <script>
标签时,HTML 解析器会暂停 DOM 的解析(因为可能会操作 DOM),JavaScript 引擎执行 script 标签中的脚本,执行完后 HTML 解析器恢复解析直至生成最终的 DOM。将 JavaScript 代码改为通过文件加载:
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
执行流程不变,执行到 script
标签时暂停整个 DOM 的解析,下载并执行 JavaScript 代码,需要注意:JavaScript 文件的下载过程会阻塞 DOM 解析。不过 Chrome 浏览器做了 HTML 预解析 优化,当渲染引擎收到字节流后会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,预解析线程会提前下载。如果 JavaScript 文件中没有操作 DOM 相关代码,可以通过 async 或 defer 将该脚本设置为异步加载来优化:
<script async type="text/javascript" src='foo.js'></script>
<script defer type="text/javascript" src='foo.js'></script>
使用 async 标志的脚本文件一旦加载完成,会立即执行;而使用 defer 标记的脚本文件,需要在 DOMContentLoaded 事件之前执行。
2. 渲染流水线与 CSSOM
2.1 CSS 不会直接阻塞 DOM 构建
CSS 加载不会阻塞 DOM 树的解析,但会阻塞 DOM 树的渲染(解析白屏),即阻塞页面的显示,因为需要等待构建 CSSOM 完成后再进行构建布局树。
<html>
<head>
<link href="theme.css" rel="stylesheet">
</head>
<body>
<div>geekbang com</div>
</body>
</html>
当渲染进程接收到 HTML 文件字节流时会先开启一个 预解析线程,遇到 JavaScript 或 CSS 文件会提前下载,如 theme.css。
CSSOM:
CSSOM 是由 CSS 文本解析得到的渲染引擎能够识别的结构,类似 HTML 和 DOM 的关系,CSSOM 可以为 JavaScript 提供操作样式表的能力,还能为布局树的合成提供基础样式信息,体现在 DOM 中即document.styleSheets
。
等 DOM 和 CSSOM 构建完成后渲染引擎会构造布局树。布局树的结构是过滤不显示元素的 DOM 树结构,渲染引擎会进行样式计算和计算布局完成布局树的构建,最后进行绘制工作。
2.2 CSS 会阻塞 JavaScript 执行
JavaScript 会阻塞 DOM 生成,而 CSS 又会阻塞 JavaScript 的执行(下面解释),因此 CSS 有时也会阻塞 DOM 的生成。
<html>
<head>
<link href="theme.css" rel="stylesheet">
</head>
<body>
<div>test1</div>
<script>
console.log('test')
</script>
<div>test2</div>
</body>
</html>
因为 JavaScript 可能会修改当前状态下的 DOM,所以会阻塞 DOM 解析。在脚本执行前,如果发现页面中包含 CSS (外部文件引用或内置 style 标签) 还会等待渲染引擎生成 CSSOM (因为 JavaScript 具有修改 CSSOM 的能力)。
<html>
<head>
<link href="theme.css" rel="stylesheet">
</head>
<body>
<div>test1</div>
<script src='foo.js'></script>
<div>test2</div>
</body>
</html>
当 HTML 预解析识别出有 CSS 文件和 JavaScript 文件需要下载,会同时发起这两个文件的并行下载请求,无论谁先到达,都要先等 CSS 文件下载完并生成 CSSOM 后再执行 JavaScript 脚本,最后再继续构建 DOM、构建布局树、绘制页面。
2.3 白屏时间优化策略
从发起 URL 请求到首次显示页面内容,在视觉上会经历三个阶段:
- 请求发出到提交数据阶段,页面展示的还是之前页面的内容
- 提交数据后渲染进程会创建一个空白页面(解析白屏),等待 CSS 文件和 JavaScript 文件加载完成并生成 CSSOM 和 DOM,然后合成布局树并准备首次渲染
- 首次渲染完成后进入完整页面生成阶段,页面会一点点被绘制出来
可以通过开发者工具来查看整个过程,在《浏览器开发者工具》一章中详解。阶段 2 的白屏时间会直接影响用户体验,渲染流水线包括了解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作,通常瓶颈主要体现在下载 CSS、JavaScript 文件和执行 JavaScript,因此缩短白屏时长有以下策略:
- 内联 JavaScript、CSS 以减少文件下载,获取到 HTML 文件后直接开始渲染流程
- 减小文件体积并使用 CDN
- 使用 async / defer 标记不需要在解析 HTML 阶段使用的 JavaScript 文件
- 大 CSS 文件拆分,通过媒体查询属性进行部分加载
3. 分层与合成机制
3.1 如何生成一帧图像
大多数设备屏幕的更新频率是 60Hz,正常情况下要实现流畅的动画效果,渲染引擎需要通过渲染流水线每秒生成 60 张图片 (60帧) 并发送到显卡的 后缓冲区,一旦显卡把合成的图像写到后缓冲区,系统就会将后缓冲区和前缓冲区互换,保证显示器能从 前缓冲区 读到最新显卡合成的图像。通常情况下,显卡和显示器的刷新频率是一致的,大多数为 60Hz (60FPS)。
前面章节《宏观视角下的浏览器》和《浏览器中的页面渲染》讲过,DOM 树生成后还要经历布局、分层、绘制、合成、渲染等阶段后才能显示出漂亮的页面,而渲染流水线任意一帧的生成方式,有 重排、重绘 和 合成 三种方式,按照效率推荐合成方式优先,在不能满足需求时使用重绘甚至重排的方式。
3.2 分层和合成:CSS动画比JavaScript高效
Chrome 中的合成技术,可以概括为:分层、分块 和 合成。分层和合成通常一起使用,类似 PhotoShop 里的图层和图层合并。页面实现一些复杂的动画效果等,如果没有采用分层机制,从布局树直接生成目标图片,当每次页面有很小的变化时都会触发重排或重绘机制,”牵一发而动全身”严重影响页面的渲染效率。Chrome 引入了分层和合成机制用于提升每帧的渲染效率,合成器只需要对相应图层操作,显卡处理这种合成时间非常短。
生成布局树后渲染引擎会将布局树转换为图层树(Layer Tree),并生成绘制指令列表,光栅化过程根据指令生成图片,合成线程将每个图层对应的图片合成为”一张”图片发送到后缓冲区,分层、合成完成。
注意:
合成操作是在渲染进程的合成线程上完成的,不影响主线程的执行,即使主线程卡住,CSS 动画依然能执行
3.3 分块
通常页面比屏幕大得多,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,大大加速了页面显示速度。
即便如此,从计算机内存上传纹理到 GPU 内存的操作还是会比较慢,Chrome 在首次合成图块时会先使用一个低分辨率图片并显示,然后合成器继续绘制正常比例的网页内容,完成后替换当前显示的低分辨率内容。
3.4 利用分层技术优化代码 will-change 优化动画
使用 JavaScript 实现对某个元素的几何形状变换、透明度变换或一些缩放操作等效果,会涉及整个渲染流水线,效率低下;而使用 will-change
来提前告知渲染引擎,该元素会被单独实现一层,变换发生时,渲染引擎会通过合成线程直接处理变换。这些变换并没有涉及到主线程,大大提升了渲染效率,这也是 CSS 动画比 JavaScript 动画高效 的原因。
.box {
will-change: transform, opacity;
}
尽量使用 will-change 来处理可以仅使用合成线程的 CSS 特效或动画,形成独立的层,但同时也会增加内存占用,因为从层树开始,后续每个阶段都会多一个层结构,需要额外的内存。
4. 不同阶段的页面性能优化
4.1 加载阶段
指从请求发出到渲染出完整页面的过程,影响主要因素:网络、 JavaScript 脚本。
图片、音频、视频等文件不会阻塞页面的首次渲染,而 JavaScript、首次请求的 HTML 文件、CSS 文件会阻塞首次渲染(构建 DOM 需要 HTML 和 JavaScript 文件,构造渲染树需要 CSS 文件),称为关键资源,三个优化关键资源的方式:
- 减少关键资源个数
- 将 JavaScript 和 CSS 改成内联形式;
- 若 JavaScript 代码没有 DOM 或 CSSOM 操作,改成 sync 或 defer 属性,变成非关键资源;
- 若 CSS 不必在构建页面之前加载,添加媒体取消阻止显现的标志,变成非关键资源
触发异步样式下载:
为 media 属性设置一个不可用的值,如”none”,当媒体查询结果值计算为 false,浏览器仍会下载样式表,但不会在渲染页面之前等待样式表的资源可用
<link rel="stylesheet" href="test.css" media="none" onload="if(media!='all')media='all'">
- 减少关键资源大小
压缩、移除代码注释、变成非关键资源等
- 减少关键资源 RTT (Round Trip Time) 次数
使用 CDN; 减少关键资源个数和大小搭配。关于 RTT (往返延迟) 详见《浏览器中的网络》一章。
4.2 交互阶段
指从页面加载完成到用户交互的过程,即渲染进程渲染帧的速度,影响主要因素:JavaScript 脚本。
和加载阶段不同的是,交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,大部分是由 JavaScript 通过修改 DOM 或者 CSSOM 触发交互动画的,另外一部分帧是由 CSS 触发的。如果在计算样式阶段发现有布局信息修改,会触发重排操作;若不涉及布局相关的调整,只是修改了颜色一类信息,就可以跳过布局阶段直接触发重绘操作;若通过 CSS 触发一些变形、渐变、动画等特效,只会触发合成线程上的合成操作,效率最高。优化单帧生成速度的方法:
- 减少 JavaScript 脚本执行时间
避免单任务霸占主线程太久,将大任务分解为多个小任务,也可以使用 Web Workers 在主线程外的一个线程中执行和 DOM 操作无关且耗时的任务(Web Workers 中没有 DOM、CSSOM 环境)
- 避免强制同步布局
通过 DOM 接口执行元素添加或删除等操作后,为避免当前任务占用主线程太长时间,一般重新计算样式和布局操作是在另外的任务中异步完成的。
<html>
<body>
<div id="mian_div">
<li id="test">test</li>
<li>csxiaoyao</li>
</div>
<p id="demo"> 强制布局 demo</p>
<button onclick="foo()"> 添加新元素 </button>
<script>
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("sun")
new_node.appendChild(textnode)
document.getElementById("mian_div").appendChild(new_node)
}
</script>
</body>
</html>
强制同步布局指 JavaScript 强制将计算样式和布局操作提前到当前任务中。
function foo() {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("sun")
new_node.appendChild(textnode)
document.getElementById("mian_div").appendChild(new_node)
// 由于要获取最新 offsetHeight 所以需要立即执行布局操作
console.log(main_div.offsetHeight)
}
应该尽量避免强制同步布局。
function foo() {
let main_div = document.getElementById("mian_div")
// 为避免强制同步布局,在修改 DOM 之前查询相关值
console.log(main_div.offsetHeight)
let new_node = document.createElement("li")
let textnode = document.createTextNode("sun")
new_node.appendChild(textnode)
document.getElementById("mian_div").appendChild(new_node)
}
- 避免布局抖动
布局抖动是指在一次 JavaScript 执行过程中,多次执行强制布局和抖动操作,应该尽量避免在修改 DOM 结构时再查询一些相关值。
function foo() {
let test = document.getElementById("test")
for (let i = 0; i < 100; i++) {
let main_div = document.getElementById("mian_div")
let new_node = document.createElement("li")
let textnode = document.createTextNode("sun")
new_node.appendChild(textnode)
new_node.offsetHeight = test.offsetHeight
document.getElementById("mian_div").appendChild(new_node)
}
}
- 合理利用 CSS 合成动画
合成动画在合成线程上执行,不影响主线程,还可以使用 will-change 标记生成单独图层。
- 避免频繁垃圾回收
垃圾回收占用主线程,可以尽可能优化储存结构,避免产生小颗粒对象,避免产生临时垃圾数据。
4.3 关闭阶段
指用户发出关闭指令后页面所做的一些清理操作,一般无需优化。
5. 虚拟DOM
5.1 DOM 的缺陷
通过 JavaScript 操纵 DOM 会影响整个渲染流水线,触发样式计算、布局、绘制、栅格化、合成等任务,牵一发而动全身,对 DOM 的不当操作还可能引发强制同步布局和布局抖动问题,尤其对复杂页面会造成性能问题。而通过 JavaScript 实现的更轻量的虚拟 DOM 可以解决上述问题。
5.2 VDOM 执行流程
- 创建阶段:以 React 为例,首先依据 JSX 和基础数据创建虚拟 DOM,然后由虚拟 DOM 树创建出真实 DOM 树,再触发渲染流水线输出页面。
- 更新阶段:数据发生改变时会根据新数据创建一个新的虚拟 DOM 树,然后 React 比较两个树,找出变化的地方,并将变化的地方一次性更新到真实 DOM 树上,最后渲染引擎更新渲染流水线,并生成新的页面。
React Fiber 更新机制:
React 中 VDOM 的新算法Fiber reconciler
使用协程解决了老的递归算法Stack reconciler
占用主线程过久的问题。
5.3 VDOM & MVC
可以把虚拟 DOM 看成 MVC 的视图部分,其控制器和模型由 Redux 提供。控制器监听 DOM 变化并通知模型更新数据;模型数据更新后,控制器会通知视图进行更新;视图根据模型数据生成新虚拟 DOM 并与之前的虚拟 DOM 比较,找出变化节点一次性更新到真实 DOM 上,最后触发渲染流水线。
6. 渐进式网页应用(PWA)
6.1 web 应用 VS 本地应用
PWA(Progressive Web App) 旨在通过技术手段渐进式缩短和本地应用或小程序的差距,web 应用相对于本地应用缺少:
- 离线(弱网)使用能力,解决:Service Worker
- 消息推送能力,解决:Service Worker
- 一级入口,解决:manifest.json 安装到桌面
但未来真正决定 PWA 能否崛起的还是底层技术,比如页面渲染效率、对系统设备的支持程度、WebAssembly 等。
6.2 Service Worker
6.2.1 概念
在 Service Worker 之前,WHATWG 小组推出过 App Cache 标准来缓存页面,但问题较多,最终废弃。2014年标准委员会提出了 Service Worker 的概念:在页面和网络模块之间增加一个拦截器,用于缓存和拦截请求。
6.2.2 架构
Chrome 的 Web Worker 在渲染进程中开启一个新线程来执行和 DOM 无关的 JavaScript 脚本,并通过 postMessage
方法将执行的结果返回给主线程,避免 JavaScript 过多占用页面主线程。
Service Worker 在 Web Worker 的基础上增加储存功能,解决了 Web Worker 每次执行完脚本后退出不保存结果而导致的重复执行问题。此外,和 Web Worker 运行在单个页面的渲染进程中不同,Service Worker 运行在浏览器进程中,在整个浏览器生命周期内为所有的页面提供服务。
6.2.3 消息推送
使用 Service Worker 接收服务器推送的消息并展示给用户,此时浏览器页面不需要启动。
6.2.4 安全
Service Worker 需要站点支持 HTTPS 协议,还要同时支持 Web 页面默认的安全策略、储入同源策略、内容安全策略(CSP)等。
7. WebComponent
JavaScript 语言特性能够实现组件化,阻碍组件化的是 CSS 的全局属性污染和全局 DOM 不能做到修改隔离。WebComponent 提供了局部视图的封装能力,可以让 DOM、CSSOM 和 JavaScript 运行在局部环境中,具体涉及 Custom elements (自定义元素)、Shadow DOM (影子 DOM) 和 HTML templates (HTML 模板)。
<!DOCTYPE html>
<html>
<body>
<template id="test">
<style>
p {
color: yellow;
}
</style>
<div>
<p>inner</p>
</div>
<script>
function foo() { console.log('inner') }
</script>
</template>
<script>
class TestComponent extends HTMLElement {
constructor() {
super()
// 1. 获取组件模板
const content = document.querySelector('#test').content
// 2. 创建影子 DOM 节点
const shadowDOM = this.attachShadow({ mode: 'open' })
// 3. 将模板添加到影子 DOM 上
shadowDOM.appendChild(content.cloneNode(true))
}
}
customElements.define('test-component', TestComponent)
</script>
<test-component></test-component>
<div>
<p>outer</p>
</div>
<test-component></test-component>
</body>
</html>
影子 DOM 能够将 DOM 和 CSS 隔离,实现了 CSS 和元素的封装。上面 demo 中 inner 为红色,outer 仍为默认的黑色,实现了 CSS 的私有化;普通 DOM 接口也无法直接查询影子 DOM 内部元素,如 document.getElementsByTagName('div')
查不到 inner,只能通过专门的接口,实现了 DOM 的作用域。但是组件内的 JavaScript 是不会隔离的,因为 JavaScript 语言本身已经能够很好地实现组件化。
浏览器为实现影子 DOM 的特性,在代码内部做了大量条件判断,比如普通 DOM 接口查找元素时,渲染引擎会判断 test-component
属性下的 shadow-root
元素是否是影子 DOM 来决定是否跳过查询。当生成布局树时,渲染引擎会判断是否是影子 DOM 来决定是否直接使用 template 内部的 CSS 属性。