Write By CS逍遥剑仙 我的主页: www.csxiaoyao.com GitHub: github.com/csxiaoyaojianxian Email: sunjianfeng@csxiaoyao.com
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 压入栈底,通过不断压栈出栈,最终栈空完成解析。
<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 事件之前执行。
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 树结构,渲染引擎会进行样式计算和计算布局完成布局树的构建,最后进行绘制工作。
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、构建布局树、绘制页面。
从发起 URL 请求到首次显示页面内容,在视觉上会经历三个阶段:
可以通过开发者工具来查看整个过程,在《浏览器开发者工具》一章中详解。阶段 2 的白屏时间会直接影响用户体验,渲染流水线包括了解析 HTML、下载 CSS、下载 JavaScript、生成 CSSOM、执行 JavaScript、生成布局树、绘制页面一系列操作,通常瓶颈主要体现在下载 CSS、JavaScript 文件和执行 JavaScript,因此缩短白屏时长有以下策略:
大多数设备屏幕的更新频率是 60Hz,正常情况下要实现流畅的动画效果,渲染引擎需要通过渲染流水线每秒生成 60 张图片 (60帧) 并发送到显卡的 后缓冲区,一旦显卡把合成的图像写到后缓冲区,系统就会将后缓冲区和前缓冲区互换,保证显示器能从 前缓冲区 读到最新显卡合成的图像。通常情况下,显卡和显示器的刷新频率是一致的,大多数为 60Hz (60FPS)。
前面章节《宏观视角下的浏览器》和《浏览器中的页面渲染》讲过,DOM 树生成后还要经历布局、分层、绘制、合成、渲染等阶段后才能显示出漂亮的页面,而渲染流水线任意一帧的生成方式,有 重排、重绘 和 合成 三种方式,按照效率推荐合成方式优先,在不能满足需求时使用重绘甚至重排的方式。
Chrome 中的合成技术,可以概括为:分层、分块 和 合成。分层和合成通常一起使用,类似 PhotoShop 里的图层和图层合并。页面实现一些复杂的动画效果等,如果没有采用分层机制,从布局树直接生成目标图片,当每次页面有很小的变化时都会触发重排或重绘机制,"牵一发而动全身"严重影响页面的渲染效率。Chrome 引入了分层和合成机制用于提升每帧的渲染效率,合成器只需要对相应图层操作,显卡处理这种合成时间非常短。
生成布局树后渲染引擎会将布局树转换为图层树(Layer Tree),并生成绘制指令列表,光栅化过程根据指令生成图片,合成线程将每个图层对应的图片合成为"一张"图片发送到后缓冲区,分层、合成完成。
注意: 合成操作是在渲染进程的合成线程上完成的,不影响主线程的执行,即使主线程卡住,CSS 动画依然能执行
通常页面比屏幕大得多,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,大大加速了页面显示速度。
即便如此,从计算机内存上传纹理到 GPU 内存的操作还是会比较慢,Chrome 在首次合成图块时会先使用一个低分辨率图片并显示,然后合成器继续绘制正常比例的网页内容,完成后替换当前显示的低分辨率内容。
使用 JavaScript 实现对某个元素的几何形状变换、透明度变换或一些缩放操作等效果,会涉及整个渲染流水线,效率低下;而使用 will-change
来提前告知渲染引擎,该元素会被单独实现一层,变换发生时,渲染引擎会通过合成线程直接处理变换。这些变换并没有涉及到主线程,大大提升了渲染效率,这也是 CSS 动画比 JavaScript 动画高效 的原因。
.box {
will-change: transform, opacity;
}
尽量使用 will-change 来处理可以仅使用合成线程的 CSS 特效或动画,形成独立的层,但同时也会增加内存占用,因为从层树开始,后续每个阶段都会多一个层结构,需要额外的内存。
指从请求发出到渲染出完整页面的过程,影响主要因素:网络、 JavaScript 脚本。
图片、音频、视频等文件不会阻塞页面的首次渲染,而 JavaScript、首次请求的 HTML 文件、CSS 文件会阻塞首次渲染(构建 DOM 需要 HTML 和 JavaScript 文件,构造渲染树需要 CSS 文件),称为关键资源,三个优化关键资源的方式:
触发异步样式下载: 为 media 属性设置一个不可用的值,如"none",当媒体查询结果值计算为 false,浏览器仍会下载样式表,但不会在渲染页面之前等待样式表的资源可用
<link rel="stylesheet" href="test.css" media="none" onload="if(media!='all')media='all'">
压缩、移除代码注释、变成非关键资源等
使用 CDN; 减少关键资源个数和大小搭配。关于 RTT (往返延迟) 详见《浏览器中的网络》一章。
指从页面加载完成到用户交互的过程,即渲染进程渲染帧的速度,影响主要因素:JavaScript 脚本。
和加载阶段不同的是,交互阶段没有了加载关键资源和构建 DOM、CSSOM 流程,大部分是由 JavaScript 通过修改 DOM 或者 CSSOM 触发交互动画的,另外一部分帧是由 CSS 触发的。如果在计算样式阶段发现有布局信息修改,会触发重排操作;若不涉及布局相关的调整,只是修改了颜色一类信息,就可以跳过布局阶段直接触发重绘操作;若通过 CSS 触发一些变形、渐变、动画等特效,只会触发合成线程上的合成操作,效率最高。优化单帧生成速度的方法:
避免单任务霸占主线程太久,将大任务分解为多个小任务,也可以使用 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)
}
}
合成动画在合成线程上执行,不影响主线程,还可以使用 will-change 标记生成单独图层。
垃圾回收占用主线程,可以尽可能优化储存结构,避免产生小颗粒对象,避免产生临时垃圾数据。
指用户发出关闭指令后页面所做的一些清理操作,一般无需优化。
通过 JavaScript 操纵 DOM 会影响整个渲染流水线,触发样式计算、布局、绘制、栅格化、合成等任务,牵一发而动全身,对 DOM 的不当操作还可能引发强制同步布局和布局抖动问题,尤其对复杂页面会造成性能问题。而通过 JavaScript 实现的更轻量的虚拟 DOM 可以解决上述问题。
React Fiber 更新机制: React 中 VDOM 的新算法
Fiber reconciler
使用协程解决了老的递归算法Stack reconciler
占用主线程过久的问题。
可以把虚拟 DOM 看成 MVC 的视图部分,其控制器和模型由 Redux 提供。控制器监听 DOM 变化并通知模型更新数据;模型数据更新后,控制器会通知视图进行更新;视图根据模型数据生成新虚拟 DOM 并与之前的虚拟 DOM 比较,找出变化节点一次性更新到真实 DOM 上,最后触发渲染流水线。
PWA(Progressive Web App) 旨在通过技术手段渐进式缩短和本地应用或小程序的差距,web 应用相对于本地应用缺少:
但未来真正决定 PWA 能否崛起的还是底层技术,比如页面渲染效率、对系统设备的支持程度、WebAssembly 等。
在 Service Worker 之前,WHATWG 小组推出过 App Cache 标准来缓存页面,但问题较多,最终废弃。2014年标准委员会提出了 Service Worker 的概念:在页面和网络模块之间增加一个拦截器,用于缓存和拦截请求。
Chrome 的 Web Worker 在渲染进程中开启一个新线程来执行和 DOM 无关的 JavaScript 脚本,并通过 postMessage
方法将执行的结果返回给主线程,避免 JavaScript 过多占用页面主线程。
Service Worker 在 Web Worker 的基础上增加储存功能,解决了 Web Worker 每次执行完脚本后退出不保存结果而导致的重复执行问题。此外,和 Web Worker 运行在单个页面的渲染进程中不同,Service Worker 运行在浏览器进程中,在整个浏览器生命周期内为所有的页面提供服务。
使用 Service Worker 接收服务器推送的消息并展示给用户,此时浏览器页面不需要启动。
Service Worker 需要站点支持 HTTPS 协议,还要同时支持 Web 页面默认的安全策略、储入同源策略、内容安全策略(CSP)等。
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 属性。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。