性能优化之关于像素管道(二)

像素管道,这个和咱们写代码息息相关的东西,我估计不少人都不太清楚它是个什么,网上也有几篇文章关于它的内容,可是不是那么尽如人意,那么我就详细说说这个东西,以及如何优化它。css

关于动画加载与人们的反应

一个流畅的动画关乎用户体验(留存)html

延迟 用户反应
0 - 16 毫秒 大部智能设备的刷新率都是 60HZ,也就是每帧 16 毫秒
(包括浏览器将新帧绘制到屏幕上所需的时间),
留给应用大约 10 毫秒的时间来生成一帧。
0 - 100 毫秒 在此时间窗口内响应用户操做,他们会以为能够当即得到结果。
时间再长,操做与反应之间的链接就会中断。
100 - 300 毫秒 用户会遇到轻微可觉察的延迟。
300 - 1000 毫秒 在此窗口内,延迟感受像是任务天然和持续发展的一部分。
对于网络上的大多数用户,加载页面或更改视图表明着一个任务。
1000+ 毫秒 超过 1 秒,用户的注意力将离开他们正在执行的任务。
10,000+ 毫秒 拜拜
  • 对于一个动做的响应,我建议通常在 100 毫秒内解决,这适用于大多数输入,无论他们是在点击按钮、切换表单控件仍是启动动画。
  • 对于须要超过 500 毫秒才能完成的操做,请始终提供反馈,例如 Loading

关于像素管道

从纯粹的数学角度而言,每帧的预算约为 16 毫秒(1000 毫秒 / 60 帧 = 16.66 毫秒/帧)。 但由于浏览器须要花费时间将新帧绘制到屏幕上,只有 10 毫秒来执行代码git

若是没法符合此预算,帧率将降低,而且内容会在屏幕上抖动。 此现象一般称为卡顿,会对用户体验产生负面影响。github

而浏览器花费时间进行绘制的过程就是执行像素管道的过程。npm

什么是像素管道

一个经典的图:浏览器

像素管道

s上图就是一个像素管道,这就是像素绘制到屏幕上的关键点。sass

  • JavaScript(代码变更)。通常来讲,咱们会使用 JavaScript 来实现一些视觉变化的效果。好比用 jQuery 的 animate 函数作一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。固然,除了 JavaScript,还有其余一些经常使用方法也能够实现视觉变化效果,好比:CSS Animations、Transitions 和 Web Animation API。
  • Style(样式计算)。此过程就是利用 CSS 匹配器计算出元素的变化,再进行计算每一个元素的最终样式。
  • Layout(布局计算)。当 Style 规则应用后,浏览器会开始计算其在屏幕上显示的位置和占据的空间大小,然而一个元素的变更可能会影响到另一个元素,从而引发重排,因此布局变更是很频繁的,这一过程常常发生。
  • Paint(绘制)。绘制就是简单的像素填充,会将排列好的样式进行填充。其包括文本、颜色、图片、边框、阴影等任何可视部分。由于网页样式是个层级结构,因此绘制操做会在每一层进行。
  • Composite(合成)。由于层级缘由,当层级绘制完成,为了确保层级结构的正确,合成操做会按照正确的层级顺序绘制到屏幕上,以便保证渲染的正确性,由于一个小小的层级顺序错误,就有可能形成样式紊乱。

管道的每一个部分都有可能会产生卡顿,因此咱们务必要知道哪一部分出现了问题,对症下药。网络

因为如今浏览器的更新,许多浏览器已经可以将绘制样式变更和页面绘制分开线程进行渲染,这已经不是咱们可以控制的了,可是不管怎么变更,管道始终要进行,不必定每帧都老是会通过管道每一个部分的处理。实际上,无论是使用 JavaScriptCSS 仍是网络动画,在实现视觉变化时,管道针对指定帧的运行一般有三种方式。框架

管道运行方式

JS/CSS —> Style —> Layout —> Paint —> Composite

像素管道

