Web性能优化-渲染阶段优化(六)

1,598 阅读14分钟

概览

60fps 与设备刷新率

目前大多数设备的屏幕刷新率为 60 次/秒。其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动。 此现象通常称为卡顿,会对用户体验产生负面影响。

像素管道

像素至屏幕管道中的关键点

  • JavaScript

    我们会使用 JavaScript 来实现一些视觉变化的效果。比如用 jQuery 的 animate 函数做一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。当然,除了 JavaScript,还有其他一些常用方法也可以实现视觉变化效果,比如:CSS Animations、Transitions 和 Web Animation API。

  • 样式计算

    此过程是根据匹配选择器(例如 .headline 或 .nav > .nav__item)计算出哪些元素应用哪些 CSS 规则的过程。从中知道规则之后,将应用规则并计算每个元素的最终样式。

  • 布局

    在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 <body> 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的。

  • 绘制

    绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。

  • 合成

    由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

不一定每帧都总是会经过管道每个部分的处理。实际上,不管是使用 JavaScript、CSS 还是网络动画,在实现视觉变化时,管道针对指定帧的运行通常有三种方式:

  • JS / CSS > 样式 > 布局 > 绘制 > 合成

    如果修改元素的“layout”属性,也就是改变了元素的几何属性(例如宽度、高度、左侧或顶部位置等),那么浏览器将必须检查所有其他元素,然后“自动重排”页面。任何受影响的部分都需要重新绘制,而且最终绘制的元素需进行合成。

  • JS / CSS > 样式 > 绘制 > 合成

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

  • JS / CSS > 样式 > 合成

如果更改一个既不要布局也不要绘制的属性,则浏览器将跳到只执行合成。

这个最后的版本开销最小,最适合于应用生命周期中的高压力点,例如动画或滚动。

优化 JavaScript 执行

JavaScript 经常会触发视觉变化。有时是直接通过样式操作,有时是会产生视觉变化的计算,例如搜索数据或将其排序。时机不当或长时间运行的 JavaScript 可能是导致性能问题的常见原因。

对于动画效果的实现,避免使用 setTimeout 或 setInterval,请使用 requestAnimationFrame。

当屏幕正在发生视觉变化时,希望在适合浏览器的时间执行工作,也就是正好在帧的开头。保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame。

function updateScreen(time) {
  // dom操作
}

requestAnimationFrame(updateScreen);

框架或示例可能使用 setTimeout 或 setInterval 来执行动画之类的视觉变化,但这种做法的问题是,回调将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。

将长时间运行的 JavaScript 从主线程移到 Web Worker。

JavaScript 在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果 JavaScript 运行时间过长,就会阻塞这些其他工作,可能导致帧丢失。因此,要妥善处理 JavaScript 何时运行以及运行多久。例如,如果在滚动之类的动画中,最好是想办法使 JavaScript 保持在 3-4 毫秒的范围内。

在许多情况下,可以将纯计算工作移到 Web Worker,例如,如果它不需要 DOM 访问权限。数据操作或遍历(例如排序或搜索)往往很适合这种模型,加载和模型生成也是如此。

var dataSortWorker = new Worker("sort-worker.js");
dataSortWorker.postMesssage(dataToSort);

// 主线程继续完成事情

dataSortWorker.addEventListener('message', function(evt) {
   var sortedData = evt.data;
   // 处理数据
});

使用微任务来执行对多个帧的 DOM 更改。

并非所有工作都适合此模型:Web Worker 没有 DOM 访问权限。如果工作必须在主线程上执行,考虑一种批量方法,将大型任务分割为微任务,每个微任务所占时间不超过几毫秒,并且在每帧的 requestAnimationFrame 处理程序内运行。

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);

function processTaskList(taskStartTime) {
  var taskFinishTime;

  do {
    // 取出顶端人物
    var nextTask = taskList.pop();

    processTask(nextTask);

    // 计算剩余时间
    taskFinishTime = window.performance.now();
  } while (taskFinishTime - taskStartTime < 3);

  if (taskList.length > 0)
    requestAnimationFrame(processTaskList);

}

缩小样式计算的范围并降低其复杂性

通过添加和删除元素,更改属性、类或通过动画来更改 DOM,全都会导致浏览器重新计算元素样式,在很多情况下还会对页面或页面的一部分进行布局(即自动重排)。这就是所谓的计算样式的计算。

计算样式的第一部分是创建一组匹配选择器,这实质上是浏览器计算出给指定元素应用哪些类、伪选择器和 ID。

第二部分涉及从匹配选择器中获取所有样式规则,并计算出此元素的最终样式。在 Blink(Chrome 和 Opera 的渲染引擎)中,这些过程的开销至少在目前是大致相同的:

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

