×

深度剖析浏览器渲染性能原理,你到底知道多少?

96
齐修_qixiuss
2016.06.20 13:51* 字数 3447

渲染卡顿是怎么回事?

网页不仅应该被快速加载,同时还应该流畅运行,比如快速响应的交互,如丝般顺滑的动画等。
大多数设备的刷新频率是60次/秒,也就说是浏览器对每一帧画面的渲染工作要在16ms内完成,超出这个时间,页面的渲染就会出现卡顿现象,影响用户体验。
为了保证页面的渲染效果,需要充分了解浏览器是如何处理HTML/JavaScript/CSS的。

渲染流程分为几步?

渲染流程

JavaScript:JavaScript实现动画效果,DOM元素操作等。
Style(计算样式):确定每个DOM元素应该应用什么CSS规则。
Layout(布局):计算每个DOM元素在最终屏幕上显示的大小和位置。由于web页面的元素布局是相对的,所以其中任意一个元素的位置发生变化,都会联动的引起其他元素发生变化,这个过程叫reflow。
Paint(绘制):在多个层上绘制DOM元素的的文字、颜色、图像、边框和阴影等。
Composite(渲染层合并):按照合理的顺序合并图层然后显示到屏幕上。

实际场景下,大概会有三种常见的渲染流程(也即是LayoutPaint步骤是可避免的):

三种常见的渲染流程

结合渲染流程怎么优化渲染性能呢?

结合上述的渲染流程,我们可以去针对性的分析并优化每个步骤。

  • 优化JavaScript的执行效率
  • 降低样式计算的范围和复杂度
  • 避免大规模、复杂的布局
  • 简化绘制的复杂度、减少绘制区域
  • 优先使用渲染层合并属性、控制层数量
  • 对用户输入事件的处理函数去抖动(移动设备)

优化JavaScript的执行效率,具体可以做什么?

动画实现,避免使用setTimeout或setInterval,尽量使用requestAnimationFrame

setTimeout(callback)setInterval(callback)无法保证callback函数的执行时机,很可能在帧结束的时候执行,从而导致丢帧,如下图:

时机不对,导致丢帧

requestAnimationFrame(callback)可以保证callback函数在每帧动画开始的时候执行。

// requestAnimationFrame将保证updateScreen函数在每帧的开始运行
requestAnimationFrame(updateScreen);

注意:jQuery的animate函数就是用setTimeout来实现动画,可以通过jquery-requestAnimationFrame这个补丁来用requestAnimationFrame替代setTimeout

requestAnimationFrame兼容性

把耗时长的JavaScript代码放到Web Workers中去做

JavaScript代码运行在浏览器的主线程上,与此同时,浏览器的主线程还负责样式计算、布局、绘制的工作,如果JavaScript代码运行时间过长,就会阻塞其他渲染工作,很可能会导致丢帧。
前面提到每帧的渲染应该在16ms内完成,但在动画过程中,由于已经被占用了不少时间,所以JavaScript代码运行耗时应该控制在3-4毫秒。
如果真的有特别耗时且不操作DOM元素的纯计算工作,可以考虑放到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线程执行结束
    // ...
});
Web Workers兼容性
把DOM元素的更新划分为多个小任务,分别在多个frame中去完成

由于Web Workers不能操作DOM元素的限制,所以只能做一些纯计算的工作,对于很多需要操作DOM元素的逻辑,可以考虑分步处理,把任务分为若干个小任务,每个任务都放到requestAnimationFrame中回调执行

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);

requestAnimationFrame(processTaskList);

function processTaskList(taskStartTime) {
    var nextTask = taskList.pop();

    // 执行小任务
    processTask(nextTask);

    if (taskList.length > 0) {
        requestAnimationFrame(processTaskList);
    }
}
使用Chrome DevTools的Timeline来分析JavaScript的性能

打开Chrome DevTools > Timeline > JS Profile,录制一次动作,然后分析得到的细节信息,从而发现问题并修复问题。

使用Chrome DevTools的Timeline来分析JavaScript的性能

降低样式计算的范围和复杂度,具体可以做什么?

添加或移除一个DOM元素、修改元素属性和样式类、应用动画效果等操作,都会引起DOM结构的改变,从而导致浏览器需要重新计算每个元素的样式,对整个页面或部分页面重新布局,这就是所谓的样式计算。
样式计算主要分为两步:创建一套匹配的样式选择器,为匹配的样式选择器计算具体的样式规则

