都中秋了 chrome渲染性能优化你会了么?

avatar
研发 @比心APP

chrome吃内存

M5HK7zs.gif 1262497171475751057.gif

网上有很多调侃Chrome吃内存的图,读完这篇文章你可以粗略了解为什么Chrome吃那么多怎么样让他少吃点。


chrome是怎么渲染页面的

image.png

上图就是Chrome渲染一帧画面的全部流程,首先是JS代码的执行,之后会进行样式计算(Style)、布局(Layout)、绘制(Paint),最后进行层合成(Composite),最终输出一帧画面显示在屏幕上,这个过程也叫像素管道; 需要注意的是,这个管道是阻塞式的,如果前一个环节没有执行完成,后面的环节就不会进行;

所以如果页面视觉变化想达到60fps(每秒输出60帧画面),就需要保证一个像素管道的所有工作在16.7ms内完成,如果超过这个时间就会发生丢帧即卡顿; 渲染性能的优化都是围绕这5个环节做的,下面介绍针对每个环节有哪些优化方案;


performance性能分析

介绍具体优化方案前,需要先知道如何监测各个阶段的耗时从而发现性能瓶颈;

Chrome提供了性能分析的利器:performance面板,下图就是performance录制后的效果:

image.png

从图片可以看到,该工具可以录制渲染中FPS、CPU消耗、网络消耗、每一帧的画面、内存消耗、JS执行栈、DOM数量等信息,这些快照信息可以让你分析每个时刻的性能表现;

这里重点介绍下最下方的火焰图,该图从左到右是浏览器脚本的调用顺序,从上到下是函数嵌套的顺序,因为形状像一个倒立的火焰,所以起名火焰图;看火焰图首先看跨度最长的任务,也就是横向最长的线,这是最耗时的任务,也是性能瓶颈点,浏览器会自动标注(右上角会有红色的标注)耗时较长的任务,解决最耗时的任务就可达到优化性能的目标;

image.png


每个环节的优化方案

下面针对像素管道的五个环节逐一给出优化的方案。

JavaScript的优化

上文我们了解到像素管道是阻塞式的,任务之间环环相扣,任意一个任务的表现不佳,都会导致整个管道的耗时变长,所以性能优化的核心逻辑是避免每个环节执行耗时很长的任务;

首先我们看JS任务执行环节有哪些手段避免长任务,有以下三个方案:

  1. 使用Web Worker分摊纯计算任务的压力:纯计算工作(不需要DOM的访问权限)移到Web Worker去处理,例如数据操作和遍历,这样主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢,有关Web Worker更多的信息可参考阮老师的文章

  2. 使用Time Slicing(时间切片)方案:时间切片就是将长任务给切割成无数个执行时间很短的任务,在每帧的requestAnimationFrame内运行;React16 Fiber架构就是使用该优化手段,这里可以思考下React16 Fiber为什么不用Web Worker进行优化?因为React是更新页面时需要操作DOM的,而Web Worker无法访问DOM;下图是一个长任务的拆分示例: image.png

  3. 使用WebAssembly:它是一种低级的类汇编语言,具有紧凑的二进制格式,由于是二进制字节码,因此运行速度更快、体积更小(多用于多功能视频播放器,音频转码工具,网页游戏,加解密),可参考MDN的介绍

上述三种方案的技术细节我们不做展开,但可以总结出它们优化的思路和逻辑:Web Worker为多进程并行处理JS任务;Time Slicing是对耗时长的任务拆分,分片执行;WebAssembly是真正提升任务的执行效率从而缩减执行时间;上述手段都达到了避免长任务的目的。


样式计算优化

样式计算就是根据css计算每个元素最终样式的过程,这是一个解析匹配的环节,所以CSS选择器越简单DOM数量越少样式计算的速度就越快,下图的三种写法都为实现同一种效果,但执行速度上依次变慢,即ID选择器优于Class选择器、Class选择器优于伪类选择器; image.png 但要注意,此阶段一般执行很快,不会成为性能的瓶颈,避免过早和低效的优化,把整个项目的样式重构远没有对项目的JS执行做优化来的划算。