此过程就是咱们常说的浏览器重排,也就是改变了元素的几何属性(例如宽度、高度、左侧或顶部位置等),那么浏览器将必须检查全部其余元素,而后“自动重排”页面。任何受影响的部分都须要从新绘制,并且最终绘制的元素需进行合成,重排进行了管道的每一步,性能受到较大影响。异步

JS/CSS —> Style —> Paint —> Composite

这既是咱们常说的重绘,也就是修改“paint only”属性(例如背景图片、文字颜色或阴影等),即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。

JS/CSS —> Style —> Composite

此过程不会重排重绘,仅仅是进行合成,也就是修改 transformopacity 属性更改来实现动画,性能获得较大提高,最适合于应用生命周期中的高压力点,例如动画或滚动。

若是你想知道更改任何指定 CSS 属性将触发上述三个版本中的哪个,请点击这里

上面列出的各项管道工做在计算开销上有所不一样,一些任务比其余任务的开销要大,因此接下来,让咱们深刻了解此管道的各个不一样部分。

我会以一些常见问题为例,阐述如何诊断和修正它们。

如何优化

JS/CSS(代码变更)

咱们使用 JS 来改变样式是最为常见的,对于经过 JS 来改变更画,有如下几点须要注意:

  • 动画效果尽可能使用 requestAnimationFrame 而不是使用 setTimeout 或者 setInterval
  • 因为 JS 是单线程运行,请将须要耗费大量时间运行的任务放到 Web Worker 进行执行。
  • 请使用微任务来进行 DOM 更改,若是你不了解什么是微任务,请点击这里
  • 使用 Chrome DevToolsTimelineJavaScript 分析器来评估 JavaScript 的影响

使用requestAnimationFrame

大多时候,我想大部分人执行一个动画效果都会用到 setTimeout 或者 setInterval 这两个函数,可是这两个函数和requestAnimationFrame 有什么区别呢,彷佛用下来感受差很少?

这要从屏幕刷新率提及。

屏幕刷新频率

屏幕刷新频率,即图像在屏幕上更新的速度,也即屏幕上的图像每秒钟出现的次数,它的单位是赫兹(Hz)。 对于通常笔记本电脑,这个频率大概是60Hz。

所以,当你对着电脑屏幕什么也不作的状况下,显示器也会以每秒60次的频率正在不断的更新屏幕上的图像。为何你感受不到这个变化? 那是由于人的眼睛有视觉停留效应,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了,这中间只间隔了16.7ms(1000/60≈16.7), 因此会让你误觉得屏幕上的图像是静止不动的。而屏幕给你的这种感受是对的,试想一下,若是刷新频率变成1次/秒,屏幕上的图像就会出现严重的闪烁,这样就很容易引发眼睛疲劳、酸痛和头晕目眩等症状。

动画原理

根据上面的原理咱们知道,你眼前所看到图像正在以每秒60次的频率刷新,因为刷新频率很高,所以你感受不到它在刷新。而动画本质就是要让人眼看到图像被刷新而引发变化的视觉效果,这个变化要以连贯的、平滑的方式进行过渡。 那怎么样才能作到这种效果呢?

刷新频率为60Hz的屏幕每16.7ms刷新一次,咱们在屏幕每次刷新前,将图像的位置向左移动一个像素,即1px。这样一来,屏幕每次刷出来的图像位置都比前一个要差1px,所以你会看到图像在移动;因为咱们人眼的视觉停留效应,当前位置的图像停留在大脑的印象还没消失,紧接着图像又被移到了下一个位置,所以你才会看到图像在流畅的移动,这就是视觉效果上造成的动画。

setTimeoutsetInterval

理解了上面的概念之后,咱们不难发现,setTimeout 其实就是经过设置一个间隔时间来不断的改变图像的位置,从而达到动画效果的。但咱们会发现,利用seTimeout实现的动画在某些配置较低的机器上会出现卡顿、抖动的现象。 这种现象的产生有两个缘由:

  • setTimeout的执行时间并非肯定的。在Javascript中, setTimeout 任务被放进了异步队列中,只有当主线程上的任务执行完之后,才会去检查该队列里的任务是否须要开始执行,所以 setTimeout 的实际执行时间通常要比其设定的时间晚一些。
  • 刷新频率受屏幕分辨率和屏幕尺寸的影响,所以不一样设备的屏幕刷新频率可能会不一样,而 setTimeout 只能设置一个固定的时间间隔,这个时间不必定和屏幕的刷新时间相同。

