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

2,513 阅读18分钟

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

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

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

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

关于像素管道

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

如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。

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

什么是像素管道

一个经典的图:

像素管道

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

  • 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 地址