我们的页面需要交互流畅,交互流畅的判断标准是 60fps
目前大多数设备的屏幕刷新率为 60 次/秒,也就是 60fps
, 如果刷新率降低,也就是说出现了掉帧, 对于用户来说,就是出现了卡顿的现象。
其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此您的所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。
当浏览器拿到了html 文件,浏览器会解析文档,生成dom树,这个过程被称为parse html
上面的html代码,会被解析为下面的dom树:
当浏览器知道某些规则应用于相关元素后,就开始计算布局,也就是计算元素会占用多少空间,这个过程被称为回流,或者布局,或者layout, 或者 reflow。layout阶段元素之间会相互影响,所以这个阶段对于浏览器来说是比较费时间的。
下一个阶段,被称为重绘,或者repaint,也就是从矢量到光栅,可以理解为浏览器需要在屏幕上把图像画出来,我们屏幕是由一个个像素点组成的,所以就像下面一样:
浏览器的渲染过程如下图所示,每一个渲染阶段我们都可以有针对性的进行优化。
下面是每一个阶段优化的方法:
setTimeout(callback)
和 setInterval(callback)
无法保证 callback
函数的执行时机,如果恰好卡在16ms
即将渲染一次那一个时刻,则会导致这一帧丢掉。
requestAnimationFrame(callback)可以保证callback函数在每帧动画开始的时候执行。
// requestAnimationFrame将保证updateScreen函数在每帧的开始运行
requestAnimationFrame(updateScreen);
每帧的渲染应该在16ms内完成,JavaScript代码运行耗时应该控制在3-4毫秒。 特别耗时的纯计算工作,可以考虑放到Web Workers中执行。
var dataSortWorker = new Worker("sort-worker.js");
dataSortWorker.postMesssage(dataToSort);
// 主线程不受Web Workers线程干扰
dataSortWorker.addEventListener('message', function(evt) {
var sortedData = e.data;
// Web Workers线程执行结束
// ...
});
对于很多需要操作DOM元素的逻辑,可以考虑分步处理,把任务分为若干个小任务,每个任务都放到requestAnimationFrame中回调执行
var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);
function processTaskList(taskStartTime) {
var nextTask = taskList.pop();
// 执行小任务
processTask(nextTask);
if (taskList.length > 0) {
requestAnimationFrame(processTaskList);
}
}
style阶段确定每个DOM元素应该应用什么CSS规则。
过程:
- 根据css的规则建立一个树状索引。需要注意的是,css规则索引是从选择器的右侧开始。
- 遍历dom树,每一个dom节点都要走一边css索引,然后生成 parse tree
css规则索引是从选择器的右侧开始
div.container p {
color: red
}
解析上面的css规则时,从右边开始,先找到所有的p标签,再匹配这些所有的p标签中其父元素的类名是container的那个p元素。
<div>
<div class="container">
<ul>
<li class="item"></li>
<li class="item"></li>
</ul>
</div>
<ul>
<li></li>
<li></li>
</ul>
</div>
对于上面的html结构,下面哪种css选择器效率更高呢?
// 第一种
.container ul li {
}
// 第二种
.item {
}
很明显是第二种。第一种的选择器会先匹配所有的li,然后筛选出所有的li中带有父节点ul的,然后再筛选出父节点的父节点类名带有 container
的那个。
降低样式选择器的复杂度,尽量保持class的简短
.box:nth-last-child(-n+1) .title {
}
// 改善后
.final-box-title {
}
我们能在不同的文章中看到不同的名词: 布局
,layout
, 回流
, reflow
, 这些名词说的都是一回事,不同浏览器的叫法不同
几乎任何测量元素的宽度,高度,和位置的方法都会不可避免的触发reflow, 包括但是不限于:
- elem.getBoundingClientRect()
- window.getComputedStyle()
- window.scrollY
- and a lot more…
更详细的信息可以访问https://gist.github.com/paulirish/5d52fb081b3570c81e3a
布局的主要消耗性能的地方在于:1. 需要布局的DOM元素的数量;2. 布局过程的复杂程度
下面是会触发layout的操作:
- 增删改动dom,比如说动画
- 修改css
- 修改默认字体
- resize 窗口
老的布局模型以相对/绝对/浮动的方式将元素定位到屏幕上 Floxbox布局模型用流式布局的方式将元素定位到屏幕上,flex性能更好。
从触发源来说,又有两种情形会触发layout:
- 写重排,即每次尝试给元素的这些属性赋值会引起layout:
width
height
left
top
margin
padding
- 读重排,即每次尝试读取这些值的时候就会引起layout:
offsetTop
、offsetLeft
、offsetWidth
、offsetHeight
、scrollTop
、scrollLeft
、scrollWidth
、scrollHeight
、clientTop
、clientLeft
、clientWidth
、clientHeight
、(getComputedStyle() or currentStyle in IE)
写重排,浏览器不是立马执行的,而是先等一等,合并批量更新,但是任何一个读重排都会中断这个过程,浏览器会强制同步布局。
使用transform不会触发layout , 只会触发paint。
如果你想页面中做一些比较炫酷的效果,相信我,transform可以满足你的需求。
// 位置的变换
transform: translate(1px,2px)
// 大小的变换
transform: scale(1.2)
上文中说了,在浏览器中,页面内容是存储为由 Node 对象组成的树状结构,也就是 DOM
树。每一个 HTML element
元素都有一个 Node
对象与之对应。其实,从 DOM 树到最后的渲染,需要进行一些转换映射。
DOM 树每个 Node 节点都有一个对应的 LayoutObject 。LayoutObject 知道如何在屏幕上 paint Node 的内容。
有相同坐标的 LayoutObjects
,在同一个渲染层(PaintLayer)。PaintLayer 最初是用来实现 stacking contest
(层叠上下文)。 根据创建
PaintLayer 的原因不同,可以将其分为常见的 3 类:
-
NormalPaintLayer
- 根元素
- relative、fixed、sticky、absolute
- opacity 小于 1
- CSS 滤镜(fliter)
- 有 CSS mask 属性
- 有 CSS mix-blend-mode 属性(不为 normal)
- 有 CSS transform 属性(不为 none)
- backface-visibility 属性为 hidden
- 有 CSS reflection 属性
- 有 CSS column-count 属性(不为 auto)或者 有 CSS column-width 属性(不为 auto)
- 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画
-
OverflowClipPaintLayer
- overflow 不为 visible
-
NoPaintLayer
- 不需要 paint 的 PaintLayer,比如一个没有视觉属性(背景、颜色、阴影等)的空 div。
某些特殊的paintLayer会被当成合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。
每个 GraphicsLayer 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后 draw 到屏幕上,此时,我们的页面也就展现到了屏幕上。
渲染层提升为合成层的原因有一下几种:
-
直接原因
- 硬件加速的 iframe 元素(比如 iframe 嵌入的页面中有合成层
- video元素
- 3d transiform
- 在 DPI 较高的屏幕上,fix 定位的元素会自动地被提升到合成层中。但在 DPI 较低的设备上却并非如此
- backface-visibility 为 hidden
- 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition(需要注意的是 active 的 animation 或者 transition,当 animation 或者 transition 效果未开始或结束后,提升合成层也会失效)
- will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)
-
后代元素原因
- 有合成层后代同时本身有 transform、opactiy(小于 1)、mask、fliter、reflection 属性
- 有合成层后代同时本身 overflow 不为 visible(如果本身是因为明确的定位因素产生的 SelfPaintingLayer,则需要 z-index 不为 auto)
- 有合成层后代同时本身 fixed 定位
- 有 3D transfrom 的合成层后代同时本身有 preserves-3d 属性
- 有 3D transfrom 的合成层后代同时本身有 perspective 属性
-
overlap 重叠原因
为啥overlap 重叠也会造成提升合成层渲染? 图层之间有重叠关系,需要按照顺序合并图层。
使用 will-change
或者 transform3d
1. will-change: transform/opacity
2. transform3d(0,0,0,)
composite更详尽的知识可以了解下面这个博客: 《GPU Accelerated Compositing in Chrome》 http://www.chromium.org/developers/design-documents/gpu-accelerated-compositing-in-chrome
接下来,我们亲自去改造一个页面, 这个页面的地址是: https://mp.beibei.com/imp/2017/12/kanjia.html#/list
弹窗的动画为:每隔3秒进行向左侧滑动淡出,然后再滑动重新淡入,更新文本为“**砍价9元”
之前的滑动和淡出的效果是通过vue提供的 <transision>
来实现的
当我们想要用到过渡效果,会在vue中写这样的代码:
<transition name="toggle">
<div class="test">
</transition>
但是其实渲染到浏览器中的代码,会依次是下面这样的:
// 过渡进入开始的一瞬间
<div class="test toggle-enter">
// 过渡进入的中间阶段
<div class="test toggle-enter-active">
// 过渡进入的结束阶段
<div class="test toggle-enter-active toggle-enter-to">
// 过渡淡出开始的一瞬间
<div class="test toggle-leave">
// 过渡淡出的中间阶段
<div class="test toggle-leave-active">
// 过渡淡出的结束阶段
<div class="test toggle-leave-active toggle-leave-to">
也就是说,过渡效果的实现,是通过不停的修改、增加、删除该dom节点的class来实现。
一方面, v-if
会修改dom节点的结构,修改dom节点会造成浏览器重走一遍 layout
阶段,也就是重排。另一方面,dom节点的class被不停的修改,也会导致浏览器的重排现象,因此页面性能会比较大的受到影响。
若页面中 <transition>
控制的节点过多时,页面的性能就会比较受影响。
为了证明,下面代码模拟了一种极端的情况:
<div v-for="n in testArr">
<transition name="toggle">
<div class="info-block" v-if="isShow"></div>
</transition>
</div>
export default {
data () {
return {
isShow: false,
testArr: 1000
}
},
methods: {
toggle() {
var self = this;
setInterval(function () {
self.isShow = !self.isShow
}, 1000)
}
},
mounted () {
this.toggle()
}
}
.toggle-show-enter {
transform: translate(-400px,0);
}
.toggle-show-enter-active {
color: white;
}
.toggle-show-enter-to {
transform: translate(0,0);
}
.toggle-show-leave {
transform: translate(0,0);
}
.toggle-show-leave-to {
transform: translate(-400px,0);
}
.toggle-show-leave-active {
color: white;
}
上面的代码在页面中渲染了 1000
个过渡的元素,这些元素会在1秒的时间内从左侧划入,然后划出。
此时,我们打开google浏览器的开发者工具,然后在 performance
一栏中记录分析性能,如下图所示:
可以发现,页面明显掉帧了。在7秒内,总共 scripting
的阶段为3秒, rendering
阶段为1956毫秒。
事实上,这种跑马灯式的重复式效果,通过 animation
的方式也可以轻松实现。 我们优化上面的代码,改为下面的代码,通过 animation
动画来控制过渡:
<div v-for="n in testArr">
<div class="info-block"></div>
</div>
export default {
data () {
return {
isShow: false,
testArr: 1000
}
}
}
.info-block {
background-color: red;
width: 300px;
height: 100px;
position: fixed;
left: 10px;
top: 200px;
display: flex;
align-items: center;
justify-content: center;
animation: toggleShow 3s ease 0s infinite normal;
}
@keyframes toggleShow {
0% {
transform: translate(-400px);
}
10% {
transform: translate(0,0);
}
80% {
transform: translate(0,0);
}
100% {
transform: translate(-400px);
}
}
打开浏览器的开发者工具,可以在 performance
里面看到,页面性能有了惊人的提升:
为了进一步提升页面的性能,我们给过渡的元素增加一个 will-change
属性,该元素就会被提升到 合成层
用GPU单独渲染,这样页面性能就会有更大的提升。
为了更显著的看出 will-change
带来的性能提升,我们把页面中渲染的过渡节点提升到 10000
个。在节点没有添加 will-change
属性时,页面的fps在1~5左右徘徊。
增加了 will-change
属性之后,fps稳定在15~20之间。
综上,我们使用 animation
代替 <transition>
,同时使用 will-change
,页面的性能有了非常明显的提升。
基于以上的思路,我们对https://mp.beibei.com/imp/2017/12/kanjia.html#/list 这个页面的代码尝试修改。
这个是修改之前的渲染结果:
这个是修改之后的渲染结果:
该页面使用了懒加载,通过看懒加载库的代码,懒加载是通过绑定 scroll
事件一个回调事件,每一次调用一次回调事件,就会测量一次元素的位置,调用 getBoundingClientRect()
方法,从而计算出是否元素出现在了可视区。
// 懒加载库中的代码,判断是否进入了可视区
const isInView = (el, threshold) => {
const {top, height} = el.getBoundingClientRect()
return top < clientHeight + threshold && top + height > -threshold
}
scroll
事件会被重复的触发,每触发一次就要测量一次元素的尺寸和位置。尽管对 scroll
的事件进行了节流的处理,但在低端安卓机上仍然会出现滑动不流畅的现象。
优化的思路是通过新增的api—— IntersectionObserver
来获取元素是否进入了可视区。
intersection observer api
可以去测量某一个dom节点和其他节点,甚至是viewport的距离。
这个是实验性的api,你应该查阅https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Browser_compatibility 查看其兼容性
在过去,检测一个元素是否在可视区内,或者两个元素之间的距离如何,是一个非常艰巨的任务。 但获取这些信息是非常必要的:
- 用于懒加载
- 用于无限加载,就是微博那种刷到底接着请求新数据可以接着刷
- 检测广告的可见性
在过去,我们需要不断的调用 Element.getBoundingClientRect()
方法去获取到我们想拿到的信息,然而这些代码会造成性能问题。
intersection observer api
可以注册回调函数,当我们的目标元素,进入指定区域(比如说viewport,或者其他的元素)时,回调函数会被触发;
var handleFun = function() {}
var boxElement = document.getElementById()
var options = {
root: null,
rootMargin: "0px",
threshold: 0.01
};
observer = new IntersectionObserver(handleFunc, options);
observer.observe(boxElement);
尝试封装了一个基于IntersectionObserver的懒加载的库。
html
<img class="J_lazy-load" data-imgsrc="burger.png">
你也许注意到上面的代码中,图片文件没有 src 属性么。这是因为它使用了称为 data-imgsrc 的 data 属性来指向图片源。我们将使用这来加载图片
js
function lazyLoad(domArr) {
if ('IntersectionObserver' in window) {
let createObserver = (dom) => {
var fn = (arr) => {
let target = arr[0].target
if (arr[0].isIntersecting) {
let imgsrc = target.dataset.imgsrc
if (imgsrc) {
target.setAttribute('src', imgsrc)
}
// 解除绑定观察
observer.unobserve(dom)
}
}
var config = {
root: null,
rootMargin: '10px',
threshold: 0.01
}
var observer = new IntersectionObserver(fn, config)
observer.observe(dom)
}
Array.prototype.slice(domArr)
domArr.forEach(dom => {
createObserver(dom)
})
}
}
这个库的使用也非常简单:
// 先引入
import {lazyLoad} from '../util/lazyload.js'
// 进行懒加载
let domArr = document.querySelectorAll('.J_lazy-load')
lazyLoad(domArr)
然后测试一下,发现可以正常使用:
这个动图太大了加载不出来,sad http://h0.hucdn.com/open/201825/9e5dfa5954ac4545_1604x1300.gif
传统的懒加载 lazy-loder 的页面性能如下:
在12秒内,存在红颜色的掉帧现象,一些地方的帧率偏低(在devtool里面是fps的绿色小山较高的地方),用于 scripting
阶段的总共有600多ms.
使用intersetctionObserver之后的懒加载性能如下:
在12秒内,帧率比较平稳,用于 scripting
阶段的时间只有60多ms了。