以上两种状况都会致使setTimeout的执行步调和屏幕的刷新步调不一致,从而引发丢帧现象。 那为何步调不一致就会引发丢帧呢?

  • 第0ms: 屏幕未刷新,等待中,setTimeout也未执行,等待中;

  • 第10ms: 屏幕未刷新,等待中,setTimeout开始执行并设置图像属性left=1px;

  • 第16.7ms: 屏幕开始刷新,屏幕上的图像向左移动了1px, setTimeout 未执行,继续等待中;

  • 第20ms: 屏幕未刷新,等待中,setTimeout开始执行并设置left=2px;

  • 第30ms: 屏幕未刷新,等待中,setTimeout开始执行并设置left=3px;

  • 第33.4ms:屏幕开始刷新,屏幕上的图像向左移动了3px, setTimeout未执行,继续等待中;

从上面的绘制过程当中能够看出,屏幕没有更新left=2px的那一帧画面,图像直接从1px的位置跳到了3px的的位置,这就是丢帧现象,这种现象就会引发动画卡顿。

requestAnimationFrame

setTimeout相比,requestAnimationFrame最大的优点是由系统来决定回调函数的执行时机。具体一点讲,若是屏幕刷新率是60Hz,那么回调函数就每16.7ms被执行一次,若是刷新率是75Hz,那么这个时间间隔就变成了1000/75=13.3ms,换句话说就是,requestAnimationFrame的步伐跟着系统的刷新步伐走。它能保证回调函数在屏幕每一次的刷新间隔中只被执行一次,这样就不会引发丢帧现象,也不会致使动画出现卡顿的问题。

除此以外,requestAnimationFrame还有如下两个优点:

  • CPU节能:使用setTimeout实现的动画,当页面被隐藏或最小化时,setTimeout 仍然在后台执行动画任务,因为此时页面处于不可见或不可用状态,刷新动画是没有意义的,彻底是浪费CPU资源。而requestAnimationFrame则彻底不一样,当页面处理未激活的状态下,该页面的屏幕刷新任务也会被系统暂停,所以跟着系统步伐走的requestAnimationFrame也会中止渲染,当页面被激活时,动画就从上次停留的地方继续执行,有效节省了CPU开销。

  • 函数节流:在高频率事件(resize,scroll等)中,为了防止在一个刷新间隔内发生屡次函数执行,使用requestAnimationFrame可保证每一个刷新间隔内,函数只被执行一次,这样既能保证流畅性,也能更好的节省函数执行的开销。一个刷新间隔内函数执行屡次时没有意义的,由于显示器每16.7ms刷新一次,屡次绘制并不会在屏幕上体现出来。

Web Worker

因为 JavaScript 是单线程的,遇到大量计算问题会使整个页面卡住,形成页面十分卡顿的感受,在许多状况下,能够将纯计算工做移到 Web Worker,例如,若是它不须要 DOM 访问权限。数据操做或遍历(例如排序或搜索)每每很适合这种模型,加载和模型生成也是如此。

可是,因为 Web Worker 不能访问 DOM,若是您的工做必须在主线程上执行,请考虑一种批量方法,将大型任务分割为微任务,每一个微任务所占时间不超过几毫秒,而且在每帧的 requestAnimationFrame 处理程序内运行,而且,您将须要使用进度或活动指示器来确保用户知道任务正在被处理,从而有助于主线程始终对用户交互做出快速响应。

避免微优化 JavaScript

我知道许多人对优化有着极致的追求,可能一个函数比另一个函数快上 10 倍,好比请求元素的 offsetTop 比计算 getBoundingClientRect() 要快,可是,每帧调用这类函数的次数几乎老是不多,通常只能节省零点几毫秒的时间。

