精读《2017 前端性能优化备忘录》
本期精读的文章是:Front End Performance Checklist 2017
现在随着 web 应用的复杂性日益增加,其性能优化就会显得尤为必要,同时会给性能指标分析带来新的挑战,因为性能指标之间的差异性非常大,这取决于使用的设备、浏览器、协议、网络类型以及其它能够对性能产生影响的潜在因素(如:CDN、ISP、cache、proxy、firewall、load balancer、server 等)。
1 引言
本文提供了解决如何让网站响应更加迅速、访问更加流畅等前端性能优化问题的方法,读者们可以提供一些在实际场景中的性能优化问题以及解决方案,可泛谈优化策略,亦可针对性深入讨论某个优化方法。
2 内容概要
文中列举了很多不同的性能优化策略、模型或方法,如下:
制定目标
网站速度快于他人 20%
根据 psychological research 指出,网站最少在速度上比别人快 20%,才能让用户感觉到比别人的更快。这个速度说的并不是整个页面的加载时间,而是启动渲染时间,首次有效渲染时间,交互时间。
控制响应时间在 100ms,控制帧速在 60 帧/秒
RAIL performance model 提出的性能优化指标:务必在用户初始操作后的 100ms 内提供反馈。考虑到存在响应时间不足 100ms 的情况,页面最迟要在 50ms 的时候,把控制权交给主线程。
针对动画,其每一帧都需要在 16ms 内完成,这样才能保证每秒 60 帧(一秒/60=16.6ms),如果可以的话最好能在 10ms 内完成。
控制首次有效渲染时间在 1.25s,控制 SpeedIndex 在 1000
控制启动渲染时间在 1s 以内,且速度指数在 1000 以内,对于首次有效渲染时间,最好可以优化到 1.25s 以内。
环境搭建
做好构建工具的选型
不要过度使用那些酷炫的技术栈,坚持选择适合开发环境的工具,如 Grunt、Gulp、Webpack、PostCSS,或者组合起来的工具。只要这个工具运行的速度够快,而且没有给项目维护带来太大问题,就够了。
渐进增强
在构建前端结构的时,应始终将渐进增强作为指导原则。首先设计并且构建核心体验,再完善为高性能浏览器设计的高级特性的相关体验。
前端框架
最好使用那些支持服务器端渲染的框架,如 Angular,React,Ember 等。所选的框架要保证是被广泛使用并且经过考验的。不同框架对性能有着不同程度的影响,同时对应着不同的优化策略,所以要清楚的了解所选择框架的每个方面。
AMP 或 Instant Articles
- Google 的 AMP 技术会提供一套可靠的性能优化框架(基于免费的 CDN 网络)
- Facebook 的 Instant Articles 技术可以在 Facebook 上提升网站的性能。
合理利用 CDN
根据网站的动态数据量,可以将部分内容给静态网站生成工具生成一个静态版本,将其置于 CDN 上,从而避免数据库的请求,亦可选择基于 CDN 的静态主机平台,通过交互组件丰富页面。
优化构建
确定优先级
将网站的所有文件(js,图片,字体,第三方 script 文件,多媒体内容等)进行分门别类。根据优先级区分基础核心内容,高性能浏览器设计的升级体验,附加内容等。具体细节可参考 Improving Smashing Magazine’s Performance。
使用 cutting-the-mustard 技术
使用 cutting-the-mustard 技术能够实现不同类型的浏览器载入不同类型的资源(传统浏览器载入核心型资源,现代浏览器载入增强型资源)。在载入资源时要严格遵守相应的规则:页面加载时应首先载入 Core 资源,然后在 DomContentLoaded 事件触发时载入 Enhancement 资源,最后在 Load 事件触发时载入 Extras 资源。
micro-optimization 和 progressive booting
- 使用 skeleton screens 代替 loading indicator 展示
- 使用能够加速 App 初始化渲染的技术,如 tree-shaking、code-splitting
- 针对服务端渲染增加预编译环节
- 使用 Optimize.js 来加快初始加载速度,其原理是包装优先级高的调用函数
- 渐进启动,先通过使用服务器端渲染快速完成首次有效渲染,浏览器再通过少量的 JS 代码就可以让交互时间接近于首次有效渲染时间。
正确设置 HTTP cache header
需要正确设置 expires、cache-control、max-age 以及其它 HTTP 缓存响应头。请使用 Cache-control: immutable,可以参考 Heroku’s primer on HTTP caching headers、HTTP caching primer以及缓存之最佳实践。
减少使用第三方库,异步加载 JS
想要在不等 js 执行完就开始渲染页面,可以通过在 HTML 的 script 标签上添加 defer 以及 async 属性来实现。减少第三方库和脚本的使用,尤其是社交网站的分享按键和 iframe 嵌入等。
合理优化图片
- 要实现图片的响应式,应尽可能地使用带有 srcset、sizes 属性的 HTML 标签,如
<picture>
- 使用 WebP 格式的图片
图片优化进阶
- 可以使用渐进式 JPEG 图片
- 可以使用压缩工具对不同格式的图片进行压缩,如 JPEG 图片用 mozJPEG 压缩、PNG 图片用 Pingo 压缩、GIF 图片用 Lossy GIF 压缩、SVG 图片用 SVGOMG 压缩
- 可以通过过滤掉不必要的图片细节(通过给图片添加高斯模糊滤镜实现)来减小文件的大小
- 可以使用 PhotoShop 导出(质量在 0-10%)的图片用于做背景图
- 可以使用多张背景图的技巧来提高对图片性能感知的能力
优化 web 字体
- 如果使用开源字体,可以使用字体库中的子集或自己归类的子集来压缩文件大小
- 浏览器对 WOFF2 的支持度较高,当浏览器不支持 WOFF2 时,可以将 WOFF、OTF 作为备用
- 可以从 Comprehensive Guide to Font-Loading Strategies 中选择一些针对字体优化的策略
- 可以使用 service worker 来达到字体缓存持久化
- 关于如何快速入门字体优化的教程
快速推送 critical CSS 文件
为了保证能够让浏览器快速渲染,会将所有用于首屏渲染的 CSS 文件整合成一个文件(即 critical CSS),以 <style>
的行内形式内嵌到 <head>
,这样可以减少 critical 渲染路径。由于 HTTP 数据包大小的限制,因此 critical CSS 文件大小不能超过 14KB。
HTTP/2 协议可以让 critical CSS 用单个 CSS 文件存储,通过服务器推送 CSS 文件的传输方式来减少 HTML 文件数据量,由于存在高速缓存问题,因此需要建立带有缓存的 HTTP/2 服务器传输机制。
tree-shaking 和 code-splitting 机制减轻负载
- Tree-shaking 机制能够帮助清理生产环境中的冗余代码。可以通过 Webpack2 Tree-Shaking 机制来清理冗余的 exports 代码或者使用 UnCSS、Helium 工具来清理冗余的 CSS 代码
- code splitting 机制是 Webpack 的另一个特性,它能够将构建的代码分成多个 chunk,并且对 chunk 按需载入。只要在代码中定义了分离点(split point),Webpack 便会处理好相关的输出文件,不仅能够较少文件数据量,而且还能对代码做到按需载入。
- 用 Rollup 来 export 代码也能够取得不错的效果
提升渲染性能
可以通过使用 css containment 属性的方式来达到隔离性能开销大的组件,限制浏览器样式的范围,限制作用在 canvas 以外的布局和绘制工作中,限制用在第三方工具上,以确保页面滚动和出现动画效果时没有延迟。推荐使用 CSS 属性 will-change,该属性能够在元素的属性改变之前通知浏览器。
需要衡量浏览器在处于运行时渲染模式下的性能,可以参考浏览器渲染优化、如何正确的使用 GPU。
优化网络环境,加快网络传输
- 使用 skeleton screen 或者使用懒加载的方式载入字体或者开销大的组件,如视频、iframe、图片等
- dns-prefetch,能够让浏览器在后台进程执行一次 DNS 查询
- preconnect,能够让浏览器在后台进程发起一次握手(DNS,TCP,TLS)
- prefetch,能够让浏览器发起对资源的请求
- prerender,能够让浏览器在后台进程渲染出特定的页面
- preload,在不执行资源的前提下,预先拿到该资源
HTTP/2
为 HTTP/2 环境的搭建做好准备
从目前来看,浏览器对 HTTP/2 支持度还不错,使用 HTTP/2 后,就可以利用 service worker 以及 HTTP/2 的服务器推送功能来获取更显著的性能提升。
在项目进行 HTTPS 改造时,需要评估 HTTP/1.1 项目的用户基数,需要针对这类用户构建并发送符合 HTTP2 规范的报头。
正确部署 HTTP/2
需要在载入大模块以及并行载入小模块之间找到一个平衡点。
- 将所有视图都分散到小模块中,然后在项目构建的过程中完成对小模块的压缩,最后通过 scount approach 以及异步的方式来分别实现对模块的引用及载入,对一个文件将不再需要重新下载整个样式清单或 js 文件
- HTTP/2 环境下打包 js 文件时存在问题,由于向浏览器发送很多 js 小文件的过程中会存在很多问题。 首先,文件压缩的优势被破坏。在压缩大文件的过程中,借助 dictionary reuse 可以达到优化性能的目的,然而单个小文件就不能。其次,浏览器不能针对一些工作流进行优化
确保服务器的安全性
需要检查是否正确设置 HTTP 请求头部,如 strict-transport-security,使用 Snyk 工具排除已知的漏洞以及使用 SSL Server Test 网站来检查证书是否失效。 尽量保证从外部引入的插件以及 js 脚本的载入是通过 HTTPS 协议的,发起 HTTP 请求同时设置 strict-transport-security 以及 content-security-policy HTTP 请求头。
服务器和 CDN 是否支持 HTTP/2
通过 Is TLS Fast Yet 来查看不同服务器和 CDN 对 HTTP/2 的兼容情况。
Brotli 或 Zopfli 压缩算法
- Brotli,是 Google 开源的无损数据格式,其压缩效率要远高于 Gzip 和 Deflate
- Zopfli 压缩算法,能够将数据编码成 Deflate、Gzip、Zlib 数据格式。用 Zopfli 算法压缩过后的文件能够比同样用 Zlib 算法压缩的文件小 3%-8%
激活 OCSP stapling
激活服务器的 OCSP stapling,可以减少 TLS 握手所需的时间,加速 TLS 握手过程。
使用 IPv6
因为 IPv6 自带 NDP 以及路由优化,能够让网站的载入速度提升 10%-15%。
HPACK 压缩算法
如果网站使用了 HTTP/2,需要检查服务器有没有执行 HPACK 对 HTTP 的响应头进行压缩,来减少不必要的消耗。
使用 service worker
如果网站切换到 HTTPS,可以使用 pragmatist-service-worker 通过 service worker cache 来缓存静态资源、离线页面等,也可以从缓存中拿数据。参考当前浏览器对 service worker 的支持程度。
测试与监控
监控警告
- 通过 Report-URI.io 工具监控混合内容中出现的警告
- 通过 Mixed Content Scan 工具扫描支持 HTTPS 的网站是否存在混合内容
使用 Devtools
在 DevTool 中选一个调试工具来对每一个功能进行检查,确保知道如何分析渲染性能和控制台输出、明白如何调试 JS 以及编辑 CSS 样式。参考开发者工具的调试技巧。
使用代理浏览器或过时浏览器测试
完成 Chrome 和 Firefox 的测试是不够的,还需要关注部分区域占比较高的浏览器,如 UC 浏览器、Opera Min 等, 也需要了解一下受关注国家的平均网速。
持续监控
在进行快速、无限制的测试时,最好使用一个个人的 WebPageTest 实例。建立一个能自动预警的性能预算监听。建立自己的用户时间标记从而测量并监测具体商用的数据。使用 SpeedCurve 对性能的变化进行监控,同时利用 New Relic 获取 WebPageTest 没法提供的数据。SpeedTracker,Lighthouse 和 Calibre 都是不错的选择。
部署私密的 WebPageTest 测试环境,有助于快速构建测试用例。针对性能开销大的环节建立自动报警机制,可以使用 SpeedCurve 对性能的变化进行监控,利用 New Relic 获取 WebPageTest 无法提供的数据。
3 精读
这一部分会介绍一些上述没有提到的方法,主要是利用 Devtools 工具对性能优化策略或方法进行深入的解读和分析。
通过 Devtools 排查渲染性能问题
页面代码被转换成屏幕上显示的像素,这个转换过程可以简单归纳为以下流程,包含五个关键步骤:
- Javascript
- Style
- Layout
- Paint
- Composite
Timeline
通过 Chrome Timeline 对页面进行 Record,其中绿色波浪线就是页面的帧率。波浪线越高表示帧率越高,反之亦然,帧率区域上边标红一行区域,表示有问题的帧,凡是标红的帧都是存在问题的,排查问题时,需要着重关注帧率低和标红的区域。
需要逐一排查带红色角标的帧,即是有问题的帧:
点击选中该帧,可以看到详细的耗时和简单的问题描述:
Javascript Profiler
如果发现运行时间很长的 JavaScript 代码,则可以开启 DevTools 中 JavaScript profiler 选项,可以看到页面中的函数调用链路,就能分析出 JavaScript 代码对于页面渲染性能的影响,从而发现并修复 JavaScript 代码中性能低下的部分。那么如何修复 JavaScript 代码中性能问题呢?
使用 requestAnimationFrame
假设页面上有一个动画效果,想在动画刚刚发生的那一刻运行一段 JavaScript 代码。那么唯一能保证这个运行时机的,就是 requestAnimationFrame。而大部分代码都是用 setTimeout 或 setInterval 来实现页面中的动画效果。这种实现方式的问题是,setTimeout 或 setInterval 中指定的回调函数的执行时机是无法保证的,如果是在帧结束的时候被执行,就意味着可能失去这一帧的信息,也就是发生 jank。
降低代码复杂度或者使用 Web Workers
JavaScript 代码是运行在浏览器的主线程上的。与此同时,浏览器的主线程还负责样式计算、布局,甚至绘制等的工作。可以想象,如果 JavaScript 代码运行时间过长,就会阻塞主线程上其他的渲染工作,很可能就会导致帧丢失。
因此,需要规划 JavaScript 代码的运行时机和运行耗时,或在浏览器空闲的时候来来运行更多的 JavaScript 代码。
也可以把纯计算工作放到 Web Workers 中做,前提是这些计算工作不会涉及 DOM 元素的存取。一般来说,JavaScript 中的数据处理工作,如排序或搜索比较适合这种处理方式。
如果 JavaScript 代码需要存取 DOM 元素,即必须在主线程上运行,那么可以考虑批处理的方式,把任务细分为若干个小任务,每个小任务耗时很少,各自放在一个 requestAnimationFrame 中回调运行。
Render(Style & Layout)
render 部分包括 Recalculate Style 和 Layout,如果发现 render 部分耗时较长,需要分别从这两部分进行分析。如果这一帧,触发了强制 layout,Timeline 会用红色角标标出,这是需要进行优化的地方。
如果需要具体分析 Recalculate Style,可以选中 Recalculate Style 部分,查看受影响的元素个数、触发 Recalculate Style 函数以及警告提示。
如果需要分析 Layout,可以选中 Layout 部分,同 Recalculate Style 一样。
那么如何提升 Render 部分的性能问题呢?
降低样式计算和复杂度
添加或移除一个 DOM 元素、修改元素属性和样式类、应用动画效果等操作,都会引起 DOM 结构的改变,从而导致浏览器需要重新计算每个元素的样式、对页面或其一部分重新布局(多数情况下),这就是所谓的样式计算。
因此需要减少执行样式计算的元素的个数,降低样式选择器的复杂度,使用基于 class 的方式,如以 BEM (Block, Element, Modifier)的方式编写 CSS 代码,能达到最好的样式计算的性能,因为这种方式建议对每个 DOM 元素都只使用一个样式 class。
避免大规模、复杂的布局
布局,就是浏览器计算 DOM 元素的几何信息的过程:元素大小和在页面中的位置。
- 尽可能避免触发布局,当修改了元素的样式属性之后,浏览器会将会检查为了使这个修改生效是否需要重新计算布局以及更新渲染树。对于 DOM 元素的几何属性的修改,比如 width/height/left/top 等,都需要重新计算布局。通过 DevTools Timeline 可以查看页面性能的分解图,从而判断布局过程是否是页面性能的瓶颈,参考能触发布局、绘制或渲染层合并的 CSS 属性清单
- 使用 flexbox 替代老的布局模型,在相同数量的元素下 Flexbox 布局,不仅达到了同样的显示效果,而且时间消耗也大大降低,因此需要在对页面布局模型的性能分析的基础之上,来选择一种性能最优的布局方式,而且应该努力避免同时触发所有布局
- 避免强制同步布局事件的发生,将一帧画面渲染到屏幕上的处理顺序是执行 JavaScript 脚本、样式计算、布局。但还可以强制浏览器在执行 JavaScript 脚本之前先执行布局过程,这就是所谓的强制同步布局。为了避免触发不必要的布局过程,应该首先批量读取元素样式属性,然后再对样式属性进行写操作,过早地同步执行样式计算和布局是潜在的页面性能的瓶颈之一
- 避免快速连续的布局,如果想确保编写的读写操作是安全的,你可以使用 FastDOM,它能帮你自动完成读写操作的批处理,还能避免意外地触发强制同步布局或快速连续的布局
Paint
Paint(绘制)其实是生成元素呈现的像素的过程。在页面的整个被解析、执行、渲染的过程中,Paint 通常来说是代价最高的一步,因此尽量减少 Paint 时间,甚至避免 Paint 的发生,对页面性能的提升有着很重要的作用。
如何触发 Paint
- 触发了 Layout,那么一定会触发 Paint
- 改变元素的一些非几何属性,如背景、颜色、阴影等,不会触发 Layout,但是依然会触发 Paint
如何定位 Paint
Timeline 中绿色部分就是 Paint 部分,Summary 会展示绘制的总体情况,包括绘制的元素、元素本身绘制耗时、元素子元素绘制耗时。如果发现绘制的区域超过了本来期望的区域,那么就是需要优化的。更加详细的信息,可以切换至 Paint Profiler,包括了每个具体 Paint 的调用和 Paint 区域截图。当页面发生 Paint 时,如果发现不期望的区域进行了 Paint,那么这里就是可以优化的。
如何优化 Paint
- 提升元素渲染层为合成层,页面的绘制并非是在单层画面里完成的,浏览器的渲染原理,是浏览器将 DOM tree 映射成 GraphicsLayer tree,中间是经过了 RenderObject、RenderLayer 的一系列映射。元素所在的层提升为合成层后可以减少 Repaint
- 使用 transform 或 opacity 实现动画,对于独立的合成层应用 transform 和 opacity 是不会触发 Repaint 的,因此尽量对 transform 或 opactiy 应用动画来实现效果
- 减少绘制区域,对于不需要重新绘制的区域应尽量避免绘制,已减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航 header,在页面底部某个区域 Repaint 时,整个屏幕包括 fix 的 header 也会被重绘,而对于固定不变的区域,期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层
- 降低绘制复杂度,对于无法避免的 Paint,需要尽可能的减少 Paint 的消耗,有些效果的 Paint 代价十分昂贵,比如绘制一个阴影可能就比绘制一个边框更加耗时,因此开发过程中,需要研究能够实现相同的效果,同时却能达到更小的 Paint 消耗的方法
Composite
渲染层合并,对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。
提升为合成层简单说来有以下优点:
- 合成层的位图,会交由 GPU 合成,比 CPU 处理更快
- 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
- 对于 transform 和 opacity 效果,不会触发 layout 和 paint
- 对于诸如 fixed 的合成层,移动时不会触发 repaint
提升动画效果的元素
合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少 paint,可以把动画效果中的元素提升为合成层。提升合成层的最好方式是使用 CSS 的 will-change 属性。
合理管理合成层
创建一个新的合成层并不是无消耗的,它得消耗额外的内存和管理资源。实际上,在内存资源有限的设备上,合成层带来的性能改善,可能远远赶不上过多合成层开销给页面性能带来的负面影响。同时,由于每个渲染层的纹理都需要上传到 GPU 处理,因此还需要考虑 CPU 和 GPU 之间的带宽问题、以及有多大内存供 GPU 处理这些纹理的问题。
防止层爆炸
同合成层重叠也会使元素提升为合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况。因此显式声明的合成层,还可能由于重叠原因不经意间产生一些不在预期的合成层,极端一点可能会产生大量的额外合成层,出现层爆炸的现象。
3 总结
现在随着 web 应用的复杂性日益增加,其性能优化的重要性越来越突出,且性能优化的方法、技巧、工具也越来越丰富和复杂,本文所展示的内容仅仅只是管中窥豹,希望读者们可以在此讨论一些在实际场景中的性能优化问题以及解决方案。
讨论地址是:精读《2017 前端性能优化备忘录》 · Issue #39 · dt-fe/weekly
如果你想参与讨论,请点击这里,每周都有新的主题,每周五发布。
发表评论 (审核通过后显示评论):