降低选择器的复杂性;使用以类为中心的方法,例如 BEM。

在最简单的情况下,在 CSS 中引用只有一个类的元素:

.title {
  /* styles */
}

但是,随着项目的增长,将可能产生更复杂的 CSS,最终选择器可能变成这样:

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

为了知道是否需要应用样式,浏览器实际上必须询问“这是否为有 title 类的元素,其父元素恰好是负第 N 个子元素加上 1 个带 box 类的元素?”计算此结果可能需要大量时间,具体取决于所用的选择器和相应的浏览器。我们可以把以上选择器更改为一个类:

.final-box-title {
  /* styles */
}

减少要计算样式的元素数量

计算元素的计算样式的最糟糕的开销情况是元素数量乘以选择器数量,因为需要对照每个样式对每个元素都检查至少一次,看它是否匹配。应当尽可能减少声明为无效的元素的数量。

避免大型、复杂的布局和布局抖动

布局是浏览器计算各元素几何信息的过程:元素的大小以及在页面中的位置。 根据所用的 CSS、元素的内容或父级元素,每个元素都将有显式或隐含的大小信息。此过程在 Chrome、Opera、Safari 和 Internet Explorer 中称为布局 (Layout)。 在 Firefox 中称为自动重排 (Reflow),但实际上其过程是一样的。

与样式计算相似,布局开销的直接考虑因素如下:

  1. 需要布局的元素数量。
  2. 这些布局的复杂性。

布局的作用范围一般为整个文档,尽可能避免布局操作

更改样式时,浏览器会检查任何更改是否需要计算布局,以及是否需要更新渲染树。对“几何属性”(如宽度、高度、左侧或顶部)的更改都需要布局计算。

.box {
  width: 20px;
  height: 20px;
}

/**
 * 更改 width and height
 * 触发 layout.
 */
.box--expanded {
  width: 200px;
  height: 350px;
}

布局几乎总是作用到整个文档。 如果有大量元素,将需要很长时间来算出所有元素的位置和尺寸。

使用 flex 而不是浮动等的布局模型

网页有各种布局模型,一些模式比其他模式受到更广泛的支持。最早的 CSS 布局模型使我们能够在屏幕上对元素进行相对、绝对定位或通过浮动元素定位。

下面的屏幕截图显示了在 1,300 个元素上使用浮动的布局开销。当然,这是一个人为的例子,因为大多数应用将使用各种手段来定位元素。

如果使用 Flex,则出现不同的情况:

现在,对于相同数量的元素和相同的视觉外观,布局的时间要少得多(本例中为分别 3.5 毫秒和 14 毫秒)。

避免强制同步布局

将一帧送到屏幕会采用如下顺序:

首先 JavaScript 运行,然后计算样式,然后布局。但是,可以使用 JavaScript 强制浏览器提前执行布局。这被称为强制同步布局。

在 JavaScript 运行时,来自上一帧的所有旧布局值是已知的,并且可供查询。

因此,如果(例如)要在帧的开头写出一个元素的高度,可能编写一些如下代码:

requestAnimationFrame(logBoxHeight);

function logBoxHeight() {
  console.log(box.offsetHeight);
}

如果在请求此框的高度之前,已更改其样式,就会出现问题:

function logBoxHeight() {

  box.classList.add('super-big');

  console.log(box.offsetHeight);
}

现在,为了获取高度,浏览器必须先应用样式更改(由于增加了 super-big 类),然后运行布局。这时它才能返回正确的高度。这是不必要的,并且可能是开销很大的工作。

因此,始终应先批量读取样式并执行(浏览器可以使用上一帧的布局值),然后执行任何写操作:

function logBoxHeight() {
  console.log(box.offsetHeight);

  box.classList.add('super-big');
}

大部分情况下,并不需要应用样式然后查询值;使用上一帧的值就足够了。与浏览器同步(或比其提前)运行样式计算和布局可能成为瓶颈。

避免布局抖动

有一种方式会使强制同步布局甚至更糟:接二连三地执行大量这种布局。如下代码:

function resizeAllParagraphsToMatchBlockWidth() {

  for (var i = 0; i < paragraphs.length; i++) {
    paragraphs[i].style.width = box.offsetWidth + 'px';
  }
}

此代码循环处理一组段落,并设置每个段落的宽度为“box”的元素的宽度。这看起来没有害处,但问题是循环的每次迭代读取一个样式值 (box.offsetWidth),然后立即使用此值来更新段落的宽度 (paragraphs[i].style.width)。在循环的下次迭代时,浏览器必须考虑样式已更改这一事实,因为 offsetWidth 是上次请求的(在上一次迭代中),因此它必须应用样式更改,然后运行布局。每次迭代都将出现此问题!