降低样式选择器的复杂度

尽量保持class的简短,或者使用Web Components框架。

.box:nth-last-child(-n+1) .title {
}
// 改善后
.final-box-title {
}
减少需要执行样式计算的元素个数

由于浏览器的优化,现代浏览器的样式计算直接对目标元素执行,而不是对整个页面执行,所以我们应该尽可能减少需要执行样式计算的元素的个数

避免大规模、复杂的布局,具体可以做什么?

布局就是计算DOM元素的大小和位置的过程,如果你的页面中包含很多元素,那么计算这些元素的位置将耗费很长时间。
布局的主要消耗在于:1. 需要布局的DOM元素的数量;2. 布局过程的复杂程度

尽可能避免触发布局

当你修改了元素的属性之后,浏览器将会检查为了使这个修改生效是否需要重新计算布局以及更新渲染树,对于DOM元素的“几何属性”修改,比如width/height/left/top等,都需要重新计算布局。
对于不能避免的布局,可以使用Chrome DevTools工具的Timeline查看明细。

使用Chrome DevTools工具的Timeline查看明细

可以查看布局的耗时,以及受影响的DOM元素数量。

使用flexbox替代老的布局模型

老的布局模型以相对/绝对/浮动的方式将元素定位到屏幕上
Floxbox布局模型用流式布局的方式将元素定位到屏幕上
通过一个小实验可以看出两种布局模型的性能差距,同样对1300个元素布局,浮动布局耗时14.3ms,Flexbox布局耗时3.5ms


Paste_Image.png
Flexbox兼容性
避免强制同步布局事件的发生

前面提过,将一帧画面渲染的屏幕上的流程是:


Paste_Image.png

首先是JavaScript脚本,然后是Style,然后是Layout,但是我们可以强制浏览器在执行JavaScript脚本之前先执行布局过程,这就是所谓的强制同步布局。

requestAnimationFrame(logBoxHeight);

// 先写后读,触发强制布局
function logBoxHeight() {
    // 更新box样式
    box.classList.add('super-big');

    // 为了返回box的offersetHeight值
    // 浏览器必须先应用属性修改,接着执行布局过程
    console.log(box.offsetHeight);
}

// 先读后写,避免强制布局
function logBoxHeight() {
    // 获取box.offsetHeight
    console.log(box.offsetHeight);

    // 更新box样式
    box.classList.add('super-big');
}

在JavaScript脚本运行的时候,它能获取到的元素样式属性值都是上一帧画面的,都是旧的值。因此,如果你在当前帧获取属性之前又对元素节点有改动,那就会导致浏览器必须先应用属性修改,结果执行布局过程,最后再执行JavaScript逻辑。

避免连续的强制同步布局发生

如果连续快速的多次触发强制同步布局,那么结果更糟糕。
比如下面的例子,获取box的属性,设置到paragraphs上,由于每次设置paragraphs都会触发样式计算和布局过程,而下一次获取box的属性必须等到上一步设置结束之后才能触发。

function resizeWidth() {
    // 会让浏览器陷入'读写读写'循环
    for (var i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = box.offsetWidth + 'px';
    }
}

// 改善后方案
var width = box.offsetWidth;
function resizeWidth() {
    for (var i = 0; i < paragraphs.length; i++) {
        paragraphs[i].style.width = width + 'px';
    }
}

注意:可以使用FastDOM来确保读写操作的安全,从而帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续布局

简化绘制的复杂度、减少绘制区域,具体可以做什么?

绘制就是填充像素的过程,通常这个过程是整个渲染流程中耗时最长的一环,因此也是最需要避免发生的一环。
如果Layout被触发,那么接下来元素的Paint一定会被触发。当然纯粹改变元素的非几何属性,也可能会触发Paint,比如背景、文字颜色、阴影效果等。

提升移动或渐变元素的绘制层

绘制并非总是在内存中的单层画面里完成的,实际上,浏览器在必要时会将一帧画面绘制成多层画面,然后将这若干层画面合并成一张图片显示到屏幕上。
这种绘制方式的好处是,使用transform来实现移动效果的元素将会被正常绘制,同时不会触发其他元素的绘制。

减少绘制区域

浏览器会把相邻区域的渲染任务合并在一起进行,所以需要对动画效果进行精密设计,以保证各自的绘制区域不会有太多重叠。

