阅读 1881

浏览器渲染优化

保证网页应用拥有很高的流畅度是至关重要的,即使只有细微的卡顿,用户也是可以感知到并对应用留下负面的印象。假如卡顿非常严重,那么用户很有可能会放弃这款应用而寻找其他的选择,这对于辛苦工作已久的整个团队都是非常大的灾难。

渲染流程

简单来说,页面的渲染包含以下这些步骤。 当页面文档抵达浏览器时,浏览器会对文档上的元素进行解析,组成DOM树。在ChromeDevTools中,这个过程被称为Parse HTML。 接下来DOM会与CSS样式相结合,成为渲染树。在DevTools中,这个过程被称为Recalculate Styles。渲染树和DOM树结构很类似,但又不完全相同。不会被渲染的元素,比如<head>和被设置为display: none的元素,就不会存在与渲染树中。而DOM中不存在的一些元素,例如伪元素,却会被加入到渲染树中。



知道页面中的元素和CSS的对应关系后,浏览器开始计算每一个元素会占用多少空间,以及位于屏幕中的什么位置。这一过程被称为Layout,有时也会被称为Reflow。由于元素之间的布局是彼此影响的,所以该过程可能会非常复杂。

元素的布局确定后,浏览器使用光栅器(rasterizer)计算出元素怎样以像素为单位进行展示,这一过程在DevTools中被叫做Paint。对于图片类型的元素,浏览器还需要将图片文件解码到内存中以便显示,这个过程叫做Image Decode,有的时候还会需要对其进行尺寸上的调整,也就是Resize

与在Photoshop等图像处理软件中类似,浏览器有时也会将界面分为多个图层,从而免除各图层之间的相互影响。每个图层中的内容被绘制完毕后,浏览器需要根据图层之间的位置关系,将他们进行整合,这个过程叫做Composite Layers



到此为止,一个页面文档上的所有内容已经完成了渲染,用户可以看见网页究竟长成什么样子。但对于我们来说,这仅仅是个开始。

渲染管道

网页的样式并非是一成不变的。针对用户的操作,页面必须及时给出合理的相应,这意味着页面显示的内容会频繁地发生变化。

通常来说,页面的变化是由js触发的。在js代码中,会存有对浏览器各种事件的监听以及相应的回调函数,以便对用户的各种操作进行响应。如果这些回调函数中含有对页面布局进行改动的地方,那么接下来就会触发浏览器对页面样式进行重新计算,之后应用更新的样式进行布局,在之后对图层进行绘制,并将它们整合在一起。整个过程可以视为一个管道,左侧的事件会导致右侧全部或部分的事件依次发生。



这基本就是每一帧在显示之前要经历的全部事情,但每次之间又会有一些不一样。假如说js修改了某些元素的几何结构或是位置,这将触发管道中每一个阶段的执行:重进计算网页上各个元素的布局,并将受影响的区域进行重新绘制,再将各个图层整合。但是如果你仅仅更改了类似于背景图片、颜色这种与布局无关的样式,那么重新计算布局的步骤就可以省略。在更理想的情况下,我们对页面上的图层进行了非常合理的划分,只对某一图层进行位移(利用`transform`)或透明度的变化,那么布局和绘制两个阶段都可以被省略掉,这将对提高渲染速度非常有帮助。

所以总的来说,以什么方式进行页面进行修改是至关重要的,修改不同的样式,浏览器的工作量也是大不相同的。开发者可以参考这一网站,来了解自己的操作对于浏览器来说都意味着要做哪些工作。


LIAR!

如果用一个词来描述网页应用的整个人生(app生),那么最恰当的应该就是LIAR。这倒不是因为它们善于欺骗用户,而是因为这四个字母代表的四个词汇(load-载入、idle-闲置、animations-动画、response-响应),能很好地总结一款应用的生命周期。实际上,这一生命周期模型更著名的名称是RAIL