修改此代码:

var width = box.offsetWidth;

function resizeAllParagraphsToMatchBlockWidth() {
  for (var i = 0; i < paragraphs.length; i++) {
    paragraphs[i].style.width = width + 'px';
  }
}

简化绘制的复杂度、减小绘制区域

绘制是填充像素的过程,像素最终合成到用户的屏幕上。 它往往是管道中运行时间最长的任务,应尽可能避免此任务。

  • 除 transform 或 opacity 属性之外,更改任何属性始终都会触发绘制。
  • 绘制通常是像素管道中开销最大的部分;应尽可能避免绘制。
  • 通过层的提升和动画的编排来减少绘制区域。
  • 使用 Chrome DevTools 绘制分析器来评估绘制的复杂性和开销;应尽可能降低复杂性并减少开销。

触发布局与绘制

通过上图我们得知,触发布局,则总是会触发绘制,因为更改任何元素的几何属性意味着其像素需要修正!

如果更改非几何属性,例如背景、文本或阴影,也可能触发绘制。在这些情况下,不需要布局,并且管道将如下所示:

提升移动或淡出的元素

绘制并非总是绘制到内存中的单个图像。事实上,在必要时浏览器可以绘制到多个图像或合成器层。

此方法的优点是,定期重绘的或通过变形在屏幕上移动的元素,可以在不影响其他元素的情况下进行处理。Sketch、GIMP 或 Photoshop 之类的软件也是如此,各个层可以在彼此的上面处理并合成,以创建最终图像。

创建新层的最佳方式是使用 will-change CSS 属性。此方法在 Chrome、Opera 和 Firefox 上有效,并且通过 transform 的值将创建一个新的合成器层:

.moving-element {
  will-change: transform;
}

对于不支持 will-change 但受益于层创建的浏览器,例如 Safari 和 Mobile Safari,需要使用3D 变形来强制创建一个新层:

.moving-element {
  transform: translateZ(0);
}

但需要注意的是:不要创建太多层,因为每层都需要内存和管理开销。请勿在不分析的情况下提升元素。

减少绘制区域

然而有时,虽然提升元素,却仍需要绘制工作。绘制问题的一个大挑战是,浏览器将两个需要绘制的区域联合在一起,而这可能导致整个屏幕重绘。因此,举例而言,如果页面顶层有一个固定标头,而在屏幕底部还有正在绘制的元素,则整个屏幕可能最终要重绘。

在高 DPI 屏幕上,固定位置的元素会被自动提升到其自有的渲染合并层。在低 DPI 设备上则不是这样,因为提升会将文本渲染从亚像素更改为灰度,并且层提升需要手动完成。

降低绘制的复杂性

一些绘制比其他绘制的开销更大。例如,绘制任何涉及模糊(例如阴影)的元素所花的时间将比(例如)绘制一个红框的时间要长。但是,对于 CSS 而言,这点并不总是很明显:background: red; 和 box-shadow: 0, 4px, 4px, rgba(0,0,0,0.5); 看起来不一定有截然不同的性能特性,但确实很不相同。

合成阶段优化

合成是将页面的已绘制部分放在一起以在屏幕上显示的过程。

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

  • 坚持使用 transform 和 opacity 属性更改来实现动画。
  • 使用 will-change 或 translateZ 提升移动的元素。
  • 避免过度使用提升规则;各层都需要内存和管理开销。

使用 transform 和 opacity 属性更改来实现动画

性能最佳的像素管道版本会避免布局和绘制,只需要合成更改:

需要坚持更改可以由合成器单独处理的属性。目前只有两个属性符合条件:transforms 和 opacity:

提升打算设置动画的元素

正如我们在“降低绘制的复杂性并减少绘制区域”一节所述,应当将打算设置动画的元素(在合理范围内,不要过度!)提升到其自己的层: 这可以提前警示浏览器即将出现更改,根据打算更改的元素,浏览器可能可以预先安排,如创建合成器层。

管理层并避免层数激增

层往往有助于性能,知道这一点可能会诱使通过以下代码来提升页面上的所有元素:

* {
  will-change: transform;
  transform: translateZ(0);
}

这是以迂回方式想要提升页面上的每个元素。此处的问题是创建的每一层都需要内存和管理,而这些并不是免费的。事实上,在内存有限的设备上,对性能的影响可能远远超过创建层带来的任何好处。每一层的纹理都需要上传到 GPU,使 CPU 与 GPU 之间的带宽、GPU 上可用于纹理处理的内存都受到进一步限制。