浏览器渲染性能优化
渲染性能
页面不仅要快速加载,而且要顺畅地运行;滚动应与手指的滑动一样快,并且动画和交互应如丝绸般顺滑。
60fps 与设备刷新率
目前大多数设备的屏幕刷新率为 60 次/秒。因此,如果在页面中有一个动画或渐变效果,或者用户正在滚动页面,那么浏览器渲染动画或页面的每一帧的速率也需要跟设备屏幕的刷新率保持一致。
其中每个帧的预算时间仅比 16 毫秒多一点 (1 秒/ 60 = 16.66 毫秒)。但实际上,浏览器有整理工作要做,因此所有工作需要在 10 毫秒内完成。如果无法符合此预算,帧率将下降,并且内容会在屏幕上抖动(judders)。此现象通常称为卡顿(jank),会对用户体验产生负面影响。
像素管道(The pixel pipeline)
在工作时需要了解并注意五个主要区域,这些是拥有最大控制权的部分,也是像素至屏幕管道中的关键点:
- JavaScript。一般来说,会使用 JavaScript 来实现一些视觉变化的效果。比如用 jQuery 的 animate 函数做一个动画、对一个数据集进行排序或者往页面里添加一些 DOM 元素等。除了 JavaScript,还有其他一些常用方法也可以实现视觉变化效果,比如:CSS Animations、Transitions 和 Web Animation API。
- 样式计算Sytle calculations。This is the process of figuring out which CSS rules apply to which elements based on matching selectors, for example, .headline or .nav > .nav__item. From there, once rules are known, they are applied and the final styles for each element are calculated.
- 布局。在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。网页的布局模式意味着一个元素可能影响其他元素,例如 <body> 元素的宽度一般会影响其子元素的宽度以及树中各处的节点,因此对于浏览器来说,布局过程是经常发生的。
- 绘制。绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,包括元素的每个可视部分。绘制一般是在多个表面(通常称为层layers)上完成的。
- 合成。由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。
管道的每个部分都有机会产生卡顿,因此务必准确了解代码触发管道的哪些部分。
不一定每帧都总是会经过管道每个部分的处理。实际上,不管是使用 JavaScript、CSS 还是网络动画,在实现视觉变化时,管道针对指定帧的运行通常有三种方式:
1. JS / CSS > 样式 > 布局 > 绘制 > 合成
如果修改元素的“layout”属性,即改变了元素的几何属性(例如宽度、高度等),那么浏览器将必须检查所有其他元素,然后“自动重排”页面(reflow the page)。任何受影响的部分都需要重新绘制,而且最终绘制的元素需进行合成。
2. JS / CSS > 样式 > 绘制 > 合成
如果修改“paint only”属性(例如背景图片、文字颜色或阴影等),即不会影响页面布局的属性,则浏览器会跳过布局,但仍将执行绘制。
3. JS / CSS > 样式 > 合成
如果更改一个既不用重新布局也不要重新绘制的属性,则浏览器将只执行合成。这个最后的方式开销最小,最适合于应用生命周期中的高压力点,例如动画或滚动。
性能是一种避免执行的艺术,并且使执行的任何操作尽可能高效。 许多情况下,这需要与浏览器配合,而不是跟它对着干。 值得谨记的是,上面列出的各项管道工作在计算开销上有所不同,一些任务比其他任务的开销要大!
优化 JavaScript 执行
JavaScript often triggers visual changes. Sometimes that's directly through style manipulations, and sometimes it's calculations that result in visual changes, like searching or sorting data.时机不当或长时间运行的 JavaScript 可能是导致性能问题的常见原因,应当设法尽可能减少其影响。
JavaScript 性能分析可以说是一门艺术,因为编写的 JavaScript 代码与实际执行的代码完全不像。现代浏览器使用 JIT 编译器和各种各样的优化和技巧来实现尽可能快的执行,这极大地改变了代码的动态性。
一些帮助应用很好地执行 JavaScript的事情:
- 对于动画效果的实现,避免使用 setTimeout 或 setInterval,使用 requestAnimationFrame。
- 将长时间运行的 JavaScript 从主线程移到 Web Worker。
- 使用小任务来执行对多个帧的 DOM 更改。
- 使用 Chrome DevTools 的 Timeline 和 JavaScript 分析器来评估 JavaScript 的影响。
使用 requestAnimationFrame 来实现视觉变化
当屏幕正在发生视觉变化时,最好在帧的开头执行操作。保证 JavaScript 在帧开始时运行的唯一方式是使用 requestAnimationFrame。
/** * If run as a requestAnimationFrame callback, this * will be run at the start of the frame. */ function updateScreen(time) { // Make visual updates here. } requestAnimationFrame(updateScreen);
框架或示例可能使用 setTimeout 或 setInterval 来执行动画之类的视觉变化,但这种做法的问题是,回调函数在帧中的某个时点运行,可能刚好在帧的末尾,而这经常会使我们丢失帧,导致卡顿。(composite等js的运行需要时间,会阻塞UI更新)。
事实上,jQuery 目前的默认 animate 行为是使用 setTimeout!强烈建议打上补丁程序以使用 requestAnimationFrame。
降低复杂性或使用 Web Worker
JavaScript 在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果 JavaScript 运行时间过长,就会阻塞这些其他工作,可能导致帧丢失。
因此,要妥善处理 JavaScript 何时运行以及运行多久。例如,如果在滚动之类的动画中,最好是想办法使 JavaScript 保持在 3-4 毫秒的范围内。超过此范围,就可能要占用太多时间。如果在空闲期间,则可以不必那么斤斤计较所占的时间。
在许多情况下,可以将纯计算工作移到 Web Worker,例如,不需要 DOM 访问权限,数据操作或遍历(例如排序或搜索),往往很适合这种模型,加载和模型生成也是如此。
var dataSortWorker = new Worker("sort-worker.js"); dataSortWorker.postMesssage(dataToSort); // The main thread is now free to continue working on other things... dataSortWorker.addEventListener('message', function(evt) { var sortedData = evt.data; // Update data on screen... });
并非所有工作都适合此模型:Web Worker 没有 DOM 访问权限。如果操作必须在主线程上执行,可以考虑一种批量方法,将大型任务分割为小任务,每个小任务所占时间不超过几毫秒,并且在每帧的 requestAnimationFrame 处理程序内运行。
var taskList = breakBigTaskIntoMicroTasks(monsterTaskList); requestAnimationFrame(processTaskList); function processTaskList(taskStartTime) { var taskFinishTime; do { // Assume the next task is pushed onto a stack. var nextTask = taskList.pop(); // Process nextTask. processTask(nextTask); // Go again if there’s enough time to do the next task. taskFinishTime = window.performance.now(); } while (taskFinishTime - taskStartTime < 3); if (taskList.length > 0) requestAnimationFrame(processTaskList); }
此方法会产生 UX 和 UI 后果,您将需要使用进度或活动指示器来确保用户知道任务正在被处理。在任何情况下,此方法都不会占用应用的主线程,从而有助于主线程始终对用户交互作出快速响应。
了解 JavaScript 的“frame tax”
在评估一个框架、库或自己的代码时,务必逐帧评估运行 JavaScript 代码的开销。当执行性能关键的动画工作(例如变换或滚动)时,这点尤其重要。
测量 JavaScript 开销和性能情况的最佳方法是使用 Chrome DevTools。通常,您将获得如下的简单记录:
The Main section provides a flame chart of JavaScript calls so you can analyze exactly which functions were called and how long each took.
如果发现有长时间运行的 JavaScript,则可以在 DevTools 用户界面的顶部启用 JavaScript 分析器:
以这种方式分析 JavaScript 会产生开销,因此一定只在想要更深入了解 JavaScript 运行时特性时才启用它。启用此复选框后,现在可以执行相同的操作,您将获得有关 JavaScript 中调用了哪些函数的更多信息:
有了这些信息之后,就可以评估 JavaScript 对应用性能的影响,并开始找出和修正函数运行时间过长的热点(hotspots)。如前所述,应当设法移除长时间运行的 JavaScript,或者若不能移除,则将其移到 Web Worker 中,腾出主线程继续执行其他任务。
避免微优化 JavaScript
知道浏览器执行一个函数版本比另一个函数要快 100 倍可能会很酷,比如请求元素的offsetTop比计算getBoundingClientRect()要快,但是,您在每帧调用这类函数的次数几乎总是很少。因此,把重点放在 JavaScript 性能的这个方面通常是白费劲。您一般只能节省零点几毫秒的时间。
如果您开发的是游戏或计算开销很大的应用,则可能属于本指南的例外情况,因为您一般会将大量计算放入单个帧,在这种情况下各种方法都很有用。
简而言之,慎用微优化,因为它们通常不会映射到您正在构建的应用类型。2/8法则,先从瓶颈处着手优化。
缩小样式计算的范围并降低其复杂性
通过添加和删除元素,更改属性、类或通过动画来更改 DOM,都会导致浏览器重新计算元素样式,在很多情况下还会对页面或页面的一部分进行布局(即自动重排)。This process is called computed style calculation.
计算样式的第一部分是创建一组匹配选择器,这实质上是浏览器计算出给指定元素应用哪些classes, pseudo-selectors and IDs。
第二部分涉及从匹配选择器中获取所有样式规则,并计算出此元素的最终样式。在 Blink(Chrome 和 Opera 的渲染引擎)中,这些过程的开销至少在目前是大致相同的:
Roughly 50% of the time used to calculate the computed style for an element is used to match selectors,而另一半时间用于从匹配的规则中构建 RenderStyle(computed style representation)。
- 降低选择器的复杂性;使用以类为中心的方法,例如 BEM规范(Block-Element_Modifer)。
- 减少必须计算其样式的元素数量。
降低选择器的复杂性
在最简单的情况下,在 CSS 中只有一个类的元素:
.title {
/* styles */
}
但是,随着项目的增长,将可能产生更复杂的 CSS,最终的选择器可能变成这样:
.box:nth-last-child(-n+1) .title {
/*
styles */
}
为了知道是否需要应用样式,浏览器实际上必须询问“这是否为有 title 类的元素,其父元素恰好是负第 N 个子元素加上 1 个带 box 类的元素?”计算此结果可能需要大量时间,具体取决于所用的选择器和相应的浏览器。特定的选择器可以更改为一个类:
.final-box-title {
/*
styles */
}
开发者可能对该类的名称有疑问,但此工作对于浏览器而言要简单得多。在上一版本中,为了知道该元素是否为其类型的最后一个,浏览器首先必须知道关于其他元素的所有情况,以及其后面是否有任何元素会是第 N 个最后子元素,这比简单地将类选择器与元素匹配的开销要大得多。
生成render tree时,对于每个DOM元素,必须在所有Style Rules中找到符合的 selector 并将对应的样式规则进行合并。
css选择器的解析是从右往左的,这样公共样式就在CSSOM树的父节点上,更具体的样式(选择器更具体)会在子节点上,节点分支和遍历次数都会变少。如果采用 left-to-right 的方式读取css规则,那么大多数规则读到最后才会发现是不匹配,做了很多无用功;而采取 right-to-left 的方式,只要发现最右边选择器不匹配,就直接舍弃,避免很多无效匹配。
减少要计算样式的元素数量
另一个性能考虑,在元素更改时需要计算的工作量对于许多样式更新而言是更重要的因素。
In general terms, the worst case cost of calculating the computed style of elements is the number of elements multiplied by the selector count, because each element needs to be at least checked once against every style rule to see if it matches.
注:以前曾经是这样:如果改变了(例如)body 元素上的一个类,则该页的所有子元素将需要重新计算其计算样式。现在有点不一样:对于更改时会导致重新计算样式的元素,某些浏览器维护一小组每个这种元素独有的规则。这意味着,根据元素在树中的位置以及所改变的具体属性,元素不一定需要重新计算。
样式计算可能经常是直接针对少量目标元素,而不是声明整个页面无效。在现代浏览器中,这往往不再是个问题,因为浏览器并不一定需要检查一项更改可能影响的所有元素。另一方面,较早的浏览器不一定针对此类任务进行了优化。应当尽可能减少声明为无效的元素的数量。
注:如果您热衷于网页组件,有一点值得注意,样式计算在这方面稍有不同,因为默认情况下样式不会跨越 Shadow DOM 的边界,并且范围限于单个组件,而不是整个树。但是,总体来看,同样的概念仍然适用:规则简单的小树比规则复杂的大树会得到更高效地处理。
测量样式重新计算的开销
测量样式重新计算的最简单、最好的方法是使用 Chrome DevTools 的 Timeline 模式。首先,打开 DevTools,转至 Timeline 选项卡,选中记录并与您的网站交互。停止记录后,将看到下图所示情况。
顶部的条表示每秒帧数,如果看到柱形超过较低的线,即 60fps 线,则存在长时间运行的帧。
如果一些滚动之类的交互或其他交互时出现长时间运行的帧,则应当进一步审查。
如果出现较大的紫色块,如上例所示,请点击记录了解到更多细节。
在这次抓取中,有一个长时间运行的重新计算样式事件,其时间刚好超过 18 毫秒,并且恰好发生在滚动期间,导致用户体验到明显的抖动。
如果点击事件本身,将看到一个调用栈,精确指出了您的 JavaScript 中导致触发样式更改的位置。此外,还获得样式受更改影响的元素数量(本例中刚好超过 400 个元素),以及执行样式计算所花的时间。您可以使用此信息来开始尝试在代码中查找修正点。
使用BEM规范
BEM的编码方法实际上纳入了上述选择器匹配的性能优势,因为它建议所有元素都有单个类,并且在需要层次结构时也纳入了类的名称:
.list { }
.list__list-item { }
如果需要一些修饰符,像在上面我们想为最后一个子元素做一些特别的东西,就可以按如下方式添加:
.list__list-item--last-child
{}
如果您在寻找一种好方法来组织您的 CSS,则 BEM 真的是个很好的起点,不仅从结构的角度如此,还因为样式查找得到了简化。
避免大型、复杂的布局和布局抖动
布局是浏览器计算各元素几何信息的过程:元素的大小以及在页面中的位置。 根据所用的 CSS、元素的内容或父级元素,每个元素都将有显式或隐含的大小信息。此过程在 Chrome、Opera、Safari 和 Internet Explorer 中称为布局 (Layout)。 在 Firefox 中称为自动重排 (Reflow),但实际上其过程是一样的。
与样式计算相似,布局开销的直接考虑因素如下:
- 需要布局的元素数量。
- 这些布局的复杂性。
- 布局的作用范围一般为整个文档。
- DOM 元素的数量将影响性能,应尽可能避免触发布局。
- 评估布局模型的性能;新版 Flexbox比旧版 Flexbox 或基于浮动的布局模型更快。
- Avoid forced synchronous layouts and layout thrashing; read style values then make style changes.
尽可能避免布局操作
当更改样式时,浏览器会检查更改是否需要计算布局,以及是否需要更新渲染树。对“几何属性”(如宽度、高度、左侧或顶部)的更改都需要布局计算。
.box {
width: 20px;
height: 20px;
}
/** Changing
width and height triggers layout. */
.box--expanded {
width: 200px;
height: 350px;
}
布局几乎总是作用到整个文档。 如果有大量元素,将需要很长时间来算出所有元素的位置和尺寸。
如果无法避免布局,关键还是要使用 Chrome DevTools 来查看布局要花多长时间,并确定布局是否是造成瓶颈的原因。首先,打开 DevTools,选择“Timeline”标签,点击“record”按钮,然后与您的网站交互。当您停止记录时,将看到网站表现情况的详细分析:
在仔细研究上例中的框架时,我们看到超过 20 毫秒用在布局上,当我们在动画中设置 16 毫秒来获取屏幕上的帧时,此布局时间太长。您还可以看到,DevTools 将说明树的大小(本例中为 1618 个元素)以及需要布局的节点数。
使用 flexbox 而不是较早的布局模型
网页有各种布局模型,一些模式比其他模式受到更广泛的支持。最早的 CSS 布局模型使我们能够在屏幕上对元素进行相对、绝对定位或通过浮动元素定位。
下面的屏幕截图显示了在 1,300 个框上使用浮动的布局开销。当然,这是一个人为的例子,因为大多数应用将使用各种手段来定位元素。
如果我们更新此示例以使用 Flexbox(Web 平台的新模型),则出现不同的情况:
现在,对于相同数量的元素和相同的视觉外观,布局的时间要少得多(本例中为分别 3.5 毫秒和 14 毫秒)。务必记住,对于某些情况,可能无法选择 Flexbox,因为它没有浮动那么受支持,但是在可能的情况下,至少应研究布局模型对网站性能的影响,并且采用最大程度减少网页执行开销的模型。
在任何情况下,不管是否选择 Flexbox,都应当在应用的高压力点期间尝试完全避免触发布局!
避免强制同步布局
将一帧送到屏幕会采用如下顺序:
首先 JavaScript 运行,然后计算样式,然后布局。但是,JavaScript 在更改元素样式后,获取其几何属性的值,此时会强制浏览器应用新样式提前执行布局,值后才能获取几何属性值。这被称为强制同步布局(forced synchronous layout)。
要记住的第一件事是,在 JavaScript 运行时,来自上一帧的所有旧布局值是已知的,并且可供您查询。因此,如果(例如)您要在帧的开头写出一个元素(让我们称其为“框”)的高度,可能编写一些如下代码:
// Schedule our function to run at the start of the frame. requestAnimationFrame(logBoxHeight); function logBoxHeight() { // Gets the height of the box in pixels and logs it out. console.log(box.offsetHeight); }
如果在请求此框的高度之前,已更改其样式,就会出现问题:
function logBoxHeight() { box.classList.add('super-big'); //样式更改后,浏览器必须先应用新的样式(重绘)之后才能获取当前的值,有时是多做无用功 // Gets the height of the box in pixels and logs it out. console.log(box.offsetHeight); }
现在,为了获得框的高度,浏览器必须先应用样式更改(由于增加了 super-big 类),然后运行布局,这时才能返回正确的高度。这是不必要的,并且可能开销很大。
因此,始终应先批量读取样式并执行(浏览器可以使用上一帧的布局值),然后执行任何赋值操作。
以上函数应为:
function logBoxHeight() { // Gets the height of the box in pixels and logs it out. console.log(box.offsetHeight); box.classList.add('super-big'); }
大部分情况下,并不需要先应用新样式然后查询值,使用上一帧的值就足够了。与浏览器同步(或比其提前)运行样式计算和布局可能成为瓶颈。
避免布局超负荷(thrashing)
有一种方式会使强制同步布局更糟:连续执行大量这种强制布局。如下:
function resizeAllParagraphsToMatchBlockWidth() { // Puts the browser into a read-write-read-write cycle. for (var i = 0; i < paragraphs.length; i++) { paragraphs[i].style.width = box.offsetWidth + 'px'; } }
此代码循环处理一组段落,并设置每个段落的宽度以匹配一个称为“box”的元素的宽度。这看起来没有害处,但问题是循环的每次迭代读取一个样式值 (box.offsetWidth),然后立即使用此值来更新段落的宽度 (paragraphs[i].style.width)。在循环的下次迭代时,浏览器必须考虑样式已更改这一事实,因为 offsetWidth 是上次请求的(在上一次迭代中),所以它必须应用更改的样式,然后运行布局。每次迭代都将出现此问题!
此示例的修正方法还是先读取值,然后写入值:
// Read. var width = box.offsetWidth; function resizeAllParagraphsToMatchBlockWidth() { for (var i = 0; i < paragraphs.length; i++) { // Now write. paragraphs[i].style.width = width + 'px'; } }
如果要保证安全,应当查看 FastDOM,它会自动批处理读取和写入,应当能防止意外触发强制同步布局或布局抖动。
简化绘制的复杂度、减小绘制区域
绘制是填充像素的过程,像素最终合成到用户的屏幕上。 它往往是管道中运行时间最长的任务,应尽可能避免此任务。
- 除 transform 或 opacity 属性之外,更改任何属性始终都会触发绘制。
- 绘制通常是像素管道中开销最大的部分,应尽可能避免绘制。
- 通过layer promotion和动画的编排来减少绘制区域。
- 使用 Chrome DevTools paint profile来评估绘制的复杂性和开销;应尽可能降低复杂性并减少开销。
触发布局与绘制
如果触发布局,则总是会触发绘制,因为更改任何元素的几何属性意味着其像素需要修正!
如果更改非几何属性,例如背景、文本或阴影,也可能触发绘制。在这些情况下,不需要布局,并且管道将如下所示:
使用 Chrome DevTools 快速确定绘制瓶颈
您可以使用 Chrome DevTools 来快速确定正在绘制的区域。打开 DevTools,按下键盘上的 Esc 键。在出现的面板中,转到“rendering”标签,然后选中“Show paint rectangles”。
打开此选项后,每次发生绘制时,Chrome 将让屏幕闪烁绿色。如果看到整个屏幕闪烁绿色,或看到不应绘制的屏幕区域,则应当进一步研究。
Chrome DevTools Timeline 中有一个选项提供更多信息:绘制分析器。要启用此选项,转至 Timeline,然后选中顶部的“Paint”框。需要注意的是,请务必仅在尝试分析绘制问题时才打开此选项,因为它会产生开销,并且会影响性能分析结果。最好是在想要更深入了解具体绘制内容时使用。
完成了上述设置之后,现在可以运行 Timeline 录制,并且绘制记录将包含更多的细节。通过点击一帧的绘制记录,您将进入该帧的绘制分析器:
点击绘制分析器将调出一个视图,您可以查看所绘制的元素、所花的时间,以及所需的各个绘制调用:
此分析器显示区域和复杂性(实际上就是绘制所花的时间),如果不能选择避免绘制,这两个都是可以设法修正的方面。
提升移动或淡出的元素
绘制并非总是绘制到内存中的单个图像。事实上,在必要时浏览器可以绘制到多个图像或合成层(compositor layers)。
此方法的优点是,定期重绘的或通过变形在屏幕上移动的元素,可以在不影响其他元素的情况下进行处理。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);
}
但需要注意的是:不要创建太多层,因为每层都需要内存和管理开销。
如果已将一个元素提升到一个新层,可使用 DevTools 确认这样做已带来性能优势。请勿在不分析的情况下提升元素。
减少绘制区域
然而有时,虽然提升元素,却仍需要绘制工作。绘制问题的一个大挑战是,浏览器将两个需要绘制的区域联合在一起,而这可能导致整个屏幕重绘。因此,如果页面顶层有一个固定标头,而在屏幕底部还有正在绘制的元素,则整个屏幕可能最终要重绘。
减少绘制区域往往是编排动画和变换,使其不过多重叠,或设法避免对页面的某些部分设置动画。
降低绘制的复杂性
在谈到绘制时,一些绘制比其他绘制的开销更大。例如,绘制任何涉及模糊(例如阴影)的元素所花的时间将比(例如)绘制一个红框的时间要长。但是,对于 CSS 而言,这点并不总是很明显:background: red; 和 box-shadow: 0, 4px, 4px, rgba(0,0,0,0.5); 看起来不一定有截然不同的性能特性,但确实很不相同。
利用上述绘制分析器,您可以确定是否需要寻求其他方式来实现效果。问问自己,是否可能使用一组开销更小的样式或替代方式来实现最终结果。
您要尽可能的避免绘制的发生,特别是在动画效果中。因为每帧 10 毫秒的时间预算一般来说是不足以完成绘制工作的,尤其是在移动设备上。