5、渲染优化
如果把浏览器呈现页面的整个过程一分为二,前面章节所讨论的诸如图像资源优化、加载优化,以及构建中如何压缩资源大小等,都可视为浏览器为呈现页面请求所需资源的部分;本章将主要关注浏览器获取到资源后,进行渲染部分的相关优化内容。
其实优化渲染的实质,就是尽量压缩每个阶段的执行时间或跳过某些阶段的执行。
目前大部分设备的屏幕分辨率都在60fps左右,也就是每秒屏幕会刷新60次,所以要满足用户的体验期望,就需要浏览器在渲染页面动画或响应用户操作时,每一帧的生成速率尽量接近屏幕的刷新率。若按照60fps来算,则留给每一帧画面的时间不到17ms,再除去浏览器对资源的一些整理工作,一帧画面的渲染应尽量在10ms内完成,如果达不到要求而导致帧率下降,则屏幕上的内容会发生抖动或卡顿。
1、渲染过程
渲染过程大体可以划分为五个部分:JavaScript处理、计算样式、页面布局、绘制与合成。
JavaScript处理:前端项目中经常会需要响应用户操作,通过JavaScript对数据集进行计算、操作DOM元素,并展示动画等视觉效果。当然对于动画的实现,除了JavaScript,也可以考虑使用如CSS Animations、Transitions等技术。
计算样式:在解析CSS文件后,浏览器需要根据各种选择器去匹配所要应用CSS规则的元素节点,然后计算出每个元素的最终样式。
页面布局:指的是浏览器在计算完成样式后,会对每个元素尺寸大小和屏幕位置进行计算。由于每个元素都可能会受到其他元素的影响,并且位于DOM树形结构中的子节点元素,总会受到父级元素修改的影响,所以页面布局的计算会经常发生。
绘制:在页面布局确定后,接下来便可以绘制元素的可视内容,包括颜色、边框、阴影及文本和图像。
合成:通常由于页面中的不同部分可能被绘制在多个图层上,所以在绘制完成后需要将多个图层按照正确的顺序在屏幕上合成,以便最终正确地渲染出来。
2、JavaScript 执行优化
1、JavaScript 实现的动画的优化:使用 requestAnimationFrame
前端实现动画效果的方法有很多,比如在CSS中可以通过transition和animation来实现,在HTML中可以通过canvas来实现。
而通过 JavaScript 实现的动画,最容易想到的方式是利用定时器setTimeout或setInterval来实现。
但不要用 setTimeout或setInterval,而是用 requestAnimationFrame。
requestAnimationFrame方法的执行时机会与系统的刷新频率同步。这样就能保证回调函数在屏幕的每次刷新间隔中只被执行一次,从而避免因随机丢帧而造成的卡顿现象。
2、恰当使用 Web Worker
可将一些纯计算的工作迁移到Web Worker上处理,它为JavaScript的执行提供了多线程环境,主线程通过创建出Worker子线程,可以分担一部分自己的任务执行压力。在Worker子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理UI交互,保证页面的使用体验流程。需要注意的是,Worker子线程一旦创建成功就会始终执行,不会被主线程上的事件所打断,这就意味着Worker会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭
●DOM限制:Worker无法读取主线程所处理网页的DOM对象,也就无法使用document、window和parent等对象,只能访问navigator和location对象。
●文件读取限制:Worker子线程无法访问本地文件系统,这就要求所加载的脚本来自网络。
●通信限制:主线程和Worker子线程不在同一个上下文内,所以它们无法直接进行通信,只能通过消息来完成。
●脚本执行限制:虽然Worker可以通过XMLHTTPRequest对象发起ajax请求,但不能使用alert()方法和confirm()方法在页面弹出提示。
●同源限制:Worker子线程执行的代码文件需要与主线程的代码文件同源。
Web Worker的使用方法非常简单,在主线程中通过new Worker()方法来创建一个Worker子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,Worker就会失败:
在子线程处理完相关任务后,需要及时关闭Worker子线程以节省系统资源,关闭的方式有两种:在主线程中通过调用worker.terminate()方法来关闭;在子线程中通过调用自身全局对象中的self.close()方法来关闭。
考虑到上述关于Web Worker使用中的限制,并非所有任务都适合采用这种方式来提升性能。如果所要处理的任务必须要放在主线程上完成,则应当考虑将一个大型任务拆分为多个微任务,每个微任务处理的耗时最好在几毫秒之内,能在每帧的requestAnimationFrame更新方法中处理完成,代码示例如下:
3、事件节流和事件防抖
所谓事件节流,简单来说就是在某段时间内,无论触发多少次回调,在计时结束后都只响应第一次的触发。代码示例如下:
事件防抖的实现方式与事件节流类似,只是所响应的触发事件是最后一次事件。具体来说,首先设定一个事件防抖的时间间隔,当事件触发开始后启动计时器,若在定时器结束计时之前又有相同的事件被触发,则更新计时器但不响应回调函数的执行,只有当计时器完整计时结束后,才去响应执行最后一次事件触发的回调函数。
如果用户操作过于频繁,每次在防抖定时器计时结束之前就进行了下一次操作,那么同一事件所要触发的回调函数将会被无限延迟。频繁延迟会让用户操作迟迟得不到响应,同样也会造成页面卡顿的使用体验,这样的优化就属于弄巧成拙。
如果用户操作过于频繁,每次在防抖定时器计时结束之前就进行了下一次操作,那么同一事件所要触发的回调函数将会被无限延迟。频繁延迟会让用户操作迟迟得不到响应,同样也会造成页面卡顿的使用体验,这样的优化就属于弄巧成拙。
4、恰当的 JavaScript 优化
通过优化执行JavaScript能够带来的性能优化,除上述几点之外,通常是有限的。很少能优化出一个函数的执行时间比之前的版本快几百倍的情况,除非是原有代码中存在明显的BUG。
若花费大量精力进行这类微优化,可能只会带来零点几毫秒的性能提升,当然如果基于游戏或大量计算的前端应用,则另当别论。所以对于渲染层面的JavaScript优化,我们首先应当定位出导致性能问题的瓶颈点,然后有针对性地去优化具体的执行函数,而避免投入产出比过低的微优化。
Chrome浏览器开发者工具中的Performance页签,使用它可让我们逐帧评估JavaScript代码的运行开销。
在工具的顶部有控制JavaScript采样的分析器复选框Disable JavaScript samples,由于这种分析方式会产生许多开销,建议仅在发现有较长时间运行的JavaScript脚本时,以及需要深入了解其运行特性时才去使用。除此之外,在可开发者工具的Setting 〉 More tools中单独调出JavaScript分析器针对每个方法的运行时间及嵌套调用关系进行分析,并可将分析结果导出为.cpuprofile文件保存分享。
该功能将帮助我们获得更多有关JavaScript调用执行的相关信息,据此可进一步评估出JavaScript对应用性能的具体影响,并找出哪些函数的运行时间过长。然后使用优化手段进行精准优化。比如尽量移除或拆分长时间运行的JavaScript脚本,如果无法拆分或移除,则尝试将其迁移到Web Worker中进行处理,让浏览器的主线程继续执行其他任务。
3、计算样式优化
1、减少要计算样式的元素数量:使用类选择器替代标签选择器
CSS选择器的匹配规则实际上是从右向左的,这样再回看上面的规则匹配,其实开销相当高,因为CSS引擎需要首先遍历页面上的所有li标签元素,然后确认每个li标签有包含类名为product-list的父元素才是目标元素,所以为了提高页面的渲染性能,计算样式阶段应当尽量减少参与样式计算的元素数量:
-
使用类选择器替代标签选择器
对于上面li标签的错误示范,如果想对类名为product-list下的li标签添加样式规则,可直接为相应的li标签定义名为product-list_li的类选择器规则
-
避免使用通配符做选择器
使用通配符就意味着在计算样式时,浏览器需要去遍历页面中的每一个元素,这样的性能开销很大,应当避免使用。
2、降低选择器的复杂性
比如使用名为final-container-content的类选择替代上述的复杂样式计算,直接添加到目标元素上。而且复杂的匹配规则,可能也会存在考虑不周从而导致画蛇添足的情况,例如,通过id选择器已经可以唯一确定目标元素了,就无须再附加其他多余的选择器:
3、使用 BEM 规范:最好只有一个选择器
BEM是一种CSS的书写规范,它的名称是由三个单词的首字母组成的,分别是块(Block)、元素(Element)和修饰符(Modifier)。理论上它希望每行CSS代码只有一个选择器,这就是为了降低选择器的复杂性,对选择器的命名要求通过以下三个符号的组合来实现。
●中画线(-):仅作为连字符使用,表示某个块或子元素的多个单词之间的连接符。
●单下画线(_):作为描述一个块或其子元素的一种状态。
●双下画线(__):作为连接块与块的子元素。
通常来说,凡是独立的页面元素,无论简单或是复杂都可以被视作一个块,在HTML文档中会用一个唯一的类名来表示这个块。具体的命名规则包括三个:只能使用类选择器,而不使用ID选择器;每个块应定义一个前缀用来表示命名空间;每条样式规则必须属于一个块。比如一个自定义列表就可视作为一个块,其类名匹配规则可写为:
元素即指块中的子元素,且子元素也被视作块的直接子元素,其类名需要使用块的名称作为前缀。以上面自定义列表中的子元素类名写法为例,与常规写法对比如下:
修饰符可以看作是块或元素的某个特定状态,以按钮为例,它可能包含大、中、小三种默认尺寸及自定义尺寸,对此可使用small、normal、big或size-N来修饰具体按钮的选择器类名,示例如下:
BEM样式编码规范建议所有元素都被单一的类选择器修饰。
4、页面布局与重绘的优化
页面布局也叫作重排和回流,指的是浏览器对页面元素的几何属性进行计算并将最终结果绘制出来的过程。凡是元素的宽高尺寸、在页面中的位置及隐藏或显示等信息发生改变时,都会触发页面的重新布局。
1、触发页面布局与重绘的操作
-
第一类:
对DOM元素几何属性的修改,这些属性包括width、height、padding、margin、left、top等,某元素的这些属性发生变化时,便会波及与它相关的所有节点元素进行几何属性的重新计算,这会带来巨大的计算量;
-
第二类:
更改DOM树的结构,浏览器进行页面布局时的计算顺序,可类比树的前序遍历,即从上向下、从左向右。
这里对DOM树节点的增、删、移动等操作,只会影响当前节点后的所有节点元素,而不会再次影响前面已经遍历过的元素。
-
第三类:
获取某些特定的属性值操作,比如页面可见区域宽高offsetWidth、offsetHeight,页面视窗中元素与视窗边界的距离offsetTop、offsetLeft,类似的属性值还有scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientWidth、clientHeight及调用window.getComputedStyle方法。
这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器就需要重新进行页面布局计算。
2、避免对样式的频繁改动
如果一定要修改样式,则可通过以下几种方式来降低触发重排或回流的频次:
1、使用类名对样式逐条修改
2、缓存对敏感属性值的计算
3、使用 requestAnimationFrame 方法控制渲染帧
在requestAnimationFrame方法的回调函数中,应始终优先样式的读取(requestAnimationFrame方法可以控制回调在两个渲染帧之间仅触发一次,如果在其回调函数中一开始就取值到即时敏感属性,其实获取的是上一帧旧布局的值,并不会触发页面布局的重新计算。),然后再执行相应的写操作:
3、通过工具对绘制进行评估
1、监控渲染信息
打开Chrome的开发者工具,可以在“设置”→“更多工具”中,发现许多很实用的性能辅助小工具,比如监控渲染的Rendering工具:
-
Paint flashing,当我们开启该功能后,操作页面发生重新渲染,Chrome会让重绘区域进行一次绿色闪动。
这样就可以通过观察闪动区域来判断是否存在多余的绘制开销,比如若仅单击Select组件弹出下拉列表框,却发现整个屏幕区域都发生了闪动,或与此操作组件的无关区域发生了闪动,这都意味着有多余的绘制开销存在,需要进一步研究和优化。
店铺管理系统在切换一级菜单项时,牵涉二级菜单的图层闪动情况 -
Layer borders功能开启后,会在页面上显示出绘制的图层边界。
-
FPS meter功能开启后,会在当前页面的左上角显示实时的帧率情况,GPU功能是否开启及GPU内存占用情况
Rendering工具对帧率的监控
2、查看图层详情
当我们通过Rendering工具发现存在有多余的图层渲染时,由于闪动是难于捕捉的,所以还需要工具辅助显示出各个图层的详细信息,这便需要用到Layers图层工具。
上图所示工具界面大体分为三部分,
①号矩形框区域为当前页面的图层列表;
②号矩形框区域为页面带有图层边框的视图;
③号矩形框区域为选中图层的详细信息,包括页面尺寸、内存占用、绘制次数等。
通过这些信息能够帮助我们快速定位到所要查看的图层信息。当我们使用Rendering工具监控页面交互过程中有不恰当的图层存在时,便可使用Layers工具进行问题复现:首先打开目标页面,然后从左侧图层列表中依次查找出问题图层,接着分析引起该图层发生重绘的原因。
4、降低绘制复杂度
绘制是在页面布局确定后,将元素的可视内容绘制到屏幕上的过程。虽然不同的CSS绘制样式看不出性能上明显的不同,但并非所有属性都有同样的性能开销。例如,绘制带有阴影效果的元素内容,就会比仅绘制单色边框所耗费的时间要长,因为涉及模糊就意味着更高的复杂度。CSS属性如下:
比如位图的阴影效果,可以考虑使用Photoshop等图像处理工具直接为图片本身添加阴影效果,而非全交给CSS样式去处理。
例如,页面的顶部有一个固定区域的header标头,若它与页面其他位置的某个区域位于同一图层,当后者发生重绘时,就有可能触发包括固定标头区域在内的整个页面的重绘。对于固定不变不期望发生重绘的区域,建议可将其提升为独立的绘图层,避免被其他区域的重绘连带着触发重绘。
5、合成处理
合成处理是将已绘制的不同图层放在一起,最终在屏幕上渲染出来的过程。在这个环节中,有两个因素可能会影响页面性能:一个是所需合成的图层数量,另一个是实现动画的相关属性。
可通过将固定区域和动画区域拆分到不同图层上进行绘制,来达到绘制区域最小化的目的。接下来我们就来探讨如何创建新的图层,最佳方式便是使用CSS属性will-change来创建:
该方法在Chrome、Firefox及Opera上均有效,而对于Safari等不支持will-change属性的浏览器,则可以使用3D变换来强制创建:
虽然创建新的图层能够在一定程度上减少绘制区域,但也应当注意不能创建太多的图层,因为每个图层都需要浏览器为其分配内存及管理开销。如果已经将一个元素提升到所创建的新图层上,也最好使用Chrome开发者工具中的Layers对图层详情进行评估,确定是否真的带来了性能提升,切忌在未经分析评估前就盲目地进行图层创建。
仅合成相关的动画属性
如果一个动画的实现不经过页面布局和重绘环节,仅在合成处理阶段就能完成,则将会节省大量的性能开销。目前能够符合这一要求的动画属性只有两个:透明度opacity和图层变换transform。它们所能实现的动画效果如表所示,其中用n来表示数字。
在使用opacity和transform实现相应的动画效果时,需要注意动画元素应当位于独立的绘图层上,以避免影响其他绘制区域。这就需要将动画元素提升至一个新的绘图层。
6、服务器端渲染
对于数据相关的页面,比如用户中心的例子,需要获取到与用户相关的数据后再去进行编译和渲染,对此可以考虑将这些步骤放在服务器端去执行。
能这么做的原因是,首先数据获取本身就需要向服务器端发起请求,这一步服务器端具有天然的优势,其次服务器端的nodejs与浏览器同样都使用JavaScript语言,这就使得服务器端能在获取到数据后,就去执行Vue核心代码进行编译及渲染,从而生成可在浏览器端直接渲染的HTML文件。当然这个HTML文件最终还需要在浏览器端与Vue框架进行混入,让Vue框架来管理相应的数据。
这就是所谓的服务器端渲染,简单说就是将原本在客户端执行的与首屏渲染相关JavaScript处理逻辑,移到服务器端进行处理。
这样做虽然可以减少等待Vue框架加载与执行的时间,但会增加服务器的算力压力,同时也有可能面临服务器端内存泄漏的风险。可是考虑到服务器端集群的运算能力,肯定会高于用户端单个手机或电脑等设备上浏览器的运算能力,所以在有限的页面上,采取服务器端渲染能够明显提升首屏页面的渲染速度,同时在具体使用的页面范围上,也应当参考运算能力平衡考虑。
由于Vue组件生命周期在服务器端和在客户端上不一致,因此需要针对服务器端渲染编写相应的组件代码。
比如Vue组件在进行服务器端渲染的时候,不存在真实DOM节点渲染的情况,所以并不存在mounted这个生命周期函数,那么原本在客户端编写的组件,就需要将mounted中的业务逻辑迁移到组件的其他位置上。
接着往右看,业务源代码从app.js处分出了两个构建入口,webpack会根据不同的入口配置,分别生成用于服务器端渲染所需的Server Bundle和客户端渲染所需的Client Bundle。其中Server Bundle会在所定义的包渲染器中,被编译生成可以在浏览器端直接进行渲染的HTML文件。
这里还存在一个小问题:由于这份服务器端渲染所得的HTML文件,也是由Vue组件和相应的数据生成的,其包含的数据到了客户端之后,还是需要通过浏览器端的Vue框架进行管理的。
Vue 的 SSR 项目实例
一个demo的目录结构如下:
build下存放与项目构建相关的配置文件,
public中存放着项目中用到的一些静态资源文件,
dist存放着工程构建打包的输出文件,
src目录下为项目的主要源代码文件,
可以看出这是一个基于Vue的典型前端项目。
其中包含了组建目录components、路由设置router、基于Vuex状态管理的store、页面视图views及相应的入口文件。接下来将对该Vue项目的服务器端渲染过程进行简要介绍。
1、服务器端渲染所返回的 HTML 文件
服务器端渲染的目的是为浏览器返回一个可供直接进行绘制的HTML文件,从而减少首屏出现的时间,在该项目中文件index.template.html即为最终所要生成的服务器端渲染结果的模板文件,其内容如下:
在head标签中包含了title、显示设置、样式文件及一些预加载和预获取的文件配置,而在body标签中则通过注释的方式(vue-ssr-oulet)标定出了服务器端渲染DOM所要注入的节点位置。
2、输出 HTML 文件的编译过程
明确了模板文件index.template.html的作用后,接下来我们分析该模板文件如何处理并最终生成给浏览器直接渲染的HTML文件,这个过程必定是通过webpack构建完成的,可在配置文件中搜索模板文件的文件名,在webpack.client.config.js中查到如下配置信息:
该配置插件HTMLPlugin的作用是编译入参中指定的模板文件,并在dist目录下生成最终所需的index.html文件。要追溯编译构建过程,可从启动项目的命令npm run dev开始查询服务器启动代码server.js,代码如下:
这里是服务器启动处理的一个中间环节,
一方面开发环境下更具体的处理流程在/build/setup-dev-server.js文件中进行,在其中会启动一个开发调试用的服务器;
另一方面当文件修改发生后,会调用createRenderer方法生成服务器返回给浏览器的HTML文件中的内容字符串。
在服务器启动环节中的主要操作分别根据webpack.client.config.js和webpack.server.config.js的配置文件构建打包出Client Bundle和Server Bundle,其中处理Server Bundle的代码如下:
3、服务器端渲染方法
createRenderer方法代码如下:
通过createBundleRenderer方法可根据上一步构建生成的Server Bundle和模板配置选项共同生成一个BundleRenderer实例,该实例包含两个成员方法renderToString和renderToStream,它们分别可以将服务器渲染的内容以字符串和可读数据流的形式输出,输出结果即为浏览器请求首屏页面后服务器端返回可供直接渲染的结果。
可以看出该项目并非对所有页面都进行了服务器端渲染,它仅对首屏页面的顶部进行了服务器端渲染,下半部分的资源列表采用的是客户端渲染,因此能够根据实际的业务情况去平衡需要客户端渲染与服务器端渲染是十分必要的。
服务器端渲染大部分解决的应当是首屏性能问题。