布局优化

我们首先搞懂布局是在做什么,这里结合像素管道的下一个环节绘制一起说明下:

就像下面的图片,布局就是先框定每块田地的位置大小,绘制就是再在每块田地进行种植;如果一块田地的大小区域变了,就需要对它周边的田地进行重新的框定和种植;

image.png

类比上述的种田,布局就是计算元素占据的空间大小及其在屏幕的位置,由于网页的流式布局,意味着一个元素的大小位置变化可能影响其他元素的布局,所以布局经常发生;

该环节是最有可能成为性能瓶颈的环节,因此应该尽量减少布局的次数和布局影响的区域;

减少布局的次数

在必须修改CSS属性才能达到想要的页面效果时,尽量只修改paint only属性,例如背景图片、文字颜色或阴影等;这些属性的修改不会影响页面布局,则浏览器会跳过布局环节,直接执行绘制。这里需注意不同的浏览器渲染引擎对同一css属性的修改触发的周期可能不同,可到 cssTriger 查看;

降低布局影响的区域

这里需要提到一个新的关键词:布局边界,在布局边界元素的范围内进行任何布局更改,仅需要"部分重排”,通过构建合理的布局边界,就可达到降低布局影响区域的效果;通过一些小的CSS调整,我们可以在文档中强制设置布局边界,下图为构建布局边界的条件:

image.png

布局边界乍一听觉得很陌生,但在实际开发中其实每个人都使用过,只是没有意识到;比如下图中红框内横向滚动区域由于指定了高度它就形成一个布局边界,内部元素的变化只会触发红框里内的重排,不会触发红框外区域的重排;

image.png

避免强制同步布局

当JS改变DOM元素的几何属性,之后又获取元素几何属性,浏览器为了回答我这个问题(宽度是多少),它必须要在此时此刻做一次布局,此时这个布局是同步的,这就是强制同步布局,如下代码就会触发强制同步布局:

// Set the width
el.style.width = '100px';
// Then ask for it
const width = el.offsetWidth;

如果上面的代码在循环里,那性能问题就会非常突出;

如何解决这个问题:只需要将两行代码的顺序做调换即可。


绘制优化

绘制的概念很好理解,绘制就是填充像素的过程,像素最终合成到用户的屏幕上;但绘制并非总是绘制到内存中的单个图像,必要时绘制会发生在多个合成层(Layers)上;Layers类似于PS中的图层,浏览器是在绘制完每个Layer后再将这些图层进行合并输出的。注意这个层不是z-index划分的层,可以在控制台的layers面板查看,如下图就是多个层的示例:

image.png

绘制的优化思路类似上面的布局优化思路,也是减少绘制的区域和次数。

减少绘制的区域

若对页面进行和里的层拆分,当页面变化时,只需要对变化的部分进行绘制,不变的部分就不需要绘制,这样绘制只会发生在某个Layer里,这就达到了减少绘制区域的目的。

那如何创建新层呢,给元素添加下面的css属性即可:

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

减少绘制的次数

当要实现动画效果时,尽量只使用 transform 和 opacity,这两种属性的修改既不要布局也不要绘制,浏览器会跳过布局和绘制只执行合成,像素管道会只执行下图的环节,这也是性能最佳的像素管道;

image.png

这种操作最适合于应用生命周期中的高压力点,例如动画或滚动,所以动画尽量用这两个属性实现。


层合成优化

刚刚提到使用css来创建新层从而在页面变化时减少绘制的区域,那是不是给所有元素提升为一个层就好了?不行,要合理分层,因为每层都需要内存和管理开销,这也是层合成优化的关键:不要创建太多层。绘制和层合成其实有点此消彼长的关系,需要具体场景具体分析,找到性能最佳的平衡点;