`Load`就是页面的加载。对于时下最为主流的SPA来说,这个过程基本只会在刚刚打开网页应用时发生一次(如果实现了按需加载,切换页面时仍需要一定时间加载页面资源,但相比传统MPA来说过程会快很多)。最理想的情况下,页面能在1秒之内完成加载。但考虑到具体的网络状况,以及页面本身的复杂程度,一般来说1.5秒内完成加载对于用户来说已经是可以接受的时间。

Response代表对用户操作的响应。及时、准确的响应对于提高用户体验来说至关重要。一般来说,假如在用户操作100ms后才提供响应,那么会有被察觉到的轻微延时。而响应需要的时间越长,对于用户体验的破坏程度就越大。

Idle代表闲置时间。为了满足更快的加载和相应时间,有些不那么重要的任务需要被延后执行,而用户的闲置时间就是绝佳的机会。页面刚刚加载完之后,用户往往会花一定的时间在当前位置进行浏览(或者仅仅是没反应过来...),这给了我们加载一些次要资源的时间。但这个过程也不能太长,因为我们还要保证对于用户操作可以及时地进行响应(100ms内),所以最好将闲置时间内处理的内容划分在50ms内可以完成的片段内,以便在用户操作时可以及时响应。

Animation就是动画了。目前大部分屏幕设备的刷新频率都在60帧每秒左右,那么在这种情况下,达到60fps(frames per second)的页面刷新频率就是我们的终极目标,这将让用户完全相信页面上的变化没有任何不流畅的地方。经过一个非常简单的计算,我们知道1秒钟显示60帧意味着留给每一帧的渲染时间大约只有16.7毫秒。而假如将浏览器的处理时间考虑在内的话,其实每一帧留给你的时间只有10到12毫秒。在某些情况下,这可能会成为一项比较艰巨的任务。为了实现这一点,有时我们需要充分利用发出响应前允许的100ms延时,完成一些预处理工作。


FLIP

FLIP是由任职于Google的Paul Lewis(事实上这篇文章的很多内容都是引用了他的著作)提出的一种动画实现准则,能帮助动画更容易地达到60fps的流畅度。

FLIPFistLastInvertPlay的缩写,分别代表动画的开始状态、最终状态、翻转,以及播放。总的来说,FLIP就是要求你先计算出动画中元素的起止状态,然后把元素直接放置在最终位置上,通过一段“反向”的transition动画,把元素从起始状态转化到最终状态。

下面是一个示例,用来说明FLIP具体是如何实现的。线上的DEMO可以点击这里查看。

const el = document.getElementById('el')

// F: First
// 获取元素最初的状态
const positionAtFirst = el.getBoundingClientRect()
const opacityAtFirst = document.defaultView.getComputedStyle(el).opacity
		 
// L: Last
// 获取元素最终的状态
el.classList.add('end')
const positionAtLast = el.getBoundingClientRect()
const opacityAtLast = document.defaultView.getComputedStyle(el).opacity
		 
// I: Invert
// 让元素反转回最初状态
const invertTop = positionAtFirst.top - positionAtLast.top
const invertLeft = positionAtFirst.left - positionAtLast.left
el.style.transform = `translate(${invertTop}px, ${invertLeft}px)`
el.style.opacity = opacityAtFirst
		 
// P: Play
// 等待样式生效,在下一帧再开始过渡动画,否则浏览器将忽略样式的更改,动画会无法显示
requestAnimationFrame(function() {
  el.style.transition = 'all 2s'
  // 清除反转的位移,从而回到最终状态
  el.style.transform = ''
  el.style.opacity = opacityAtLast
})
		 
// 当一切结束后,就可以移除动画相关的CSS属性
el.addEventListener('transitionend', () => {
  el.style.transition = ''
})
复制代码

FLIP能实现使动画更为流程的原因是,它将一些代价较高的计算安排在了动画开始前执行。因为从操作到浏览器给出反馈前,用户是可以接受一定时间的延时的(比如100ms),这会是一个很好的进行复杂计算的时机。当计算完成后,使用CSS提供的transition功能,能以代价非常小的方式完成动画(对位移进行transform,以及改变opacity,都只会重新进行composition,而不会触发layout以及paint,这将节省相当大的工作量)。这样就保证了动画一旦开始,就能非常流畅地执行下去。


