Appearance
浏览器渲染机制和优化
前面都是网络方面的优化,接下来看看浏览器渲染过程如何优化。
1. 服务端渲染 - 更快速地看到完整渲染的页面
什么是服务器端渲染 (SSR)?
服务器端渲染是一个相对的概念。
以 Vue 为例,默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。也就是需要执行 JS 文件之后,才知道页面内容是什么,这就是典型的客户端渲染。
它的特点是页面上呈现的内容,你在 html 源文件里里找不到。
html
<div id="app">
{{ message }}
</div>
js
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
当然也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记"激活"为客户端上完全可交互的应用程序。这就是服务端渲染。
为什么使用服务器端渲染 (SSR)?
与传统 SPA (单页应用程序 (Single-Page Application)) 相比,服务器端渲染 (SSR) 的优势主要在于:
- 更好的 SEO,由于搜索引擎爬虫抓取工具可以直接查看完全渲染的页面。
请注意,截至目前,Google 和 Bing 可以很好对同步 JavaScript 应用程序进行索引。在这里,同步是关键。如果你的应用程序初始展示 loading 菊花图,然后通过 Ajax 获取内容,抓取工具并不会等待异步完成后再行抓取页面内容。也就是说,如果 SEO 对你的站点至关重要,而你的页面又是异步获取内容,则你可能需要服务器端渲染(SSR)解决此问题。
- 更快的内容到达时间 (time-to-content),特别是对于缓慢的网络情况或运行缓慢的设备。无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以你的用户将会更快速地看到完整渲染的页面。通常可以产生更好的用户体验,并且对于那些「内容到达时间(time-to-content) 与转化率直接相关」的应用程序而言,服务器端渲染 (SSR) 至关重要。
不难看出,服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。这样当资源抵达浏览器时,它呈现的速度就快了。
当然服务端渲染也不是万能的,使用服务器端渲染 (SSR) 时还需要有一些权衡之处:
开发条件所限。浏览器特定的代码,只能在某些生命周期钩子函数 (lifecycle hook) 中使用;一些外部扩展库 (external library) 可能需要特殊处理,才能在服务器渲染应用程序中运行。
涉及构建设置和部署的更多要求。与可以部署在任何静态文件服务器上的完全静态单页面应用程序 (SPA) 不同,服务器渲染应用程序,需要处于 Node.js server 运行环境。
更多的服务器端负载。在 Node.js 中渲染完整的应用程序,显然会比仅仅提供静态文件的 server 更加大量占用 CPU 资源 (CPU-intensive - CPU 密集),因此如果你预料在高流量环境 (high traffic) 下使用,请准备相应的服务器负载,并明智地采用缓存策略。比如很多个用户请求,相当于现在把多个浏览器的渲染都给数量并不多的服务器来处理,服务器的压力就会很大。
在使用服务端渲染之前,应该问的第一个问题是,是否真的需要它。这主要取决于内容到达时间 (time-to-content) 对应用程序的重要程度。
2. 浏览器的渲染机制和如何优化
详细的渲染过程可以看 浏览器渲染过程
渲染流程
在浏览器中,页面的渲染经历了以下阶段:
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上
这渲染过程中需要关注几棵关键的树。
几棵关键的树
为了使渲染过程更明晰一些,我们需要给这些”树“们一个特写:
- DOM 树:解析 HTML 以创建的是 DOM 树(DOM tree ):渲染引擎开始解析 HTML 文档,转换树中的标签到 DOM 节点,它被称为“内容树”。
- CSSOM 树:解析 CSS(包括外部 CSS 文件和样式元素)创建的是 CSSOM 树。CSSOM 的解析过程与 DOM 的解析过程是并行的。
- 渲染树(布局树):CSSOM 与 DOM 结合,之后我们得到的就是渲染树(Render tree )。
- 布局渲染树:从根节点递归调用,计算每一个元素的大小、位置等,给每个节点所应该出现在屏幕上的精确坐标,我们便得到了基于渲染树的布局渲染树(Layout of the render tree)。
- 绘制渲染树: 遍历渲染树,每个节点将使用 UI 后端层来绘制。整个过程叫做绘制渲染树(Painting the render tree)。
所以整个渲染过程可以大致总结为:HTML 构建一个 DOM 树,这棵 DOM 树与 CSS 解释器解析出的 CSSOM 相结合,就有了布局渲染树。最后浏览器以布局渲染树为蓝本,去计算布局并绘制图像,页面的初次渲染就大功告成了。
之后每当一个新元素加入到这个 DOM 树当中,浏览器便会通过 CSS 引擎查遍 CSS 样式表,找到符合该元素的样式规则应用到这个元素上,然后再重新去绘制它。
这就引出了第一优化点————CSS 样式表规则优化
优化 1:基于渲染流程的 CSS 优化建议
CSS 引擎查找样式表,对每条规则都按从右到左的顺序去匹配。看如下规则:
css
#myList li {}
这种方式很常见,但是实际上很低效:我们以为会先根据 id 选择器 #myList
定位到元素,然后再去找它后代元素中的 li
元素。但是,因为 css 选择符是从右到左的,所以会先遍历页面上每个 li
元素,并且每次都要确认这个 li
元素的父元素 id 是不是 myList
,所以就非常低效了!
CSS 性能提升的方案主要有:
- 避免使用通配符,只对需要用到的元素进行选择。
- 关注可以通过继承实现的属性,避免重复匹配重复定义。
- 少用标签选择器。如果可以,用类选择器替代。例如
#myList li{}
改为.myList_li{}
。 - 不要画蛇添足,id 和 class 选择器不应该被多余的标签选择器拖后腿。例如
.myList#title
改为#title
。 - 减少嵌套。尽量将选择器的深度降到最低(最高不超过 3 层),尽可能使用类来关联每一个标签元素。
优化 2:CSS 和 JS 的加载顺序优化
CSS 的阻塞
DOM 和 CSSOM 合力才能构建渲染树,浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了。
开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSS 才出现,CSSOM 的构建才开始。很多时候,DOM 不得不等待 CSSOM。可以这样总结:
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。
所以事实上,现在很多团队都已经做到了尽早(将 CSS 放在 head 标签里)和尽快(启用 CDN 实现静态资源加载速度的优化)。
JS 的阻塞
在渲染过程中,虽然很少提及 JS。因为没有 JS,CSSOM 和 DOM 照样可以组成渲染树,页面依旧可以呈现,虽然毫无交互。
JS 的作用在于修改,包括内容、样式以及它如何响应用户交互。本质上都是对 DOM 和 CSSDOM 进行修改。因此 因此 JS 的执行会阻止 CSSOM,不作显式声明的情况下,它也会阻塞 DOM。
html
<!DOCTYPE html>
<html lang="en">
<head>
<title>JS阻塞测试</title>
<style>
#container {
background-color: yellow;
width: 100px;
height: 100px;
}
</style>
<script>
// 尝试获取container元素
var container = document.getElementById("container")
console.log('container', container)
</script>
</head>
<body>
<div id="container"></div>
<script>
// 尝试获取 container 元素
var container = document.getElementById("container")
console.log('container', container)
// 输出 container 元素此刻的背景色
console.log('container bgColor', getComputedStyle(container).backgroundColor)
</script>
<style>
#container {
background-color: blue;
}
</style>
</body>
</html>
最后的输出为:
// 获取 DOM 失败,说明 JS 执行阻塞了 DOM,后续的 DOM 没有构建
"container" null
// 获取 DOM 成功,说明 JS 只能获取在它前面构建好的元素,所以 JS 会阻塞 DOM
"container" "<div id="container"></div>"
// 获取到的颜色是 yellow,而不是最后设定的 blue,说明 JS 也会阻塞 CSSOM。
"container bgColor" "rgb(255, 255, 0)"
JS 引擎是独立于渲染引擎的,JS 代码在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script
标签时,它会暂停渲染过程,将控制权交给 JS 引擎。JS 引擎对内联的 JS 代码会直接执行,对外部 JS 文件还要先获取到脚本、再进行执行。
等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建。JS 阻塞 HTML 和 CSS,实际上是 JS 引擎抢走了渲染引擎的控制器。
JS 的 3 种加载方式:
- 正常模式:这种情况下 JS 会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。
html
<script src="index.js"></script>
- async 模式:async 模式下,JS 不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS 脚本会立即执行。
html
<script async src="index.js"></script>
- defer 模式:defer 模式下,JS 的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。
从应用的角度来说,一般当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async
;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用 defer
。
合理的使用 async/defer
,可以有效的提升性能。
优化 3:DOM 优化原理与基本实践
首先 DOM 为什么会慢?
JS 操作 DOM 要收费:把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。JS 引擎和渲染引擎(浏览器内核)是独立实现的,用 JS 去操作 DOM,本质上是 JS 引擎和渲染引擎之间进行了“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口作为“桥梁”。每操作一次 DOM(不管是为了修改还是仅仅为了访问其值),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题。因此要“减少 DOM 操作”。
对 DOM 的修改引发样式的更迭:很多时候,对 DOM 的操作都不会局限于访问,而是为了修改它。当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流或重绘。
要优化 DOM 了
知道 DOM 变慢的原因,接下来就要对症下药了:减少 DOM 操作,少交“过路费”,避免过度渲染。
通过一个例子:
html
<body>
<div id="container"></div>
</body>
假如有一个需求,往 container
元素里写 10000 句一样的话。那么如果这么做:
js
for(var count=0;count<10000;count++){
document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
}
这么代码就需要进行优化了:
- 过桥费太高了:每次循环都调用 DOM 接口获取
container
元素,也就是需要过 10000 次桥,费用很高。实际上可以用变量缓存的方式来处理,这样就可以只过 1 次桥:
js
let container = document.getElementById('container');
for(var count=0;count<10000;count++){
container.innerHTML+='<span>我是一个小测试</span>'
}
- 不必要的 DOM 更改太多了:在每次循环里,都修改了 DOM 树。前面说过,对 DOM 的修改会引起回流或者重绘的过程。这个过程性能开销非常昂贵,而这个操作,却循环执行了 10000 次。可以通过“就是论事”的方式减少不必要的渲染:
js
let container = document.getElementById('container')
let content = ''
// 先对内容进行操作
for(let count=0;count<10000;count++){
content += '<span>我是一个小测试</span>'
}
// 内容处理好了,最后再触发 DOM 的更改
container.innerHTML = content
所谓的“就事论事”,就是 JS 的事,JS 自己处理好,再去找 DOM。
说到这里,那就需要看看 DocumentFragment。
DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。它被作为一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的 XML 片段。最大的区别是因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。
在上面的例子中,变量 content
就扮演 DocumentFragment
的角色。它们本质是一样了,作为脱离真实 DOM 树的容器,用于缓存批量的 DOM 操作。
前面通过字符串拼接的方式,这样虽然可以,但是还是不过优雅。通过 DocumentFragment
可以帮助我们更加结构化的去实现,可以维持性能的同时,代码更加的优雅,可扩展性更高:
js
let container = document.getElementById('container')
// 创建一个 DocumentFragment 对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一个小测试'
content.appendChild(oSpan)
}
container.appendChild(content)
DocumentFragment
对象允许我们像操作真实 DOM 一样去调用各种各样的 DOM API,相比之前的实现,代码质量得到了保证。
优化 4:回流(Reflow)与重绘(Repaint)
当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。
要避免回流与重绘的发生,最直接的做法是避免掉可能会引发回流与重绘的 DOM 操作。
回流“导火索”
- 最“贵”的操作:改变 DOM 元素的几何属性。这个改变几乎可以说是“牵一发动全身”——当一个DOM元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)的几何属性都需要进行重新计算,它会带来巨大的计算量。
- “价格适中”的操作:改变 DOM 树的结构。这里主要指的是节点的增减、移动等操作。浏览器引擎布局的过程,顺序上可以类比于树的前序遍历,它是一个从上到下、从左到右的过程。通常在这个过程中,当前元素不会再影响其前面已经遍历过的元素。
- 最容易被忽略的操作:获取一些特定属性的值。offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,这些值有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。
重绘“导火索”
当页面中元素的样式改变并不影响它在文档流中的位置时(例如:color,background-color,visibility 等)浏览器会将新样式赋给元素并重新绘制它,就会导致重绘
规避回流和重绘
- 将“导火索”缓存起来,避免频繁改动
js
const el = document.getElementById('el')
// 实际类型的场景可能更加复杂
for(let i = 0; i < 10; i++) {
el.style.top = el.offsetTop + 10 + "px";
el.style.left = el.offsetLeft + 10 + "px";
}
每次都获取“敏感属性”,导致多次回流,可以这样优化:
js
// 缓存 offsetLeft 与 offsetTop 的值
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}
// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop + "px"
- 避免逐条改变样式,使用类名去合并样式
js
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
通过 class
优化:
css
.is-container {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
js
const container = document.getElementById('container')
container.classList.add('is-container')
- 将 DOM “离线”。将元素
display: none
,从页面上拿掉,那么后续的操作,就不会触发回流和重绘了。
js
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
进行离线优化:
js
const container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
container.style.display = 'block'
拿掉元素再放回去,虽然也会触发回流。但是拿下来之后,后面的操作都不会触发回流,所以每一步成本都会降低,所以还是值得的。