一个具体的产品需求

下面我们看一个具体的产品需求场景,做下性能优化实战,功能如下:一个语音聊天室,会显示聊天室内用户的实时消息并自动滚动消息列表展示最新消息;

image.png

初级程序员开始实现聊天室消息,上线后用户反馈页面待久了手机发热严重页面很卡 image.png

资深程序员开始做性能优化,上线后稳定运行,手机也没那么烫手了 image.png

让我们看看这位头发比较少的资深程序员做了哪些优化。


消息列表优化方案

有经验的程序员这里就会意识到应该是长时间运行后随着消息越来越多,消息列表的渲染越来越慢导致的性能问题;

资深程序员做了以下操作:

  1. 消息最大显示数量限制:最多显示200条,超出时丢弃老的消息;保证页面DOM数量不会无限膨胀,减轻布局绘制压力;
  2. 消息截流:200ms消费一次累积的最新消息,批量渲染,而非来一条渲染一条;减少布局和重排的次数;
  3. 虚拟滚动:进一步减少DOM数量,减轻布局绘制压力,后面详细介绍;
  4. 图片资源复用:降低页面的运存占用,后面详细介绍;

前两点很好理解,我们重点说下后面两点优化;

虚拟滚动

在这个消息列表中,虽然列表的很多元素不再页面的可视区域,但这些元素还是会经历重排和重绘,这部分消耗是多余的,虚拟滚动就是为了减少这部分不必要的消耗;简单说,虚拟滚动就是只渲染列表可视区域,可视区域外用空的DOM或padding做占位,从而降低滚动过程中或列表元素变化时的性能消耗;篇幅有限这里我们不做展开;

github上有很多开源的虚拟滚动组件库,如:vue-virtual-scroll-listreact-tiny-virtual-listvue-virtual-scroller等;

图片资源裁剪阶梯复用

笔者所在公司使用的三方CDN有拼接参数做裁剪的功能,可根据具体业务场景对用户原头像做裁剪后再回传给客户端,从而降低网络消耗;

这个页面里有很多不同大小的头像图片,那是不是直接以这些图片的实际大小来拼接裁剪参数加载资源就可以了呢?不是的,笔者在这里做了一个阶梯复用,就是将一定尺寸范围内的图片裁剪为同一尺寸,如20px-25px的图片统一使用25px的图片,这样尺寸相差不大的图片只会请求一份网络资源,图片在内存中也只存在一份;

对图片分阶梯复用,长时间运行后内存消耗差距还是很明显的;

这里又要提到Chrome的另一个内存使用机制,Chrome会将页面使用过的资源都放在运存里,无论后续是否使用或之前使用的DOM是否已经被卸载,除非触发系统级别的运存回收否则不会释放,且这部分运存的占用不在JS heap的层面,performance无法统计到,需使用活动监视器或chrome的任务管理器查看真正的运存消耗,这里我用一个实验来佐证上述机制;

打开google图片搜索任意图片,记为起始;滚动页面加载更多图片后,记为终止;手动选择body元素进行删除,记为手动删除DOM后;内存表现如下:

记录时机JS Heap内存任务管理器内存
起始20-30M119M
终止22-23M217M
手动删除DOM后20-21M210M

可以看出Chrome将页面使用过的资源都放在运存里,即使之前使用资源的DOM已经被卸载,这部分内存也没有释放。


其他类chrome环境优化建议

基本优化技巧通用,再结合运行环境特有的实现针对优化;

  1. electron下:

    1. 非原生依赖放在devDependence而非dependence里;
    2. 调用webFrame.clearCache()手动释放内存;
  2. 小程序:

小程序的特点是渲染线程和JS线程分离,这样设计的优点是窗体动画稳;缺点是渲染线程和JS线程频繁数据交互会成为性能瓶颈,若有多线程的数据交互操作,注意交互数据的大小;