1. 前言
“输入url到页面显示这个过程中发生了什么”是各位前端们背得滚瓜烂熟的面试题了。在我心中它是前端面试最经典的题目没有之一,通过这个流程可以很好地发散开覆盖到大部分的前端面试问题。
而在这次落地页优化之前我一直认为它仅仅是个“为了面试而出现”的八股文问题,但在落地页优化的过程中发现这个问题是具有现实意义的:它直接提供了页面优化的思路。
正如“木桶原理”说的:
一只木桶盛水的多少,并不取决于桶壁上最高的那块木块,而恰恰取决于桶壁上最短的那块。
我们在做优化,无论是性能优化还是业务链路优化,都会遵循这个原则,找到产生瓶颈的那个节点进行优化。完成一轮之后又开始回溯各个节点找到新的瓶颈重复上述步骤,最终完成整个优化工作。而“输入url到页面显示这个过程中发生了什么”这个问题的答案,正标明了需要去检查的各个节点。
在这篇文章中我整理了这次落地页优化中的一些思路和优化方案。
2. 项目介绍
业务线中有一个动态落地页和配置落地页的后台,在管理后台中进行落地页的搭建(如添加/删除/移动组件,配置组件内容),在落地页中读取管理后台的配置生成相应的页面。
落地页使用vue搭建,模板文件由服务器端动态生成并把后台配置好的组件列表挂载到window对象下;
浏览器接收到模板文件之后开始请求资源并执行资源,由vue进行页面的搭建,包括发出异步请求获取商品的信息,遍历渲染组件列表并使用商品信息填充组件,完成整个落地页的搭建。
参考整个业务流程:
3. 优化背景
参考页面业务流程可以发现:当访问落地页的网址,浏览器向服务端发出请求之后,会先经历
资源请求->执行js->页面构建->图片请求->页面再次渲染(reflow/repaint)
因为资源和图片都在CDN上,所以强网的情况下能得到非常快的加载速度,首屏渲染虽然没有特别快但也是能接收的那种速度。
在弱网下受限于下行速度的限制,首屏渲染的瓶颈卡在了资源传输上;而使用vue-lazyload进行图片懒加载在这种情况下也出现了一个问题:页面内的图片会先使用base64的占位图触发img标签的load事件,进而在真正的图片没加载完成之前就触发页面的load事件,影响数据埋点上报的准确性(数据埋点会在页面load事件之后进行一次页面曝光的埋点上报),这也让页面性能监控采集到的数据变得不准确。
4. 粗暴地计算首屏渲染时间
对于浏览器渲染/解析代码来说,各个流程的速度快慢往往为:
js执行 > 页面渲染 > 资源加载
对于落地页来说,首屏往往会包含两三张图片。
基于这两个判断我们可以整理出如下的耗时计算:
- 强网下资源(代码/图片)普遍能在50ms内加载完成,因此页面流程耗时可以理想且粗暴地表现为
模板文件请求(150ms) -> 各种js/css资源并行请求(50ms * 6 速度太快可以认为是串行加载) -> js执行/页面绘制(50ms,页面渲染过程中这块耗时占比很小因此粗暴按50ms计算) -> 图片请求(50ms * 3) -> 页面触发重绘/回流完成渲染(忽略不计)
这个流程是一个理想的流程,仅为了粗暴表示页面耗时的计算,不代表真实业务场景。
通过这个流程可以算出强网下完成落地页的首屏渲染需要650ms;参考图片
到了弱网下各个资源的TTFB时间普遍需要500~600ms,加上资源本身的传输时间,小体积的资源一般会产生700~800ms的传输开销,大体积(超过20kB)的资源比如
vue.min.js 打包出来的main.js vendor.js和样式文件
会产生1~1.5s的时间开销,对于基本50kB以上的图片更是会产生1.5s以上的传输时间。模板文件请求(800ms) -> 各种js/css资源请求(1.5s 基本是并行加载的请求只计算耗时最久的) -> js执行/页面绘制(50ms,页面渲染过程中这块耗时占比很小因此粗暴按50ms计算) -> 图片请求(1.6s 也是并行加载的) -> 页面触发重绘/回流完成渲染(忽略不计)
通过这个流程可以算出弱网下完成落地页的首屏渲染需要3950ms;参考图片
5. 优化思路
常见的思路自然是做服务端渲染或预渲染。考虑到对历史项目做大改动有一定风险因此放弃了服务端渲染的方案;预渲染更适合一些静态页面,对于需要根据页面id和商品id动态生成的页面就有点捉鸡。因此优化思路就集中在了如下几点:
- 观察弱网情况下的页面耗时可以发现:因为首屏图片依赖vue和lazyload动态生成并加载,这导致图片请求必须等到js资源加载执行完成之后才发出。我们期望能在首次加载资源的时候利用资源并行加载的特性对首屏图片做一次预加载,省下上文所述的图片请求流程中1.6s的开销。实现这一步可以利用浏览器的缓存机制,当读取本地缓存资源(disk cache/memory cache)时只会有0~3ms的时间开销,可以忽略不计。
- 页面介绍的页面流程中提到,落地页中的图片通过请求接口得到。直接想到的是提前发出接口请求得到页面图片,提取出首屏的图片进行预加载。但同步XHR不是一个推荐的做法,见同步和异步请求
注意:从Gecko 30.0 (Firefox 30.0 / Thunderbird 30.0 / SeaMonkey 2.27),Blink 39.0和Edge 13开始,主线程上的同步请求由于对用户体验的负面影响而被弃用。但开发人员通常不会注意到这个问题,因为在网络状况不佳或服务器响应速度慢的情况下,挂起只会显示同步XHR现在处于弃用状态。建议开发人员远离这个API。
而异步请求因为会被放置到异步队列中等待主线程空闲再去执行,即使提前发出接口请求也会等到一堆主线程中的js执行完毕之后才被处理,其实和前端框架中发出区别不大。
因此落地页中的接口请求需要放到服务端中完成。这一步是整个优化的前提,不提前取到页面图片无论再怎么优化都会等到js加载完框架开始构建页面之后才会发出图片请求,然后等图片返回之后才渲染出页面。
- 做完以上两点理论上弱网下页面的首屏渲染速度会有非常快(秒级)的提升。解决了图片加载速度的瓶颈之后可以做进一步的优化。比如拆分页面的组件,优先渲染首屏组件,异步渲染剩下的组件。
- 具体的框架编程层面,弱网情况下mounted中的异步事件也先于load执行,而且异步事件中发出的一些请求(比如以Image的方式发出的埋点)会被计入load的时间中。优化的目标是是尽早完成首屏渲染和load,在load之后做额外的页面逻辑。通过判断document.readyState值,当页面还未完成readyState的complete事件时挂载readyState事件的监听器等到页面完成complete之后再做额外的逻辑。
- 观察上文中弱网情况下的网络贴图可以发现,区别于强网下的只会加载一张首图的情况,弱网下不仅发出了首图的请求,还触发了懒加载对屏幕之外图片的请求。基于上面一点的推迟非首屏逻辑执行的思路,对lazy-load插件也进行一次修改,页面complete之后再挂载图片的监听,这样可以避免lazy-load一执行就触发首屏之外图片的加载拖长首屏渲染时间。
下文会先介绍性能观测的几个工具和指标,然后说明具体的优化方案和代码。
6. 如何去观测页面性能及一些指标
本次优化中主要借助三个工具进行性能的观测,分别是window.performance对象,Chrome DevTools中的Network选项卡和performace选项卡。
6.1 Window.performance
这个api主要用于回溯页面的一些性能数据,上报页面性能生成性能报表。这里主要用了两个属性,一个是
window.performance.timing
,另外一个是window.performance.getEntries()
。window.performance.timing
window.performance.timing
对象提供了以下参数interface PerformanceTiming { "connectStart":number; "navigationStart":number; "loadEventEnd":number; "domLoading":number; "secureConnectionStart":number; "fetchStart":number; "domContentLoadedEventStart":number; "responseStart":number; "responseEnd":number; "domInteractive":number; "domainLookupEnd":number; "redirectStart":number; "requestStart":number; "unloadEventEnd":number; "unloadEventStart":number; "domComplete":number; "domainLookupStart":number; "loadEventStart":number; "domContentLoadedEventEnd":number; "redirectEnd":number; "connectEnd":number; }
通过这些参数可以计算出页面的一些性能,比如
'重定向时间' -- (time.redirectEnd - time.redirectStart) / 1000; 'DNS解析时间' -- (time.domainLookupEnd - time.domainLookupStart) / 1000; 'TCP完成握手时间' -- (time.connectEnd - time.connectStart) / 1000; 'HTTP请求响应完成时间' -- (time.responseEnd - time.requestStart) / 1000; 'DOM开始加载前所花费时间' -- (time.responseEnd - time.navigationStart) / 1000; 'DOM加载完成时间' -- (time.domComplete - time.domLoading) / 1000; 'DOM结构解析完成时间' -- (time.domInteractive - time.domLoading) / 1000; '脚本加载时间' -- (time.domContentLoadedEventEnd - time.domContentLoadedEventStart) / 1000; 'onload事件时间' -- (time.loadEventEnd - time.loadEventStart) / 1000; '页面完全加载时间' -- '重定向时间' + 'DNS解析时间' + 'TCP完成握手时间' + 'HTTP请求响应完成时间' + 'DOM结构解析完成时间' + 'DOM加载完成时间';
window.performance.getEntries()
window.performance.getEntries()
方法会返回一个数组,包含页面内所有http请求的数据,各请求的数据类型和结构同window.performance.timing
const PerformanceEntries: Array<PerformanceTiming>;
使用方式也和
window.performance.timing
一致,通过各个属性之间的计算得到具体某个http请求的性能参数。得到资源具体的请求参数,我们就可以利用这些参数做一些判断,比如可以得到首图的加载时间。如果首图加载时间在200ms以内可以认为是强网状态,就可以在页面加载完成之后对首屏之外的图片进行预加载,提供更好的页面体验。6.2 Chrome DevTools - Network
Network选项卡可以说是观察资源加载状态最重要的一项工具了。一般我们会关注资源的大小、加载状态、加载时间,页面的DOMContentLoadeds和Load时间等;本次优化中我更多地关注各资源的加载时间点,包括请求发出的时间点和各资源并行请求时的状态。正如上文优化思路中说的
我们期望能在首次加载资源的时候利用资源并行加载的特性对首屏图片做一次预加载
同时我还利用Network提供的Network Throttling功能模拟弱网下的网络情况进行页面性能测试。
6.3 Chrome DevTools - Performance
Performance选项卡功能非常强大,打开Record之后会不断截取页面数据最后生成性能分析。在新版chrome上performance提供了包括FP(first paint),DCL(DomContentLoaded),FCP(first contentful paint),L(Load),LCP(largest contentful paint)。
FP(First Paint): 页面在导航后首次呈现出不同于导航前内容的时间点。
FCP(First Contentful Paint): 首次绘制任何文本,图像,非空白canvas或SVG的时间点。
FMP(First Meaningful Paint): 首次绘制页面“主要内容”的时间点。
LCP(Largest Contentful Paint): 可视区域“内容”最大的可见元素开始出现在页面上的时间点。
对于首屏优化来说,我们最关注的是从页面发出第一个请求(index.html模板请求)到LCP之间经过的时间,这个是用户从打开页面到看到较为完整的首屏经过的时间;以及从FP开始到LCP之间经过的时间,这个是用户从感知到页面开始渲染到首屏渲染完成经过的时间。
7. 实战开始
参照上文的优化思路和性能观测方法,开始对项目做一些优化实战。
7.1 参考Network分析页面加载流程
图4:使用Network工具分析页面请求
先对未优化之前的页面进行问题排查--在网络中切换到3G模拟弱网情况,访问页面之后在网络总览中可以看到各个资源的加载时间点和耗时:
- 前1600ms:全都花费在了模板(即第一个文件preview,即类似于index.html之类的文件)的请求上。
- 1600ms~2700ms:模板文件加载完成之后浏览器便开始解析该文件,通过该文件中的外链标签发出一堆请求获取资源。这一步便是之间主要做的事情。
- 当main.js(第九个资源,main_202006221554.js文件)加载完成之后浏览器执行main.js中的代码。在main.js中调用
new Vue()
创建Vue的实例并开始页面元素的搭建。
- 2700ms:总览中出现了一条蓝色的竖线,该竖线标识
DOMContentLoaded
事件的触发。差不多的时间点里图中选中的请求ajaxDetail
在main.js
中作为异步请求发出,该请求中包含页面里使用到的一些图片地址。
- 3500ms~4800ms:即图中红框圈出来的部分,在3500ms左右发出了首屏图片的请求,经过1300ms左右完成首屏图片的加载。
- 3600ms~5500ms:即图中黄框圈出来的部分,发出了几个埋点和统计代码的请求,等这些请求完成之后总览中出现了一条红色的竖线,该竖线标识
Load
事件的触发。
此时上报首屏渲染完成的埋点。
7.2 参考页面加载流程整理优化点
- 模板资源加载优化
可以发现第一个模板请求独占了页面30%的Load时间。弱网下请求的
Waiting(TTFB)
时间往往会数倍于Content DownLoad
时间。
Waiting(TTFB)
是Time to First Byte
的缩写,当网络情况为固定量时,TTFB便更多地标识了服务端响应的速度。因此这一步的优化更多地由服务端进行,比如增加页面/数据的缓存来加快响应时间,或是将静态页面部署到CDN上得到更快的响应。
Content DownLoad
则直接标识了资源下载花费的时间,该时间和资源体积,网络状况成正相关。当网络状况固定时可以考虑压缩页面体积,比如开启GZIP压缩等。
这里面会遇到一个矛盾便是:如果我们新增了脚本,是通过外联的方式加载还是直接把脚本内联在模板文件中执行?在不考虑模板代码可维护性的情况下必然是把脚本内联能得到更快的加载速度和执行时间,因为外联的脚本会产生额外的TTFB,而且在实际观察中发现37KB的外联资源需要190.90ms的下载时间,而4KB的外联资源反而有时会产生200ms以上的下载时间--如果追求极致加载速度的话内联代码是更好的选择。- 异步请求优化
异步请求极大地提升了网页的用户体验,因此现有的规范和chrome都取消了对同步请求的支持;但异步请求也是首屏渲染的大敌:动态页面的话用户必须要等到异步请求返回之后才能看到首屏的内容。这也是目前服务端渲染、预渲染方案的一个出发点,将原本在客户端中通过异步请求方式获取到的数据/内容直接在服务端渲染好返回。
在实际的优化中我采取了折中的方案:由服务端把原先由异步请求获取的数据塞到window对象下,前端直接取window下挂载的数据走原来的业务逻辑。
- 图片加载节点优化
正如在优化思路中提到的,我们可以利用浏览器的缓存策略优化图片的加载节点。
比如在图四中可以看到的,图片请求在3500ms才被发出,在4800ms才完成请求--这意味着至少在4800ms的之后才有可能渲染出首屏内容。
如果我们能充分使用浏览器的缓存策略,结合浏览器对多请求并行的支持,甚至升级协议到HTTP2的得到多路复用特性;在模板开始解析之后便发出图片请求,便可以为之后页面使用到的图片提前做好缓存。
具体到图四中来说,如果可以把图片请求提前到2400ms(此时已经有部分资源请求完成,浏览器可以再发出一些请求)左右,或者如果并发请求数量允许的话在1600ms的时候发出图片请求,那就可以在3700ms(或者更理想地在1600ms发出请求2900ms完成请求)的时候完成图片的加载渲染。
上一步中完成的异步请求优化为这一步的图片加载优化提供了基础:可以在数据挂载之后插入一段脚本,解析出接口数据中的图片地址直接发出请求,而不用等到前端框架构建页面的时候再发出。
- 图片预加载的优化
除了创建img发出图片请求,在首屏渲染的场景下还可以使用 preload 的方式来得到更高的加载优先级:
对于这种即刻需要的资源,你可能希望在页面加载的生命周期的早期阶段就开始获取,在浏览器的主渲染机制介入前就进行预加载。这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。
首先判断浏览器是否支持preload
/** * 判断是否支持preload */ const isPreloadSupported = () => { const link = document.createElement("link"); const relList = link.relList; if (!relList || !relList.supports) { return false; } return relList.supports("preload"); };
使用preload的方式发出图片
/** * 使用preload的方式预加载图片 */ const sendImgWithPreload = (url) => { const link = document.createElement("link"); link.rel = "preload"; link.as = "image"; link.href = url.ossimg(); document.head.appendChild(link); };
- vue-lazyload优化
现有的页面中会读取组件配置统一给图片配置是否需要lazyload,这带来的问题便是首屏图片不需要懒加载却要等到lazyload执行之后才触发真实图片的请求。
而对于用户浏览页面来说期望能在尽快完成首屏渲染的同时,能尽可能快地完成剩余屏幕中图片的加载,这样在滑动的时候能流畅地浏览到页面中的图片。vue-lazyload的配置参数中提供了
preLoad
属性用于配置触发图片加载的阈值,为了达到尽可能快地加载剩余图片我们可以把这个值填得很大。但这会带来一个问题:在弱网下剩余图片的懒加载可能会在Load事件之前触发,会抢占首屏渲染的资源并拖长Load的时间,这和优化的初衷是违背的。因此需要对上述两点进行优化。
首先是首屏图片不需要懒加载。在发出图片预加载请求的时候可以以预加载图片url作为key构建一个Map(命名为preloadImgMap),在Vue中生成图片元素时判断图片地址是否存在于preloadImgMap中,如果存在的话就不加上lazyload的指令,当做普通图片加载便可以。
其次是vue-lazyload挂载时机的优化,首屏不依靠lazyload插件之后剩余屏幕中图片的预加载便可以推到页面Load事件之后进行:在首屏渲染完成之后无论加载几个屏幕的图片都不会对用户体验产生大的影响。优化挂载时机首先想到动态使用插件,但在文档中提到使用插件的时机是固定的:
通过全局方法 Vue.use() 使用插件。它需要在你调用 new Vue() 启动应用之前完成
因此考虑修改插件的源码。将vue-lazyload源码拷贝到本地,/src为源码目录,本地开发可以直接引入
/src/index.js
。找到插件install
方法中的Vue.directive('lazy', { bind: lazy.add.bind(lazy), update: lazy.update.bind(lazy), componentUpdated: lazy.lazyLoadHandler.bind(lazy), unbind: lazy.remove.bind(lazy) })
bind调用的方法在
/src/lazy.js
中,可以看到lazy.js的add()
方法中调用ReactiveListener
初始化监听对象开始对图片进行懒加载监听。只要把初始化监听对象的过程推迟到页面Load事件之后便可以达到优化lazyload挂载时机的目的。
完成这两步之后就可以放心把应用VueLazyload时传入的preLoad
参数改到3甚至更大,即能加快首屏渲染速度也能提前加载剩余页面的图片。