Taking advantage of user perception.


充分利用开发者工具

如果遇到了渲染相关的问题,并想进行细致的分析的话,Chrome的开发者工具(DevTools)会是一个得力的帮手。其中的Performance功能,能详细地展示网页渲染过程中的每一个细节,从而给想进行优化的开发者提供很有价值的线索。

以刚才的demo为例(因为它很简单,从而查看起来会更加清晰)。在开发者工具的Performance一栏中,点击左上角的录制按钮,刷新页面,再点击Stop按钮结束录制,就能看见从页面开始加载到动画完成的全部浏览器工作细节。



图表上方是一些总览信息,包括FPS、CPU和网络情况。可以看到FPS一栏在动画全程都保持了很高的水平,说明我们的目的达到了(High five!)。而如果这一栏上方出现了醒目的红线,说明画面的卡顿程度很可能到了影响用户体验的程度,需要开发者进行适当的优化。

紧贴着是页面的快照,可以在这里看到有一排快照直观地显示了页面的变化过程。假如没有发现这一栏,需要用户在顶部勾选Screenshots功能。

下面的图表是要着重分析的部分,它完整地展示了浏览器在什么时间都进行了什么工作。因为真实的场景下,浏览器进行的任务会是非常密集的,这时你可以点击W键(或滑动滚轮)放大其中某一部分。图中显示的是js中动画开始的过程,浏览器的主线程(图表中的Main部分)开始了一次Animation Frame Fired事件,这意味着requestAnimationFrame方法开始执行。它下方的Function Call就是作为参数传递的回调函数,点击这个矩形,在下方的Summary栏中可以看到这个函数的具体信息,包括函数名(本例中为匿名函数),在代码中的位置,和函数执行的时间(包括总时间和自身执行时间)。

就像上面提到的管道图中所展示的一样,js执行完之后,往往会跟着重新计算样式、更新布局、重绘、以及合并图层,这些流程都可以在图表中准确地找到对应的执行位置和时间。通过这些图表中的信息,我们可以很容易地判别浏览器是在哪一个步骤耗费了过多的时间从而导致页面的卡顿。

Frames一栏中记录了每一帧的快照,和渲染花费的时间。如之前所讨论过的,我们要尽力保证每一帧的渲染时间都接近16.7ms,但由于我们选择在提前进行一些复杂的计算,从而保证后面的动画可以流畅进行,所以目前显示的时间仍是在我们控制范围内的。

如果你勾选了顶部的Memory功能,还能看到内存使用量跟随时间的变化情况,这将帮助你更好地侦测到内存溢出等异常现象。

除了上面介绍的之外,DevTools还有很多其它强大的功能,我们将在后面具体的实例用进行说明。


看好你的JS代码

现代的js编译器会重新编译我们的代码,从而使代码的运行速度更快,这一过程是通过即时编译器完成的(Just In Time compiler),它非常庞大而复杂,所以一般的开发者基本无法猜测自己的代码会被编译成什么样子。既然如此,我们不如放弃一些所谓的微优化(因为他很可能不会按我们的预期产生理想的效果),把时间花在一些其他能提高页面渲染性能的措施上。

从js的执行时机入手可能是个比较好的办法。在某些情况下,浏览器可能正在处理着一些有关样式的工作,但此时出现了一段js代码需要被执行,于是浏览器开始执行这段代码。但这段代码改变了一些页面的样式,于是浏览器之前的工作白做了!它必须重来一遍之前关于样式的工作。如果这是一个脾气不好的浏览器,那么这很可能让它气得丢了一些帧来报复你。

想要浏览器更有效率地工作,避免这一类的返工,我们需要更好地安排自己的js代码而不是经常去给浏览器添乱,此时requestAnimationFrame可能会是一个好的选择。


requestAnimationFrame

window.requestAnimationFrame方法告诉浏览器您希望执行动画,并请求浏览器调用指定的函数在下一次重绘之前更新动画。该方法使用一个回调函数作为参数,这个回调函数会在浏览器重绘之前调用。

