前端性能优化笔记
随着前端页面的复杂化,网页的载入速度是越来越慢了,而最近的日子里面,Google 等公司也是在大力推动 PWA 的落地,力图使网页呈现向着原生程序的体验方向去优化和呈现,提示用户的体验。
前端的性能优化粗鲁的划分可以分为网络相关和页面渲染的部分。网络请求一般会和用户网络带宽,延迟,服务器带宽等相关,页面渲染一般会和浏览器解析代码和静态资源文件,以及渲染页面有关。
下面我会从用户加载网页开始讲解起,因为这样更加符合直觉。
首屏加载
顾名思义,指的是网页首页(或者是进入网页看见的第一个页面)的加载,里面包含了包含网页文档,CSS,JS,图片,字体文件的加载,首屏相关的数据 API 调用,浏览器渲染布局与样式。这部分的涉及流程还是很多的,我梳理了一张图,帮助大家理解,大致的顺序如下:
首屏加载示意图这里面到解析 CSSOM 和JS 脚本之前,基本都是和网络请求相关的,这里一一讲解一下
DNS 解析 (DNS Lookup):
我们平时访问网站都是使用的网站域名(Domain),在解析网站之前,浏览器需要将域名解析为对应服务器的 IP 地址,才能发起对后端服务器的资源请求,DNS 解析就是浏览器在解析域名的过程,其中会去层级查询 DNS 服务器。这一块的时间耗损一般是不需要注意的,但是如果你需要建立一个跨越多个国家的网站,在移动设备上提供良好的用户体验,或者资源文件分散在非常多个网站下,DNS 解析的延迟优化就可能需要考虑下了。对此考虑下 HTML5 中的 DNS Prefetch 技术,现代浏览器会预先做 DNS 解析,可以在网页文件中添加下面的代码。
<!--打开和关闭 DNS 预读取-->
<meta http-equiv="x-dns-prefetch-control" content="on">
<!--或者 强制查询特定主机名-->
<link rel="dns-prefetch" href="http://www.spreadfirefox.com/">
参考链接:
* https://developer.mozilla.org/zh-CN/docs/Controlling_DNS_prefetching
* https://tech.youzan.com/dns-prefetching/
请求 HTML 文档:
浏览器拿到对应服务器的 IP 地址后,就会发起对服务器的请求,获取 HTML 文档。
这里面除了刚才的 DNS 时间外,比较重要的就是服务的响应时间了,这个时间称之为 TTFB (Time to first Byte,意思是发起请求到获取到服务相应的第一个比特的时间间隔)。TTFB 的时间耗费主要在网络链路的传输
与服务器处理数据
上,所以优化方向也集中在优化网络链路的传输速度
与服务器的处理速度上了
,可以参考的优化点在:
- 使用 CDN 优化网络链路
- 使用缓存代理层,避免服务器计算冗余,进而减少服务器的请求响应时间。
此外, 还有就是文件的下载时间(Content Download),如果你的服务器开启了 Gzip 压缩,浏览器的 Gzip 解压过程也会算在这段时间中。这部分的优化点在于如何减少资源文件的大小,如果文件过小,可以考虑关掉 Gzip 压缩,因为可能 Gzip 解压的时间,会比单纯下载的时间多。
注意,上面讲到的 TTFB 与下载时间是所有 HTTP 请求都会有的一个阶段,所有 TTFB 的优化会优化到所有的网络请求。
解析 DOM,解析 CSS,编译 JS:
获取到 HTML 文档后,浏览器就开始将 HTML 解析为 DOM 树,同时解析文档中的资源文件,这个时候,浏览器会边解析边渲染,但是一旦解析阻塞渲染的资源文件时,比如 CSS 与 JS 文件( JS 在现代浏览器其可以在 script 标签中标注 async 属性使其变成一个非阻塞的资源,不过请慎用,有可能会因为加载顺序的原因,返回导致你的应用崩溃或者变的更慢),浏览器会暂时停止解析 HTML,开始请求对应的资源文件,并解析相应 CSS 和 JS 文件源码。对应的一些文件图片字体等资源,因为不会特别影响布局,所以浏览器会不会中断 HTML 的解析过程。
这里的优化点设计到一个关键渲染路径(Critical Rendering Path)的概念,就是我们需要优先显示与当前用户操作有关的内容。
摘抄一段 Google Develop 的一张图片,如下:
render-tree-construction可以看到,最终网页的呈现在于 DOM 与 CSSOM 结合生成渲染树,最后形成了用户可以看到的页面,那么优化的方向就是首屏,我只加载与首屏相关的资源:
- 首屏需要的 HTML 代码(HTML)
- 首屏显示与交互需要的样式代码 (CSS + JS)
这里可以使用 Chrome 60 版本 Devtool 引入的 Code Coverage 工具,大致可以分享哪些代码是被使用过的,哪些是暂时没有用到的代码。
代码覆盖率分析完成非首屏的代码之后,就需要拆分代码了,CSS 的拆分,就不细说了,大致都是,根据页面加载,放到对应的 HTML 页面或者和对应页面的 JS 绑定到一起之类的优化手段。而对应现在比较主流的单页面应用来说,JS 代码的膨胀是会同时影响下载时间与编译时间的。
目前的通常的做法是通过代码拆分(Code Splitting)的方式加载代码,最新的 JS 标准中有引入 Import
函数来支持动态导入代码的功能,现代主流的打包工具例如 Webpack,也是在 2.0 版本后就支持了此功能。当然,目前来说,主流的切分方案都是根据页面来切分的,目前主流的框架的路由功能都有提供支持,以下分别是下 Angular,Vue 以及 React (React-Router v4) 路由中代码拆分的写法
// Angular
RouteModule.forRoot([
{
path: 'home',
loadChildren: 'app/home/home.module#HomeModule',
},
{
path: 'admin',
loadChildren: 'app/admin/admin.module#AdminModule',
},
]}
// vue
const router = new VueRouter({
routes: [
{ path: '/foo', component: () => import('./Foo.vue') }
]
})
// React
// 示例略复杂,请直接查看官网示例 <https://reacttraining.com/react-router/web/guides/code-splitting>
值得注意的是,代码切割后,虽然首屏的加载速度会得到提升,但是你需要注意一些小技巧,可以提升一些用户体验
- 建议将工程中的第三方依赖抽离到一个单独的文件中,比如 vendor 中,并使用类似 Webpack 的 CommonPlugin 插件将第三方代码集中在 vendor 文件中,其他文件在构建代码的时候只会写入一个引用。原因有二,一是一般情况,第三方代码工程中可以比较长的时间不会发生更改,这样浏览器缓存命中几率比较大,可以节省下载文件的时间。二是避免构建中,第三方依赖代码在不同文件中都有,产生代码冗余。
- 建议对分割的代码文件使用 HTML5 特性中的 prefetch/preload 技术,因为,代码分割后,在切换页面时,浏览器会发出请求来获取对应页面的 JS 文件,导致页面一段时间的加载时间,这回破坏之前单页面应用路由无缝切换的体验。使用 prefetch/preload 技术预先下载对应的 JS 文件,可以预防切换页面的体验下载。
交互体验优化
现代的前端框架都有将 DOM 操作封装的趋势,自然在优化优化交互性能上,基本上就以减少不必要的 DOM 操作为基准咯。而自从 React 提出了单向数据流和组件化的思想后,现在的主流框架都有吸收和采纳这种思想。进而到到了一个什么效果呢,我只要限制了组件级别的渲染,那么框架就不会对对应组件进行重新渲染,也就没有 DOM 的重新计算。
这里面 React
体现的尤为明显,我这里就以 React
为基准讲解。
React 中的组件只接受一种输入,就是 Props,类似 HTML 元素中的属性。只要一个组件的 Props 不变,这个组件就不会触发是否需要相应的 Rerender 判断。而 React 的每次接受到新的 Props 后,更新判断都在 shouldComponentUpdate
这个方法中进行判断,如下图所示:
而 Props 虽然更新了,但是可能对应的值其实并没有发生变化,比如 组件 A 是一个 checkbox,他接受一个 checked
prop,可能他被用在一个多选的列表中,比如下图,刚好该组件已经被选中了,此后用户去勾选了 check All
的操作,这个组件的 checked
被被重新设置为 true
,而这个 prop 和之前的 prop 是完全相等的,但是 React 认为这是一个新的 prop,就会触发上图所示的 React Update 声明周期。
针对这种情况,React 官方提供了 React.PureComponent 组件,该组件默认会对组件更新的 Props 进行浅比对,结合 Immutable 的话,可以减少绝大部分 React 中不必要的重渲染,对提升性能非常有帮助。强烈推荐在 React 项目中使用 PureComponent 代替原本的 Component 去实现组件,并考虑使用 Immutable。
如果性能仍然遇到多次冗余的重渲染,可能有两个原因会导致这个问题:
- 没有使用 Immutable,PureComponent 的浅比对无法生效(比如更改了对象中局部值,并使用了 Object.assgin({}, {...state, others }) 写法来更新),可以考虑使用 Immutable 类库,或者考虑覆写
shouldComponentUpdate
方法进行深比对。 - 使用了 Redux,在 connect 的
mapStateToProps
方法中组合了多个state
值,或者进行了拷贝语法生成了新的对象,导致浅比对失效,可以考虑使用reselect
库,进行优化。
在 Angular 中,可以将组件改为 OnPush 模式,类似 React 的 PureComponent,只有当 Props 变化后,才会更新。
@Component({
// ...
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MovieComponent {
// ...
}
值得注意的是,react-redux 中的 connect 方法本身就有进行浅比对哦
连接:https://github.com/reactjs/react-redux/blob/76dd7faa90981dd2f9efa76f3e2f26ecf2c12cf7/src/components/connectAdvanced.js#L17
其他优化
Prefetch, Preload
我们在首屏加载有提到使用 HTML5 新特性去优化页面切换的加载时间,HTML5 中的 Prefetch 与 Preload 就是预获取和预加载后续可能会用到的资源文件,并缓存在浏览器中。两者的区别只在于前者只是通知浏览器,我需要这个文件,帮我提前获取下,而浏览器会在空闲的时候去获取这个文件,但是这个空闲时间是什么时候,就是浏览器厂商的实现了。而后者就是强制把预读资源文件的优先级提高了,相当于快递加急的感觉,浏览器需要在有空闲的时候马上去获取这个资源文件。
不过两个特性的支持性都不太好,preload 的支持程度更低,不过不是特别要紧,我们可以使用 JS 自己封装一个预读取的函数,下图是 Netflix 的性能提升视频中提到的一个小技巧,可以考虑采用。
Prefetching Function** Service Worker, Cache-Control 等等 **
网络IO的消耗不管你如何去优化,对于应用来说还是非常大的时间开销,所以做好缓存工作,可以大大增强用户使用体验。
常用的缓存策略里,由后端处理的Cache-Control
是比较通用的解决方案,现代浏览器,代理,CDN对此都有不同程度的支持。
Service Worker 是 Google 为推广 PWA 渐进式应用引入的技术,可以带来前端对缓存资源的强控制,特别是在离线的情况下,可以带来前端体验的进一步提升。需要注意的是,service worker 文件还是会受到 Cache-Control
的影响的。
** HTTP/2 **
HTTP/2 的目的是通过支持完整的请求与响应复用来减少延迟,通过有效压缩 HTTP 标头字段将协议开销降至最低,同时增加对请求优先级和服务器推送的支持。
上面是节选自 Google Developers 的对 HTTP/2 的描述。HTTP/2 有效解决了前端复数请求带来的建立TCP 的开销,以及浏览器对并发请求的限制带来的请求排队现象,可以有效降低网络开销带来的延迟,并且如果现有的浏览器还不支持这套协议,服务器可以实现优雅降级到 HTTP/1.1。简直就是性能提升一大利器。
** WebAssembly **
还有一项值得期待的技术,就是 Webassembly,字面意思就是 Web 上的汇编。它可以进一步加快 JS 的执行效率,据 2017 JS Conf EU 上演讲者的描述,平均有 40% - 60% 的执行性能提升,而且由于使用类似 JVM 的字节码的关系,极大的扩展了前端编程的可能性。
如何找到性能问题
然而,知道问题点,还需要有能找到问题的方式方法,是吧。我们下面就讨论下比较非常有用的性能分析工具—— Chrome DevTool。
NetWork Tab1如上图,是截图自 Chrome 63
的 Devtool,里面值得注意的是和性能相关的标签是 Network
, Performance
, Memory
。
先介绍下 NetWork
标签,下面是我用 3G模式加载新浪的首页。可以看到有大量灰色、绿色和蓝色的横向柱形图显示在概览图中。
可以点开每一个请求具体的 Timing
标签,可以看到每一个请求具体每个部分花了多长时间。
大概可以看得出,主要的时间花销分摊到 TTFB
和 Content Download
上,如前面所讲的,具体相关优化要在网络传输与文件大小上下功夫。
如果你的并发请求实在是多,就会遇到下图中 Queueing
或 Stalled
时间开销也变得举足轻重起来了。其中可能的原因有下面几点:
- 有更高优先级的请求,导致本请求排队(比如之前有设置
preload
的资源文件会被浏览器标记为高优先级的请求) - 超过六个并发请求(这是 Chrome 为 HTTP/1.0 与 HTTP/1.1 设置的最大并发请求数,不清楚其他浏览器的数量限制是多少)
- 浏览器还在为资源分配磁盘空间(硬盘的读写速度等参数相关)
最后在 NetWork
标签下注意最下面状态栏有统计网页的成功的请求数量/总请求数量,文件大小,完成时间,DOM加载完成时间等等。
下面我们接着看下 Performance
标签,主要需要关注的是 Main
区域,里面的不同颜色代表了 HTML解析(蓝色), CSS解析与样式渲染(紫色),JS代码执行与编译(黄色区域)。结合下发状态栏的四个标签可以查看对应部分的调用栈与触发的事件。
值得注意的是,还有个可以手动档调试性能的方法,就是 Web Timing API,相关数据会在 User Timing
区域显示出来,像是 React
在 v16 版本以后,就在开发模式使用了这个 API,可以看到下图中可以清晰的看到 React 加载组件相关的耗时情况,可以大致推断出哪些部分的代码执行时间过长。
最后就是 Memory
区域,主要用来分析内存泄漏的问题,只要在你觉得可能出现内存泄漏的地方记录就可以了,按照谷歌官方的建议,在记录开始于技术都点击下左上方的垃圾桶图标,强制浏览器 GC,将内存占用降低下来,作为基线参考。
在怀疑内存泄漏前,可以先使用 Chrome 的 Task Manager
先看下对应网页 Tab 是否会无限制的内存增加,预先排除一些问题。
结语
这篇文章仅仅是我在工程开发中学习和总结的个人感到比较实用的且基础的的部分。其实主要就是网络层的优化与代码执行优化两大块。性能问题,大都可以从这两处着手。
此外还有一些优化性能的工具和手段,我罗列在这里,供参考:
- 优化列表与表格数据的渲染(react): https://github.com/bvaughn/react-virtualized
- TreeShaking:中文翻译摇树,主要作用是去除依赖库中没有用到的函数与代码,现在主要个构建工具都已经支持,不过需要将编译模式设置为 ES2015 才能生效
-
React 浏览器扩展: 最新的 react 开发者工具,支持实时查看页面组件的
Reconciliation
,可以大致分析页面相关区域的重渲染频率,是否有些地方本来不会被影响的,重新渲染,都会导致性能问题。