# 优化页面渲染
- CSS 放前面,JS 放后面
- 懒加载(图片懒加载、下拉加载更多)
- 减少 DOM 查询,对 DOM 查询做缓存
- 减少 DOM 操作,多个操作尽量合并在一起执行(
DocumentFragment
) - 事件节流
- 尽早执行操作(
DOMContentLoaded
) - 使用 SSR 后端渲染,数据直接输出到 HTML 中,减少浏览器使用 JS 模板渲染页面 HTML 的时间
# 使用 SSR 后端渲染
可一次性输出 HTML 内容,不用在页面渲染完成之后,再通过 Ajax 加载数据、再渲染。例如使用 smarty、Vue SSR 等。
# CSS 放前面,JS 放后面
述浏览器渲染过程时已经提过,不再赘述。
# 懒加载
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的 src 属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为 src 属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
一开始先给为 src
赋值成一个通用的预览图,下拉时候再动态赋值成正式的图片。如下,preview.png
是预览图片,比较小,加载很快,而且很多图片都共用这个preview.png
,加载一次即可。待页面下拉,图片显示出来时,再去替换src
为data-realsrc
的值。
<img src="preview.png" data-realsrc="abc.png" />
另外,这里为何要用data-
开头的属性值?—— 所有 HTML 中自定义的属性,都应该用data-
开头,因为data-
开头的属性浏览器渲染的时候会忽略掉,提高渲染性能。
# 优化 DOM 操作,减少回流和重绘
回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。
由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大。但这两个说到底都是吃性能的,所以都不是什么善茬。我们在开发中,要从代码层面出发,尽可能把回流和重绘的次数最小化。
# 合并 DOM 操作
我们来看这样一段 HTML 内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>DOM操作测试</title>
</head>
<body>
<div id="container"></div>
</body>
</html>
此时我有一个假需求——我想往 container 元素里写 10000 句一样的话。如果我这么做:
for (var count = 0; count < 10000; count++) {
document.getElementById("container").innerHTML += "<span>我是一个小测试</span>";
}
这段代码有两个明显的可优化点。
第一点,过路费交太多了。我们每一次循环都调用 DOM 接口重新获取了一次 container 元素,相当于每次循环都交了一次过路费。前后交了 10000 次过路费,但其中 9999 次过路费都可以用缓存变量的方式节省下来:
// 只获取一次container
let container = document.getElementById("container");
for (let count = 0; count < 10000; count++) {
container.innerHTML += "<span>我是一个小测试</span>";
}
第二点,不必要的 DOM 更改太多了。我们的 10000 次循环里,修改了 10000 次 DOM 树。我们前面说过,对 DOM 的修改会引发渲染树的改变、进而去走一个(可能的)回流或重绘的过程,而这个过程的开销是很“贵”的。这么贵的操作,我们竟然重复执行了 N 多次!其实我们可以通过就事论事的方式节省下来不必要的渲染:
let container = document.getElementById("container");
let content = "";
for (let count = 0; count < 10000; count++) {
// 先对内容进行操作
content += "<span>我是一个小测试</span>";
}
// 内容处理好了,最后再触发DOM的更改
container.innerHTML = content;
所谓“就事论事”,就像大家所看到的:JS 层面的事情,JS 自己去处理,处理好了,再来找 DOM 打报告。
事实上,考虑 JS 的运行速度,比 DOM 快得多这个特性。我们减少 DOM 操作的核心思路,就是让 JS 去给 DOM 分压。
这个思路,在 DOM Fragment 中体现得淋漓尽致。
DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的 XML 片段。因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题。
在我们上面的例子里,字符串变量 content 就扮演着一个 DOM Fragment 的角色。其实无论字符串变量也好,DOM Fragment 也罢,它们本质上都作为脱离了真实 DOM 树的容器出现,用于缓存批量化的 DOM 操作。
前面我们直接用 innerHTML 去拼接目标内容,这样做固然有用,但却不够优雅。相比之下,DOM Fragment 可以帮助我们用更加结构化的方式去达成同样的目的,从而在维持性能的同时,保住我们代码的可拓展和可维护性。我们现在用 DOM Fragment 来改写上面的例子:
let container = document.getElementById("container");
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment();
for (let count = 0; count < 10000; count++) {
// span此时可以通过DOM API去创建
let oSpan = document.createElement("span");
oSpan.innerHTML = "我是一个小测试";
// 像操作真实DOM一样操作DOM Fragment对象
content.appendChild(oSpan);
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content);
我们运行这段代码,可以得到与前面两种写法相同的运行结果。
# 哪些实际操作会导致回流与重绘
要避免回流与重绘的发生,最直接的做法是避免掉可能会引发回流与重绘的 DOM 操作,就好像拆弹专家在解决一颗炸弹时,最重要的是掐灭它的导火索。
触发重绘的“导火索”比较好识别——只要是不触发回流,但又触发了样式改变的 DOM 操作,都会引起重绘,比如背景色、文字色、可见性(可见性这里特指形如 visibility: hidden 这样不改变元素位置和存在性的、单纯针对可见性的操作,注意与 display:none 进行区分)等。为此,我们要着重理解一下那些可能触发回流的操作。
# 回流的“导火索”
- 最“贵”的操作:改变 DOM 元素的几何属性
这个改变几乎可以说是“牵一发动全身”——当一个 DOM 元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)的几何属性都需要进行重新计算,它会带来巨大的计算量。
常见的几何属性有 width、height、padding、margin、left、top、border 等等。此处不再给大家一一列举。有的文章喜欢罗列属性表格,但我相信我今天列出来大家也不会看、看了也记不住(因为太多了)。我自己也不会去记这些——其实确实没必要记,️ 一个属性是不是几何属性、会不会导致空间布局发生变化,大家写样式的时候完全可以通过代码效果看出来。多说无益,还希望大家可以多写多试,形成自己的“肌肉记忆”。
- “价格适中”的操作:改变 DOM 树的结构
这里主要指的是节点的增减、移动等操作。浏览器引擎布局的过程,顺序上可以类比于树的前序遍历——它是一个从上到下、从左到右的过程。通常在这个过程中,当前元素不会再影响其前面已经遍历过的元素。
- 最容易被忽略的操作:获取一些特定属性的值
当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,你就要注意了!
“像这样”的属性,到底是像什么样?——这些值有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。
除此之外,当我们调用了 getComputedStyle 方法,或者 IE 里的 currentStyle 时,也会触发回流。原理是一样的,都为求一个“即时性”和“准确性”。
# 如何规避回流与重绘
了解了回流与重绘的“导火索”,我们就要尽量规避它们。但很多时候,我们不得不使用它们。当避无可避时,我们就要学会更聪明地使用它们。
# 将“导火索”缓存起来,避免频繁改动
有时我们想要通过多次计算得到一个元素的布局位置,我们可能会这样做:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style>
#el {
width: 100px;
height: 100px;
background-color: yellow;
position: absolute;
}
</style>
</head>
<body>
<div id="el"></div>
<script>
// 获取el元素
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";
}
</script>
</body>
</html>
这样做,每次循环都需要获取多次“敏感属性”,是比较糟糕的。我们可以将其以 JS 变量的形式缓存起来,待计算完毕再提交给浏览器发出重计算请求:
// 缓存offsetLeft与offsetTop的值
const el = document.getElementById("el");
let offLeft = el.offsetLeft,
offTop = el.offsetTop;
// 在JS层面进行计算
for (let i = 0; i < 10; i++) {
offLeft += 10;
offTop += 10;
}
// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px";
el.style.top = offTop + "px";
# 避免逐条改变样式,使用类名去合并样式
比如我们可以把这段单纯的代码:
const container = document.getElementById("container");
container.style.width = "100px";
container.style.height = "200px";
container.style.border = "10px solid red";
container.style.color = "red";
优化成一个有 class 加持的样子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById("container");
container.classList.add("basic_style");
</script>
</body>
</html>
前者每次单独操作,都去触发一次渲染树更改,从而导致相应的回流与重绘过程。
合并之后,等于我们将所有的更改一次性发出,用一个 style 请求解决掉了。
# 将 DOM “离线”
我们上文所说的回流和重绘,都是在“该元素位于页面上”的前提下会发生的。一旦我们给元素设置 display: none,将其从页面上“拿掉”,那么我们的后续操作,将无法触发回流与重绘——这个将元素“拿掉”的操作,就叫做 DOM 离线化。
仍以我们上文的代码片段为例:
let container = document.getElementById("container");
container.style.width = "100px";
container.style.height = "200px";
container.style.border = "10px solid red";
container.style.color = "red";
// ...(省略了许多类似的后续操作)
离线化后就是这样:
let 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";
有的同学会问,拿掉一个元素再把它放回去,这不也会触发一次昂贵的回流吗?这话不假,但我们把它拿下来了,后续不管我操作这个元素多少次,每一步的操作成本都会非常低。当我们只需要进行很少的 DOM 操作时,DOM 离线化的优越性确实不太明显。一旦操作频繁起来,这“拿掉”和“放回”的开销都将会是非常值得的。
# 节流
考虑一个场景,滚动事件中会发起网络请求,但是我们并不希望用户在滚动过程中一直发起请求,而是隔一段时间发起一次,对于这种情况我们就可以使用节流。
理解了节流的用途,我们就来实现下这个函数
// func是用户传入需要防抖的函数
// wait是等待时间
const throttle = (func, wait = 50) => {
// 上一次执行该函数的时间
let lastTime = 0;
return function(...args) {
// 当前时间
let now = +new Date();
// 将当前时间和上一次执行函数时间对比
// 如果差值大于设置的等待时间就执行函数
if (now - lastTime > wait) {
lastTime = now;
func.apply(this, args);
}
};
};
setInterval(
throttle(() => {
console.log(1);
}, 500),
1,
);
# 防抖
考虑一个场景,有一个按钮点击会触发网络请求,但是我们并不希望每次点击都发起网络请求,而是当用户点击按钮一段时间后没有再次点击的情况才去发起网络请求,对于这种情况我们就可以使用防抖。
理解了防抖的用途,我们就来实现下这个函数
// func是用户传入需要防抖的函数
// wait是等待时间
const debounce = (func, wait = 50) => {
// 缓存一个定时器id
let timer = 0;
// 这里返回的函数是每次用户实际调用的防抖函数
// 如果已经设定过定时器了就清空上一次的定时器
// 开始一个新的定时器,延迟执行用户传入的方法
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, wait);
};
};
# 性能优化怎么做
上面提到的都是性能优化的单个点,性能优化项目具体实施起来,应该按照下面步骤推进:
- 建立性能数据收集平台,摸底当前性能数据,通过性能打点,将上述整个页面打开过程消耗时间记录下来
- 分析耗时较长时间段原因,寻找优化点,确定优化目标
- 开始优化
- 通过数据收集平台记录优化效果
- 不断调整优化点和预期目标,循环 2~4 步骤
性能优化是个长期的事情,不是一蹴而就的,应该本着先摸底、再分析、后优化的原则逐步来做。
← 减少页面体积,提升网络加载 设计模式 →