一、渲染优化
如果把浏览器呈现页面的整个过程一分为二,前面所讲的主要是浏览器为呈现页面请求所需资源的部分;本章将主要关注浏览器获取到资源后,进行渲染部分的相关优化内容。
在前面的前端页面的生命周期课程中,介绍过关键渲染路径的概念,浏览器通过这个过程对HTML,CSS, JavaScript等资源文件进行解析,然后组织渲染出最终的页面。本章将以此为基础,对渲染过程进行更深入的讨论,不仅包括打开一个网站的首次渲染,还有用户与页面进行交互后导致页面更改的渲染,即所谓的重绘与重排。其中除了对渲染过程的充分介绍,更重要的是对提升渲染过程性能的优化手段的探讨。
浏览器从获取 HTML 到最终在屏幕上显示内容需要完成以下步骤
- 处理 HTML 标记并构建 DOM 树。
- 处理 CSS 标记并构建 CSSOM 树。
- 将 DOM 与 CSSOM 合并成一个 render tree。
- 根据渲染树来布局,以计算每个节点的几何信息。
- 将各个节点绘制到屏幕上。
经过以上整个流程我们才能看见屏幕上出现渲染的内容,优化关键渲染路径就是指最大限度缩短执行上述第1步至第5步耗费的总时间,让用户最快的看到首次渲染的内容。
不但网站页面要快速加载出来,而且运行过程也应更顺畅,在响应用户操作时也要更加及时,比如我们通常使用手机浏览网上商城时,指尖滑动屏幕与页面滚动应很流畅,拒绝卡顿。那么要达到怎样的性能指标,才能满足用户流畅的使用体验呢?
目前大部分设备的屏幕分辨率都在60fps左右,也就是每秒屏幕会刷新60次,所以要满足用户的体验期望,就需要浏览器在渲染页面动画或响应用户操作时,每一帧的生成速率尽量接近屏幕的刷新率。若按照60fps来算,则留给每一帧画面的时间不到17ms,再除去浏览器对资源的一些整理工作,一帧画面的渲染应尽量在10ms内完成,如果达不到要求而导致帧率下降,则屏幕上的内容会发生抖动或卡顿。
为了使每一帧页面渲染的开销都能在期望的时间范围内完成,就需要开发者了解渲染过程的每个阶段,以及各阶段中有哪些优化空间是我们力所能及的。经过分析根据开发者对优化渲染过程的控制力度,可以大体将其划分为五个部分: JavaScript处理、计算样式、页面布局、绘制与合成,下面先简要介绍各部分的功能与作用。
- JavaScript处理:前端项目中经常会需要响应用户操作,通过JavaScript对数据集进行计算、操作DOM元素,并展示动画等视觉效果。当然对于动画的实现,除了JavaScript,也可以考虑使用如CSS Animations、Transitions等技术。
- 计算样式:在解析 CSS 文件后,浏览器需要根据各种选择器去匹配所要应用 CSS 规则的元素节点,然后计算出每个元素的最终样式。
- 页面布局:指的是浏览器在计算完成样式后,会对每个元素尺寸大小和屏幕位置进行计算。由于每个元素都可能会受到其他元素的影响,并且位于DOM树形结构中的子节点元素,总会受1到父级元素修改的影响,所以页面布局的计算会经常发生。
- 绘制:在页面布局确定后,接下来便可以绘制元素的可视内容,包括颜色、边框、阴影及文本和图像。
- 合成:通常由于页面中的不同部分可能被绘制在多个图层上,所以在绘制完成后需要将多个图层按照正确的顺序在屏幕上合成,以便最终正确地渲染出来。
二、优化渲染路径
CSS 是关键资源,它会阻塞关键渲染路径也并不奇怪,但通常并不是所有的 CSS 资源都那么的『关键』。
举个例子:一些响应式CSS只在屏幕宽度符合条件时才会生效,还有一些CSS只在打印页面时才生效。这些CSS在不符合条件时,是不会生效的,所以我们为什么要让浏览器等待我们并不需要的 CSS 资源呢?
针对这种情况,我们应该让这些非关键的 CSS 资源不阻塞渲染。
避免在 CSS 中使用 @import
三、优化javascript使用
下面有注释。
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<!-- <link href="style.css" rel="stylesheet"> -->
<!-- <link rel="stylesheet" href="main.css"> -->
<!-- 阻塞渲染 -->
<!-- <link href="style.css" rel="stylesheet"> -->
<!-- 非阻塞的加载 CSS -->
<!-- <link href="print.css" rel="stylesheet" media="print"> -->
<!-- 拆分媒体查询相关 CSS 资源:可变阻塞加载 -->
<!-- <link href="other.css" rel="stylesheet" media="(min-width: 40em)"> -->
<!-- <link href="portrait.css" rel="stylesheet" media="orientation:portrait"> -->
<title>Critical Path</title>
<!-- <style></style> -->
<!-- <script>
document.addEventListener('DOMContentLoaded', () => alert("DOM ready after defer!"));
</script> -->
<!-- defer 特性告诉浏览器不要等待脚本。相反,浏览器将继续处理 HTML,构建 DOM。脚本会“在后台”下载,然后等 DOM 构建完成后,脚本才会执行。 -->
<!-- <script defer src="index.js"></script>
<script defer src="index2.js"></script> -->
<!-- async 特性与 defer 有些类似。它也能够让脚本不阻塞页面。但是,在行为上二者有着重要的区别。 async是谁先加载完了 谁就执行,defer是即使后面先加载完 也要按顺序执行-->
<!-- <script async src="index.js"></script>
<script async src="index2.js"></script> -->
<!-- 利用空闲时间预加载指定的资源 -->
<!-- <link rel="preload" href="index.js">
<link rel="preload" href="index2.js"> -->
<!-- 预加载将来可能要用到的资源 -->
<link rel="prefetch" href="index.js">
<link rel="prefetch" href="index2.js">
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<!-- <div><img src="awesome-photo.jpg"></div> -->
<script src="index.js"></script>
<script src="index2.js"></script>
</body>
</html>
async是谁先加载完了 谁就执行
defer是即使后面先加载完 也要按顺序执行
四、使用requestAnimationFrame实现动画
实践经验告诉我们,使用定时器实现的动画会在一些低端机器上出现抖动或者卡顿的现象,这主要是因为浏览器无法确定定时器的回调函数的执行时机。以 setInterval 为例,其创建后回调任务会被放入异步队列,只有当主线程上的任务执行完成后,浏览器才会去检查队列中是否有等待需要执行的任务,如果有就从任务队列中取出执行,这样会使任务的实际执行时机比所设定的延迟时间要晚一些。
其次屏幕分辨率和尺寸也会影响刷新频率,不同设备的屏幕绘制频率可能会有所不同,而 setInterval 只能设置某个固定的时间间隔,这个间隔时间不一定与所有屏幕的刷新时间同步,那么导致动画出现随机丢帧也在所难免,如图所示。
为了避免这种动画实现方案中因丢帧而造成的卡顿现象,我们推荐使用 window. requestAnimationFrame 方法。与 setIntervall方法相比,其最大的优势是将回调函数的执行时机交由系统来决定,即如果屏幕刷新频率是 60Hz,则它的回调函数大约会每 16.7ms 执行一次,如果屏幕的刷新频率是 75Hz,则它回调函数大约会每 13.3ms执行一次,就是说 requestAnimationFrame方法的执行时机会与系统的刷l新频率同步。
这样就能保证回调函数在屏幕的每次刷新间隔中只被执行一次,从而避免因随机丢帧而造成的卡顿现象。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.box {
width: 100px;
height: 100px;
position: absolute;
background-color: skyblue;
}
</style>
</head>
<body>
<div class="box"></div>
<script>
const box = document.querySelector('.box')
// const id = setInterval(() => {
// if (box.offsetLeft >= 200) {
// box.style.left = '200px'
// window.clearInterval(id)
// return
// }
// box.style.left = `${box.offsetLeft + 1}px`
// }, 1000 / 60)
function run () {
if (box.offsetLeft >= 200) {
box.style.left = '200px'
return
}
box.style.left = `${box.offsetLeft + 1}px`
window.requestAnimationFrame(run)
}
window.requestAnimationFrame(run)
</script>
</body>
</html>
五、恰当使用 Web Worker
众所周知 JavaScript 是单线程执行的,所有任务放在一个线程上执行,只有当前一个任务执行完才能处理后一个任务,不然后面的任务只能等待,这就限制了多核计算机充分发挥它的计算能力。同时在浏览器上, JavaScript的执行通常位于主线程,这恰好与样式计算、页面布局及绘制一起,如果 JavaScript 运行时间过长,必然就会导致其他工作任务的阻塞而造成丢帧。
为此可将一些纯计算的工作迁移到Web Worker上处理,它为JavaScript的执行提供了多线程
环境,主线程通过创建出Worker子线程,可以分担一部分自己的任务执行压力。在 Worker 子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理UI交互,保证页面的使用体验流程。需要注意的是,Worker子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着Worker会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭。除此之外,在使用中还有以下几点应当注意。
- DOM限制: Worker无法读取主线程所处理网页的DOM对象,也就无法使用 document 、window 和 parent 等对象,只能访问navigator 和 location 对象。
- 文件读取限制:Worker 子线程无法访问本地文件系统,这就要求所加载的脚本来自网络。
- 通信限制:主线程和Worker子线程不在同一个上下文内,所以它们无法直接进行通信,只能通过消息来完成。
- 脚本执行限制:虽然Worker可以通过 XMLHTTPRequest对象发起ajax请求,但不能使用alert()方法和confirm()方法在页面弹出提示。
- 同源限制:Worker子线程执行的代码文件需要与主线程的代码文件同源。
Web Worker的使用方法非常简单,在主线程中通过 new Worker()方法来创建一个Worker子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,Worker就会失败。代码示例如下:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Worker</title>
</head>
<body>
<input type="number" id="num1" value="1">+
<input type="number" id="num2" value="2">
<button id="btn">=</button>
<strong id="result">0</strong>
<script>
const worker = new Worker('worker.js')
const num1 = document.querySelector('#num1')
const num2 = document.querySelector('#num2')
const result = document.querySelector('#result')
const btn = document.querySelector('#btn')
btn.addEventListener('click', () => {
worker.postMessage({
type: 'add',
data: {
num1: num1.value - 0,
num2: num2.value - 0
}
})
})
// 监听来自子线程的消息事件
worker.addEventListener('message', e => {
const { type, data } = e.data
if (type === 'add') {
result.textContent = data
}
})
</script>
</body>
</html>
worker.js
// 监听来自主线程的消息事件
onmessage = function (e) {
const { type, data } = e.data
if (type === 'add') {
const ret = data.num1 + data.num2
// 给主线程发布事件
postMessage({
type: 'add',
data: ret
})
// 关闭线程自己
self.close()
}
}