使用requestAnimationFrame的好处是,它会安排js代码尽量在每一帧的开始时进行,之后才会继续处理跟样是有关的后续工作。这就避免了不知什么时候出现的js任务导致的返工,从而保证了动画能更流畅地展示。

下面是一个数字增加的动画效果的实现。

// 动画的起始时间
let startTime = null

// 增加显示数额
const increase = (timeStamp) => {
  // 第一次执行时,将执行时间设为起始时间
  if (!startTime) {
    startTime = timeStamp
  }

  // 计算这一帧距离起始时间的时间差
  const timeOffset = timeStamp - startTime

  // 如果时间差在两秒内,那么执行动画
  if (timeOffset < 2000) {
    // 根据时间差计算当前应该增长到多少百分比。开方是为了实现ease-out的效果(想想它的曲线图)
    const percent = Math.pow((timeOffset / 2000), 0.5)
    this.setShownAmount(percent)
    // 开始下一帧的计算
    requestAnimationFrame(increase)
  } else {
    // 如果动画结束,将数额设置回初始值,防止在最后一帧中出现精度偏差
    this.setShownAmount()
  }
}

// 开始我们的动画
requestAnimationFrame(increase)
复制代码

可以看到我们并没有像使用setTimeoutsetInterval一样指定每一帧的间隔时间,这也是使用requestAnimationFrame的另外一个好处,你不需要去管究竟要多久来展示一个帧,浏览器会尽力做到最好。


requestAnimationFrame动画示例


雇佣一个Web Worker

之前的情况都是比较理想的,js能在非常短的时间内完成,可以通过合理地安排执行时间从而保证动画的流畅执行。但如果遇到了一些需要花费非常久才可以完成的任务,那么无论把它安排到哪里(鉴于js是单线程执行的),它都会毫无疑问地阻塞页面的动画。

此时也许可以考虑帮我们的主线程找一个帮手,Web Workers是个物美价廉的选择(雇佣他们是免费的!)。

Web Workers能让你在一个完全独立的上下文中,在一个独立的线程中执行js代码,与主线程互不影响。于是你可以开启一个Web Worker,并将一些耗时很长的任务交给它,等它执行完的时候,再利用它的执行结果进行下一步的操作,从而避免了主线程上的阻塞。

Web Workers的使用非常简单,你只要在需要的时候创建好它,并在它和主线程的代码内部各自做好数据的监听和传递即可。

在主线程内:

// 通过文件来创建一个Web Worker
const myWorker = new Worker('./worker.js')

// 向你的免费劳工传递信息
myWorker.postMessage(msg)

// 做好数据监听的工作,好在它完成任务的时候能够及时响应
myWorker.onmessage = function(e) {
  // 在这里你可以充分利用它的劳动成果做任何你想做的事
}
复制代码

woker.js中:

// 随时等候主人的调遣
this.onmessage = function(e) {
  // 主人的命令就藏在e.data中
  const msg = e.data

  // 任劳任怨,勤勤恳恳...

  // 告诉主人我的工作做完了
  postMessage(result)
}
复制代码

怎么样,使唤别人的感觉是不是特别的舒畅呢?


不要强迫浏览器

有时我们无意中就会强迫浏览器做了一些它不愿意做的事,既然是不情愿的,过程有时也就不会很顺畅。

下面是一段将所有段落的宽度改为基准宽度的一段代码,它向你展示了如何强迫浏览器做它不情愿的事情。

const ps = document.querySelectorAll('.paragraph')
const benchmark = document.querySelector('.benchmark')
let i = ps.length

// 如果你想和你的浏览器搞好关系 你最好这么写
size = benchmark.offsetWidth
while (i--) {
  ps[i].style.width = size + 'px'
}

// 否则你可以强迫浏览器这么做
while (i--) {
  ps[i].style.width = benchmark.offsetWidth + 'px'
}
复制代码

如果我们选择下面这种方式,而且受影响的元素数量(i)很可观的话,浏览器将会在渲染过程中表现得非常的不情愿。这是因为针对每一个元素,我们都重新获取了一遍基准元素(benchmark)的宽度,而浏览器为了得知这一数据,需要对页面进行重新布局。所以总的来说,我们总共重新布局了至少i次,但很明显我们并不必要这么做(其实一次就够了)。