简化绘制的复杂度

可以实现同样效果的不同方式,我们应该采用性能更好的那种。

通过Chrome DevTools来分析绘制复杂度和时间消耗,尽可能降低这些指标

打开DevTools,按下键盘的ESC键,在弹出的面板中,选中rendering选项卡下的Enable paint flashing,这样每当页面发生绘制的时候,屏幕就会闪现绿色的方框。通过该工具可以检查Paint发生的区域和时机是不是可以被优化。

Paste_Image.png

通过Chrome DevTools中的Timeline > Paint选项可以查看更细节的Paint信息

优先使用渲染层合并属性、控制层数量,具体可以做什么?

使用transform/opacity实现动画效果

使用transform/opacity实现动画效果,会跳过渲染流程的布局和绘制环节,只做渲染层的合并。


transform/opacity可以实现的功能

使用transform/opacity的元素必须独占一个渲染层,所以必须提升该元素到单独的渲染层。

提升动画效果中的元素

应用动画效果的元素应该被提升到其自有的渲染层,但不要滥用。
在页面中创建一个新的渲染层最好的方式就是使用CSS属性winll-change,对于目前还不支持will-change属性、但支持创建渲染层的浏览器,可以通过3D transform属性来强制浏览器创建一个新的渲染层。需要注意的是,不要创建过多的渲染层,这意味着新的内存分配和更复杂的层管理。

.moving-element {
    will-change: transform;
    transform: translateZ(0);
}
will-change兼容性
transform2D兼容性
管理渲染层、避免过多数量的层

尽管提升渲染层看起来很诱人,但不能滥用,因为更多的渲染层意味着更多的额外的内存和管理资源,所以当且仅当需要的时候才为元素创建渲染层。

* {
  will-change: transform;
  transform: translateZ(0);
}
使用Chrome DevTools来了解页面的渲染层情况

开启Chrome DevTools > Timeline > Paint选项,然后录制一段时间的操作,选择单独的帧,看到每个帧的渲染细节,在ESC弹出框有个Layers选项,可以看到渲染层的细节,有多少渲染层?为何被创建?

Paste_Image.png

对用户输入事件的处理函数去抖动(移动设备),具体可以做什么?

用户输入事件处理函数会在运行时阻塞帧的渲染,并且会导致额外的布局发生。

避免使用运行时间过长的输入事件处理函数

理想情况下,当用户和页面交互,页面的渲染层合并线程将接收到这个事件并移动元素。这个响应过程是不需要主线程参与,不会导致JavaScript、布局和绘制过程发生。


Paste_Image.png

但是如果被触摸的元素绑定了输入事件处理函数,比如touchstart/touchmove/touchend,那么渲染层合并线程必须等待这些被绑定的处理函数执行完毕才能执行,也就是用户的滚动页面操作被阻塞了,表现出的行为就是滚动出现延迟或者卡顿。

简而言之就是你必须确保用户输入事件绑定的任何处理函数都能够快速的执行完毕,以便腾出时间来让渲染层合并线程完成他的工作。

Paste_Image.png
避免在输入事件处理函数中修改样式属性

输入事件处理函数,比如scroll/touch事件的处理,都会在requestAnimationFrame之前被调用执行。
因此,如果你在上述输入事件的处理函数中做了修改样式属性的操作,那么这些操作就会被浏览器暂存起来,然后在调用requestAnimationFrame的时候,如果你在一开始就做了读取样式属性的操作,那么将会触发浏览器的强制同步布局操作。


Paste_Image.png
对滚动事件处理函数去抖动

通过requestAnimationFrame可以对样式修改操作去抖动,同时也可以使你的事件处理函数变得更轻

function onScroll(evt) {
    // Store the scroll value for laterz.
    lastScrollY = window.scrollY;

    // Prevent multiple rAF callbacks.
    if (scheduledAnimationFrame) {
        return;
    }

    scheduledAnimationFrame = true;
    requestAnimationFrame(readAndUpdatePage);
}

window.addEventListener('scroll', onScroll);

总结点什么?

网站性能优化是一个有一定门槛的细致活,需要对浏览器的机制有很好的理解,同时也应该学会利用Chrome DevTools去分析并解决实际问题,关于Chrome DevTools的学习我会专门开一篇博客来讲解,同时会结合具体的性能问题来分析。

性能优化
Web note ad 1