关注公众号 前端开发博客,回复“加群”
加入我们一起学习,天天进步
作者:晨风明悟
链接:https://zhuanlan.zhihu.com/p/39878259
性能优化
上文简单介绍了浏览器渲染流程上的各个组成部分,下面我们通过像素管道来研究如何优化视觉变化效果所引发的更新。
像素管道
JavaScript。一般来说,我们会使用 JavaScript 来实现一些视觉变化的效果。比如用 jQuery 的 animate 函数做一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。当然,除了 JavaScript,还有其他一些常用方法也可以实现视觉变化效果,比如:CSS Animations、Transitions 和 Web Animation API。样式计算。此过程是根据匹配选择器(例如 .headline 或 .nav > .nav__item)计算出哪些元素应用哪些 CSS 规则的过程。从中知道规则之后,将应用规则并计算每个元素的最终样式。布局。在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的。绘制。绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。合成。由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。
渲染时的每一帧都会经过管道的各部分进行处理,但并不意味着所有的部分都会执行。实际上,在实现视觉变化效果时,管道针对指定帧通常有三种方式:
JS / CSS > 样式 > 布局 > 绘制 > 合成
如果你修改一个 DOM 元素的 Layout 属性,也就是改变了元素的样式(比如 width、height 或者 position 等),那么浏览器会检查哪些元素需要重新布局,然后对页面激发一个 reflow(重排)过程完成重新布局。被 reflow(重排)的元素,接下来也会激发绘制过程,最后激发渲染层合并过程,生成最后的画面。
JS / CSS > 样式 > 绘制 > 合成
如果你修改一个 DOM 元素的 Paint Only 属性,比如背景图片、文字颜色或阴影等,这些属性不会影响页面的布局,因此浏览器会在完成样式计算之后,跳过布局过程,只会绘制和渲染层合并过程。
JS / CSS > 样式 > 合成
如果你修改一个非样式且非绘制的 CSS 属性,那么浏览器会在完成样式计算之后,跳过布局和绘制的过程,直接做渲染层合并。这种方式在性能上是最理想的,对于动画和滚动这种负荷很重的渲染,我们要争取使用第三种渲染过程。
影响 Layout、Paint 和 Composite 的属性都可以通过 CSS Triggers 网站查阅。
刷新率
上面提到每一帧都要经过像素管道处理,也就是说每一帧都是一次重新渲染。我们需要引出另外一个概念:刷新率。
刷新率是一秒钟能够重新渲染多少次数的指标。目前大多数设备的屏幕刷新率为 60 次/秒;因此如果在页面中有动画、渐变、滚动效果,那么浏览器每一次重新渲染的时间间隔必须跟设备的每一次刷新保持一致,才能比较流畅。需要注意的是,大多数浏览器也会对重新渲染的时间间隔进行限制,因为即使超过屏幕刷新率,用户体验也不会提升。
刷新率(Hz)取决与显示器的硬件水平。帧率(FPS)取决于显卡或者软件制约。
每次重新渲染的时间不能超过 16.66 ms(1 秒 / 60 次)。但实际上,浏览器还有很多整理工作,因此我们的所有工作最好在 10 毫秒之内完成。如果超过时间,刷新率下降,就会导致页面抖动,感觉卡顿。
优化 JavaScript 执行
JavaScript 是触发视觉变化的主要因素,时机不当或长时间运行的 JavaScript 可能是导致性能下降的常见原因。针对 JavaScript 的执行,下面有一些常用的优化措施。
window.requestAnimationFrame
在没有 requestAnimationFrame
方法的时候,执行动画,我们可能使用 setTimeout
或 setInterval
来触发视觉变化;但是这种做法的问题是:回调函数执行的时间是不固定的,可能刚好就在末尾,或者直接就不执行了,经常会引起丢帧而导致页面卡顿。
归根到底发生上面这个问题的原因在于时机,也就是浏览器要知道何时对回调函数进行响应。setTimeout
或 setInterval
是使用定时器来触发回调函数的,而定时器并无法保证能够准确无误的执行,有许多因素会影响它的运行时机,比如说:当有同步代码执行时,会先等同步代码执行完毕,异步队列中没有其他任务,才会轮到自己执行。并且,我们知道每一次重新渲染的最佳时间大约是 16.6 ms,如果定时器的时间间隔过短,就会造成 过度渲染,增加开销;过长又会延迟渲染,使动画不流畅。
requestAnimationFrame
方法不同与 setTimeout
或 setInterval
,它是由系统来决定回调函数的执行时机的,会请求浏览器在下一次重新渲染之前执行回调函数。无论设备的刷新率是多少,requestAnimationFrame
的时间间隔都会紧跟屏幕刷新一次所需要的时间;例如某一设备的刷新率是 75 Hz,那这时的时间间隔就是 13.3 ms(1 秒 / 75 次)。需要注意的是这个方法虽然能够保证回调函数在每一帧内只渲染一次,但是如果这一帧有太多任务执行,还是会造成卡顿的;因此它只能保证重新渲染的时间间隔最短是屏幕的刷新时间。
requestAnimationFrame
方法的具体说明可以看 MDN 的相关文档,下面通过一个网页动画的示例来了解一下如何使用。
let offsetTop = 0;
const div = document.querySelector(".div");
const run = () => {
div.style.transform = `translate3d(0, ${offsetTop += 10}px, 0)`;
window.requestAnimationFrame(run);
};
run();
如果想要实现动画效果,每一次执行回调函数,必须要再次调用 requestAnimationFrame
方法;与 setTimeout
实现动画效果的方式是一样的,只不过不需要设置时间间隔。
参考资料
被誉为神器的requestAnimationFrame
requestAnimationFrame 知多少?
浅析 requestAnimationFrame
告别定时器,走向 window.requestAnimationFrame()
requestAnimationFrame 性能更好
谈谈requestAnimationFrame的动画循环
window.requestIdleCallback
requestIdleCallback
方法只在一帧末尾有空闲的时候,才会执行回调函数;它很适合处理一些需要在浏览器空闲的时候进行处理的任务,比如:统计上传、数据预加载、模板渲染等。
以前如果需要处理复杂的逻辑,不进行分片,用户界面很可能就会出现假死状态,任何的交互操作都将无效;这时使用 setTimeout
就可以把任务拆分成多个模块,每次只处理一个模块,这样能很大程度上缓解这个问题。但是这种方式具有很强的不确定性,我们不知道这一帧是否空闲,如果已经塞满了一大堆任务,这时在处理模块就不太合适了。因此,在这种情况下,我们也可以使用 requestIdleCallback
方法来尽可能高效地利用空闲来处理分片任务。
如果一直没有空闲,requestIdleCallback
就只能永远在等待状态吗?当然不是,它的参数除了回调函数之外,还有一个可选的配置对象,可以使用 timeout
属性设置超时时间;当到达这个时间,requestIdleCallback
的回调就会立即推入事件队列。来看下如何使用:
// 任务队列 const tasks = [
() => {
console.log("第一个任务");
},
() => {
console.log("第二个任务");
},
() => {
console.log("第三个任务");
},
];
// 设置超时时间 const rIC = () => window.requestIdleCallback(runTask, {timeout: 3000})
function work() {
tasks.shift()();
}
function runTask(deadline) {
if (
(
deadline.timeRemaining() > 0 ||
deadline.didTimeout
) &&
tasks.length > 0
) {
work();
}
if (tasks.length > 0) {
rIC();
}
}
rIC();
回调函数参数的详细说明可以查看 MDN 的文档。
改变 DOM
不应该在 requestIdleCallback
方法的回调函数中改变 DOM。我们来看下在某一帧的末尾,回调函数被触发,它在一帧中的位置:
回调函数安排在帧提交之后,也就是说这时渲染已经完成了,布局已经重新计算过;如果我们在回调中改变样式,并且在下一帧中读取布局信息,那之前所作的所有布局计算全都浪费掉了,浏览器会强制重新进行布局计算,这也被称为 强制同步布局。
如果真的想要修改 DOM,那么最佳实践是:在 requestIdleCallback
的回调中构建 Document Fragment,然后在下一帧的 requestAnimationFrame
回调进行真实的 DOM 变动。
Fiber
React 16 推出了新的协调器,Fiber Reconciler(纤维协调器)。它和原先 Stack Reconciler(栈协调器)不同的是:整个渲染过程不是连续不中断完成的;而是进行了分片,分段处理任务,这就需要用到 requestIdleCallback
和 requestAnimationFrame
方法来实现。requestIdleCallback
负责低优先级的任务,requestAnimationFrame
负责动画相关的高优先级任务。
参考资料
requestIdleCallback-后台任务调度
你应该知道的requestIdleCallback
使用requestIdleCallback
React Fiber初探 —— 调和(Reconciliation)
Web Worker
JavaScript 采用的是单线程模型,也就是说,所有任务都要在一个线程上完成,一次只能执行一个任务。有时,我们需要处理大量的计算逻辑,这是比较耗费时间的,用户界面很有可能会出现假死状态,非常影响用户体验。这时,我们就可以使用 Web Worker 来处理这些计算。
Web Worker 是 HTML5 中定义的规范,它允许 JavaScript 脚本运行在主线程之外的后台线程中。这就为 JavaScript 创造了 多线程 的环境,在主线程,我们可以创建 Worker 线程,并将一些任务分配给它。Worker 线程与主线程同时运行,两者互不干扰。等到 Worker 线程完成任务,就把结果发送给主线程。
Web Worker 与其说创造了多线程环境,不如说是一种回调机制。毕竟 Worker 线程只能用于计算,不能执行更改 DOM 这些操作;它也不能共享内存,没有 线程同步 的概念。
Web Worker 的优点是显而易见的,它可以使主线程能够腾出手来,更好的响应用户的交互操作,而不必被一些计算密集或者高延迟的任务所阻塞。但是,Worker 线程也是比较耗费资源的,因为它一旦创建,就一直运行,不会被用户的操作所中断;所以当任务执行完毕,Worker 线程就应该关闭。
Web Workers API
一个 Worker 线程是由 new
命令调用 Worker()
构造函数创建的;构造函数的参数是:包含执行任务代码的脚本文件,引入脚本文件的 URI 必须遵守同源策略。
Worker 线程与主线程不在同一个全局上下文中,因此会有一些需要注意的地方:
两者不能直接通信,必须通过消息机制来传递数据;并且,数据在这一过程中会被复制,而不是通过 Worker 创建的实例共享。详细介绍可以查阅 worker中数据的接收与发送:详细介绍。
不能使用 DOM、
window
和parent
这些对象,但是可以使用与主线程全局上下文无关的东西,例如WebScoket
、indexedDB
和navigator
这些对象,更多能够使用的对象可以查看Web Workers可以使用的函数和类。
使用方式
Web Worker 规范中定义了两种不同类型的线程;一个是 Dedicated Worker(专用线程),它的全局上下文是 DedicatedWorkerGlobalScope 对象;另一个是 Shared Worker(共享线程),它的全局上下文是 SharedWorkerGlobalScope 对象。其中,Dedicated Worker 只能在一个页面使用,而 Shared Worker 则可以被多个页面共享。
下面我来简单介绍一下使用方式,更多的 API 可以查看 使用 Web Workers。
专用线程
下面代码最重要的部分在于两个线程之间怎么发送和接收消息,它们都是使用 postMessage
方法发送消息,使用 onmessage
事件进行监听。区别是:在主线程中,onmessage
事件和 postMessage
方法必须挂载在 Worker 的实例上;而在 Worker 线程,Worker 的实例方法本身就是挂载在全局上下文上的。
<!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>Web Worker 专用线程</title>
</head>
<body>
<input type="text" name="" id="number1">
<span>+</span>
<input type="text" name="" id="number2">
<button id="button">确定</button>
<p id="result"></p>
<script src="./main.js"></script>
</body>
</html>
// main.js
const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");
// 1. 指定脚本文件,创建 Worker 的实例
const worker = new Worker("./worker.js");
button.addEventListener("click", () => {
// 2. 点击按钮,把两个数字发送给 Worker 线程
worker.postMessage([number1.value, number2.value]);
});
// 5. 监听 Worker 线程返回的消息
// 我们知道事件有两种绑定方式,使用 addEventListener 方法和直接挂载到相应的实例
worker.addEventListener("message", e => {
result.textContent = e.data;
console.log("执行完毕");
})
// worker.js
// 3. 监听主线程发送过来的消息
onmessage = e => {
console.log("开始后台任务");
const result= +e.data[0]+ +e.data[1];
console.log("计算结束");
// 4. 返回计算结果到主线程
postMessage(result);
}
共享线程
共享线程虽然可以在多个页面共享,但是必须遵守同源策略,也就是说只能在相同协议、主机和端口号的网页使用。
示例基本上与专用线程的类似,区别是:
创建实例的构造器不同。
主线程与共享线程通信,必须通过一个确切打开的端口对象;在传递消息之前,两者都需要通过
onmessage
事件或者显式调用start
方法打开端口连接。而在专用线程中这一部分是自动执行的。
// main.js
const number1 = document.querySelector("#number1");
const number2 = document.querySelector("#number2");
const button = document.querySelector("#button");
const result = document.querySelector("#result");
// 1. 创建共享实例 const worker = new SharedWorker("./worker.js");
// 2. 通过端口对象的 start 方法显式打开端口连接,因为下文没有使用 onmessage 事件 worker.port.start();
button.addEventListener("click", () => {
// 3. 通过端口对象发送消息 worker.port.postMessage([number1.value, number2.value]);
});
// 8. 监听共享线程返回的结果 worker.port.addEventListener("message", e => {
result.textContent = e.data;
console.log("执行完毕");
});
// worker.js
// 4. 通过 onconnect 事件监听端口连接 onconnect = function (e) {
// 5. 使用事件对象的 ports 属性,获取端口 const port = e.ports[0];
// 6. 通过端口对象的 onmessage 事件监听主线程发送过来的消息,并隐式打开端口连接 port.onmessage = function (e) {
console.log("开始后台任务");
const result= e.data[0] * e.data[1];
console.log("计算结束");
console.log(this);
// 7. 通过端口对象返回结果到主线程 port.postMessage(result);
}
}
参考资料
优化 JavaScript 执行 —— 降低复杂性或使用 Web Worker
使用 Web Workers
深入 HTML5 Web Worker 应用实践:多线程编程
JS与多线程
防抖和节流函数
在进行改变窗口大小、滚动网页、输入内容这些操作时,事件回调会十分频繁的被触发,严重增加了浏览器的负担,导致用户体验非常糟糕。此时,我们就可以考虑采用防抖和节流函数来处理这类调动频繁的事件回调,同时它们也不会影响实际的交互效果。
我们先来简单了解一下这两个函数:
防抖(debounce)函数。在持续触发事件时,并不执行事件回调;只有在一段时间之内,没有再触发事件的时候,事件回调才会执行一次。
节流(throttle)函数。在持续触发事件时,事件回调也会不断的间隔一段时间后执行一次。
这两个函数最大的区别在于执行的时机,防抖函数会在事件触发停止一段时间后执行事件回调;而节流函数会在事件触发时不断的间隔一段时间后执行事件回调。我们用定时器来简单实现一下这两个函数,详细版本可以参考 Underscore 和 Lodash —— debounce、Lodash —— throttle。节流函数其实在浏览器拥有 requestAnimationFrame
方法之后,使用这个方法调用事件回调会更好一些。
实现防抖函数
每次执行到 debounce
返回的函数,都先把上一个定时器清理掉,再重新运行一个定时器;等到最后一次执行这个返回的函数的时候,定时器不会被清理,就可以正常等待定时器结束,执行事件回调了。
function debounce(func, wait) {
let timeout = null;
return function run(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
}
};
实现节流函数
在定时器存在的时候,不在重新生成定时器;等到定时器结束,事件回调执行,就把定时器清空;在下一次执行 throttle
返回的函数的时候,再生成定时器,等待下一个事件回调执行。
function throttle(func, wait) {
let timeout = null;
return function run(...args) {
if (!timeout) {
timeout = setTimeout(() => {
timeout = null;
func.apply(this, args);
}, wait);
}
}
}
参考资料
JS的防抖与节流
使输入处理程序去除抖动
Underscore
Lodash —— debounce
Lodash —— throttle
降低 Style 的复杂性
我们知道 CSS 最重要的组成部分是选择器和声明,所以我会通过这两方面来讲解如何降低 Style 的复杂性。
避免选择器嵌套
我们在 CSSOM Tree 这一节中了解到:嵌套的选择器会从右向左匹配,这是一个递归的过程,而递归是一种比较耗时的操作。更不用说一些 CSS3 的选择器了,它们会需要更多的计算,例如:
.text:nth-child(2n) .strong {
/* styles */
}
为了确定哪些节点应用这个样式,浏览器必须先询问这是拥有 "strong" class
的节点吗?其父节点恰好是偶数的 "text" class
节点吗?如此多的计算过程,都可以通过一个简单的 class
来避免:
.text-even-strong {
/* styles */
}
这么简单的选择器,浏览器只要匹配一次就可以了。为了准确描述网页结构、可复用和代码共享等方面的考虑,我们可以使用 BEM 来协助开发。
BEM(块,元素,修饰符)
BEM 简单来讲就是一种 class
的命名规范,它建议所有元素都有单个类,并且嵌套也能够很好的组织在类中:
.nav {}
.nav__item {}
如果节点需要与其他节点进行区分,就可以加入修饰符来协助开发:
.nav__item--active {}
更为详细的描述和用法可以查看 Get BEM。
使用开销更小的样式
因为屏幕显示效果的不同,所以浏览器渲染每一个样式的开销也会不一样。例如,绘制阴影肯定要比绘制普通背景的时间要长。我们来对比下这两者之间的开销。
<!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">
<style>
.simple {
background-color: #f00;
}
.complex {
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.5);
}
</style>
<title>性能优化</title>
</head>
<body>
<div class="container"></div>
<script>
const div = document.querySelector(".container");
let str = "";
for (let i = 0; i < 1000; i++) {
str += "<div class="simple">background-color: #f00;</div>";
// str += "<div class="complex">box-shadow: 0, 4px, 4px, rgba(0,0,0,0.5);</div>"; }
div.innerHTML = str;
</script>
</body>
</html>
可以看到阴影的 Layout 是 31.35 ms,paint 是 6.43 ms;背景的 Layout 是 10.81 ms,paint 是 4.30 ms。Layout 的差异还是相当明显的。
因此,如果可能,还是应该使用开销更小的样式替代当前样式实现最终效果。
参考资料
缩小样式计算的范围并降低其复杂性
CSS BEM 书写规范
最小化重排(Reflow)和重绘(Repaint)
首先我们先来了解一下什么是重排和重绘。
重排是指因为修改 style 或调整 DOM 结构重新构建部分或全部 Render Object Tree 从而计算布局的过程。这一过程至少会触发一次,既页面初始化。
重绘是指重新绘制受影响的部分到屏幕。
观察像素通道会发现重绘不一定会触发重排,比如改变某个节点的背景色,只会重新绘制这个节点,而不会发生重排,这是因为布局信息没有发生变化;但是重排是一定会触发重绘的。
下面的情况会导致重排或者重绘:
调整 DOM 结构
修改 CSS 样式
用户事件,如页面滚动,改变窗口大小等
浏览器优化策略
重排和重绘会不断触发,这是不可避免的。但是,它们非常消耗资源,是导致网页性能低下的根本原因。
提高网页性能,就是要降低重排和重绘的频率和成本,尽可能少的触发重新渲染。
浏览器面对集中的 DOM 操作时会有一个优化策略:创建一个变化的队列,然后一次执行,最终只渲染一次。
div2.style.height = "100px";
div2.style.width = "100px";
上面的代码在浏览器优化后只会执行一次渲染。但是,如果代码写得不好变化的队列就会立即刷新,并进行渲染;这通常是在修改 DOM 之后,立即获取样式信息的时候。下面的样式信息会触发重新渲染:
offsetTop/offsetLeft/offsetWidth/offsetHeight
scrollTop/scrollLeft/scrollWidth/scrollHeight
clientTop/clientLeft/clientWidth/clientHeight
getComputedStyle()
提高性能的技巧
多利用浏览器优化策略。相同的 DOM 操作(读或写),应该放在一起。不要在读操作中间插入写操作。
不要频繁计算样式。如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次使用的时候,再进行重排。
// Bad const div1 = document.querySelector(".div1");
div1.style.height = div1.clientHeight + 200 + "px";
div1.style.width = div1.clientHeight * 2 + "px";
// Good const div2 = document.querySelector(".div2");
const div2Height = div1.clientHeight + 200;
div2.style.height = div2Height + "px";
div2.style.width = div2Height * 2 + "px";
不要逐条改变样式。通过改变
className
或cssText
属性,一次性改变样式。
// Bad const top = 10;
const left = 10;
const div = document.querySelector(".div");
div.style.top = top + "px";
div.style.left = left + "px";
// Good div.className += "addClass";
// Good div.style.cssText += "top: 10px; left: 10px";
使用离线 DOM。离线意味着不对真实的节点进行操作,可以通过以下方式实现:
操纵 Document Fragment 对象,完成后再把这个对象加入 DOM Tree
使用
cloneNode
方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点将节点设为
display: none;
(需要一次重排),然后对这个节点进行多次操作,最后恢复显示(需要一次重排)。这样一来,就用两次重排,避免了更多次的重新渲染。将节点设为
visibility: hidden;
和设为display: none;
是类似的,但是这个属性只对重绘有优化,对重排是没有效果的,因为它只是隐藏,但是节点还在文档流中的。设置
position: absolute | fixed;
。节点会脱离文档流,这时因为不用考虑这个节点对其他节点的影响,所以重排的开销会比较小。使用虚拟 DOM,例如 Vue、React 等。
使用 flexbox 布局。flexbox 布局的性能要比传统的布局模型高得多,下面是对 1000 个
div
节点应用float
或flex
布局的开销对比。可以发现,对于相同数量的元素和相同视觉的外观,flex
布局的开销要小得多(float 37.92 ms | flex 13.16 ms)。
参考资料
网页性能管理详解
渲染优化:重排重绘与硬件加速
浏览器渲染流程 详细分析
CSS Animation性能优化
相关文章
最后
关注公众号:前端开发博客,后台回复以下关键字:
回复「1024」领取前端进阶资料
回复「电子书」领取海量面试和JS资料
回复「资料」领取前端群分享及培训机构的资料
回复「Vue」获取 Vue 精选文章
回复「面试」获取 面试 精选文章
回复「JS」获取 JavaScript 精选文
回复「CSS」获取 CSS 精选文章
“在看”吗?在看就点一下吧