DevTools中我们能看见,对于一个拥有一千个元素的示例来说,重新设置他们的宽度使这一帧的渲染时间达到了560.6ms,在实际场景中这是很难被接受的。好在贴心的DevTools给出了明显的提示,右上角标记为红色的部分就是Chrome认为存在异常的部分,并且贴心地给出了提示:

Warning Forced reflow is a likely performance bottleneck.

它在提醒你代码中存在强制同步布局(Forced synchronous layout)。

许多获取元素布局信息(比如尺寸、位置)的方法,以及其他一些方法(getComputedStyleinnerTextfocus 等)都会导致页面重新布局,在代码中我们都需要尽可能地减少执行这些方法的次数,并认真考虑执行他们的时机。具体会触发FSL的方法可以参阅这篇文章


为你的网页分层

像之前所提到的,合理地利用分层能提高网页的渲染速度。将一些只会应用transformopacity变换的元素分离到一个独立的层中,可以在渲染时只进行Composite的步骤从而避免浏览器进行额外的工作。那么如何利用这一特性,随时随地把想要的元素抽离到一个图层上呢?

你可以利用will-change: transform,这个属性告诉浏览器该元素接下来会进行transform的修改,从而提前准备好,将元素放置到一个独立图层中。对于一些还不支持这个属性的浏览器,你可以利用transform: translateZ(0)实现同样的效果,它假装要在纵向进行3D转换(实则没有),使浏览器不得不为它创建一个新的图层。

合理地创建图层有时会对页面的渲染速度带来很大的改善,你可以尝试这个来自优达学城的魔性demo来感受到这一点。进入这个网页后,点击左上角的Animate按钮,就会开始循环一段诡异的动画。如果你对自己的机器性能不是很自信,那么建议你马上点击旁边的Isolate按钮,它会为在这些元素增加一个Z轴方向的位移从而为为其各自创建独立图层,以达到避免把你的浏览器卡到崩溃的结果...

如果想要更细致地观察浏览器都做了什么,可以再次打开DevTools,打开Rendering面板,勾选Paint FlashingLayer BordersPaint Flashing功能可以帮你更清晰地查看到哪些元素正在被重绘,假如你没有点击,或是再次点击Isolate按钮,你会发现几乎整个画面都被绿色的区域覆盖着,这意味着他们都在不断地进行重绘。此时如果再次点击Isolate按钮,绿色的区域会消失,意味着不再有持续的重绘发生。这些元素转而由橙色的方框包裹,这些橙色的方框就是浏览器为它们新建的图层。



虽然这一回,分层将你的浏览器从崩溃的边缘拯救了回来,但它也并不总是有效。这种办法只适用于图层内的元素仅会发生`transform`和`opacity`这种不会触发重绘的样式的变化,加入图层内的元素需要更改一些诸如颜色、尺寸的样式,那么重绘和布局还是会被触发,分层将不会带来实质的效果。所以我们应该合理地使用这一特性,在创建新图层的代价和它带来的收益之间做出谨慎的权衡。

如果想知道浏览器具体创建了多少图层,以及这些图层的具体信息,还可以利用DevTools里这个酷炫的功能。在Preformance面板中点击具体的一帧(标记着时间的那一栏),然后选择Layers标签,可以以3D的方式展示各个图层在浏览器上的位置关系。下面是demo中某一帧的图层信息,数不清的图层组成的螺旋形柱体让我想起了一种小时候玩过的玩具。



结语

当然还有很多其他提高浏览器渲染效率的方法没有在本文中进行讨论,但了解以上所有的信息,应该已经能让你在解决渲染性能问题时拥有更多的思路。 事实上这篇文章基本是对优达学城的同名课程的总结和拓展。这是一门很棒的课程,如果你时间充裕,并且更喜欢通过视频进行学习,推荐你也完整地学习一遍。


参考和引用

文章分类
前端
文章标签