固然我并非说这样作很差,可是这花费的精力和得到的提高相比起来很不值得,也就是说,花费了大力气修改,可能界面毫无变化,还会破坏代码的结构性,我建议,代码结构性和稳定性的重要性远远大于微优化。

Style(样式计算)

你们都清楚重排和重绘这两个词,改变 DOM 结构就会致使浏览器从新计算元素样式,在不少状况下还会对整个页面或页面的一部分进行布局(即自动重排)。这就是所谓的样式的计算。

计算样式实际上分为两个步骤:

  1. 建立一组匹配选择器(浏览器计算出给指定元素应用哪些类、伪选择器和 ID)
  2. 从匹配选择器中获取全部样式规则,并计算出此元素的最终样式

用于计算某元素计算样式的时间中大约有 50% 用来匹配选择器,而另外一半时间用于从匹配的规则中构建

这一节其实没什么好写的,其实就两点须要注意一下:

  • 下降选择器的复杂性
  • 减小必须计算其样式的元素数量

下降选择器的复杂性

例如:

.box:nth-last-child(-n+1) .title {
  /* styles */
}
复制代码

这个 class ,浏览器会查找这是否为有 title 类的元素,其父元素刚好是负第 N 个子元素加上 1 个带 box 类的元素? 计算此结果可能须要大量时间,具体取决于所用的选择器和相应的浏览器。 改成这样可能会更好:

.final-box-title {
  /* styles */
}
复制代码

固然,有些样式必不可免会使用到第一种写法,可是我建议,尽可能少用这种写法。

举个具体的栗子:

这是页面上的元素:

<div class="box"></div>
<div class="box"></div>
<div class="box b-3"></div>
复制代码

这是写的 css 选择器

.box:nth-child(3)
.box .b-3
复制代码

查找的元素越多,查找的花费时间越多。 若是 .box:nth-child(3) 花费时间是 2ms, .box .b-3 花费时间是 1ms,若是有 100 个元素,.box:nth-child(3) 花费 200ms,.box .b-3 花费 100ms,时间差距就出来了。

整体来讲,计算元素的计算样式的最糟糕的开销状况是元素数量乘以选择器数量,由于须要对照每一个样式对每一个元素都检查至少一次,看它是否匹配。

因此请尽可能减小无效的 class,可能写在页面上不会形成任何影响,可是这会给浏览器形成负担,因此我建议使用 BEM 命名规范。

建议使用 BEM

BEM(块、元素、修饰符)之类的编码方法实际上归入了上述选择器匹配的性能优点,由于它建议全部元素都有单个类,而且在须要层次结构时也归入了类的名称:

.list { }
.list__list-item { }
复制代码

若是须要一些修饰符,像在上面咱们想为最后一个子元素作一些特别的东西,就能够按以下方式添加:

.list__list-item--last-child {}
复制代码

sass 则能够更好的组织 BEM

.list {
  &__list-item {
    &--last-child {}
  }
}
复制代码

Layout(布局)

布局的过程是上就是重排的过程,重排几乎将整个页面从新计算布局,开销之大显而易见。 DOM 的数量以及复杂性将影响到性能。

如下几点建议可让咱们优化布局:

  • 尽量避免布局操做
  • 使用 flexbox 而不是浮动布局
  • 避免强制同步布局
  • 避免布局抖动

避免强制同步布局

强制同步布局(Forced Synchronous Layout),发生的缘由在于在 JavaScript 代码阶段触发了 Layout 部分的 CSS 属性。 例如:读取某个元素的 offsetWidth 值,就会强迫浏览器在此帧就必须更新,浏览器会当即计算样式和布局,而后更新视图,此刻,浏览器会进入读取数据/写入数据的循环中。 用一张图表示:

Forced Synchronous Layout

因为比较抽象,来举个具体的栗子:

栗子 1:

divs.forEach(function(elem, index, arr) {
  if (window.scrollY < 200) {
    element.style.opacity = 0.5;
  }
});
复制代码

读取 window.scrollY 值会形成 Layout,接着设置透明度,会形成浏览器 读取(读取 scrollY)/ 写入(opacity),forEach 致使浏览器会一直循环进行这种强制同步操做。 修改以下:

const positionY = window.scrollY;

divs.forEach(function(elem, index, arr) {
  if (positionY < 200) {
    element.style.opacity = 0.5;
  }
});
复制代码

先预读取 scrollY 的值,再进行循环写入操做。

栗子 2:

divs.forEach(function(elem, index, arr) {
  if (elem.offsetHeight < 500) {
    elem.style.maxHeight = '100vh';
  }
});
复制代码

同上一个问题,读取 offsetHeight,写入 maxHeight

修改以下:

if (elem.offsetHeight < 500) { // 先读取属性值
  divs.forEach(function(elem, index, arr) { // 再更新样式
    elem.style.maxHeight = '100vh';
  });
}
复制代码

栗子 3:

var newWidth = container.offsetWidth;

divs.forEach(function(elem, index, arr) {
  element.style.width = newWidth;
});
复制代码

这个栗子是正确的,没问题。

避免布局抖动

不断的强制同步会致使布局抖动。

下面一个栗子,点击 click 以后将蓝色宽度设置为和绿色相同:

Layout Thrashing Demo

代码以下:

const paragraphs = document.querySelectorAll('p');
const clickme = document.getElementById('clickme');
const greenBlock = document.getElementById('block');

clickme.onclick = function(){
  greenBlock.style.width = '600px';

  for (let p = 0; p < paragraphs.length; p++) {
    let blockWidth = greenBlock.offsetWidth;
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};
复制代码

你们看看有什么问题?

问题就在于,循环读取了 greenBlock.offsetWidth,致使浏览器不断进行样式计算和布局计算,将此刻的值赋予 paragraphs[p].style.width,强迫在此帧得到更新值,并作样式更新,至关于到 Layout 步骤取值,而后打断,下一个循环继续。

Layout Thrashing

Forced Reflow is a likely performance bottleneck.

Details 上能够看到「Forced reflow is a likely performance bottleneck.」

解决方法很简单:

clickme.onclick = function(){
  greenBlock.style.width = '600px';
  const blockWidth = greenBlock.offsetWidth;

  for (let p = 0; p < paragraphs.length; p++) {
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};
复制代码

提早取出 greenBlock.offsetWidth,而后再批量写入。

Paint(绘制)

绘制是填充像素的过程,像素最终合成到用户的屏幕上。它每每是管道中运行时间最长的任务。

这过程当中其实什么好说的,就如下几点须要注意一下:

  • transformopacity 属性以外,更改任何属性始终都会触发绘制。
  • 绘制一般是像素管道中开销最大的部分
  • 经过层(z-index)的提高和动画的编排来减小绘制区域

Composite(合成)

合成是像素管道的最后一环,合成是将页面的已绘制部分放在一块儿以在屏幕上显示的过程。

此方面有两个关键因素影响页面的性能:须要管理的合成器层数量,以及您用于动画的属性。

  • z-index 层数过多会占用更多的内存,请合理分配
  • 坚持使用 transformopacity 属性更改来实现动画,这不会触发重排和重绘。
  • 使用 will-changetranslateZ 提高移动的元素。

总结

从整篇文章看,Paint(绘制) 和 Composite(合成)是说的最少的内容,由于这一部分仅仅是须要注意的点。

总有人问,从 JavaScriptCSS 哪一个入手,性能会更好一点?

其实从像素管道的角度看,改变 Layout 的成本是比较高的,不管你是使用 JavaScript 仍是 CSS

在编写 JavaScript 代码时,不经意间可能会形成强制同步和布局抖动。

其实完成一个项目的优化不是刻意进行的,而是在一点一滴编码过程当中积累进行的,使优化成为你的习惯,写出的代码天然就有了优化的内容。

最后很差意思推广一下我基于 Taro 框架写的组件库:MP-ColorUI

能够顺手 star 一下我就很开心啦,谢谢你们。

点这里是文档

点这里是 GitHUb 地址

相关文章
相关标签/搜索