信息流投放是公司获取公域流量的关键渠道,也是公司外部流量的主要来源。而投放落地页会作为这个流量链路的第一步最先映入用户眼帘。因此落地页整体的加载体验和交互体验变成了影响用户转化的重要因素。
摘自Google对于“核心网页指标”的报告:
为什么网页性能很重要
研究表明,更好的核心网页指标可以提高用户互动度和业务指标。例如:
研究表明,当网站达到核心网页指标阈值时,用户放弃加载网页的可能性会降低 24%。
Largest Contentful Paint (LCP) 每减少 100 毫秒,Farfetch 的网站转化率就会提高 1.3%。
我们当前的众多落地页都是由低代码平台搭建出来的,在前期投放过程中暴露出了许多性能及交互问题,譬如首页白屏、异步组件加载耗时等。
从两个比较关键的加载性能指标来看,当前页面的中位数FCP超过1500ms,中位数LCP超过4000ms,而对比业界标准,达到优秀级别的数据是FCP低于1000ms,LCP低于2500ms,显然有着不小的差距。那针对落地页,我们也会以最低达到这个标准为目标去拆解优化,同时在页面内的交互上也需要优化细节。
下面就来分享下整个优化动作是如何进行的。
拆解分析
一个性能表现不佳的Web页面,通过对Lighthouse及Performance的分析,结合加载时序图,已经可以找出90%的问题了,这里我们先来看一张对应当前页面的简易时序图:
结合整个项目代码来分析(具体的代码逻辑比较分散,这里就不贴出来了),大致总结为如下六个问题点:
1、针对DNS、TCP以及TLS连接,当前代码无任何优化逻辑,这一块儿大有可为;
2、JS/CSS静态资源文件存在单个文件过大的问题(Gzip前超过500KB);
3、静态资源包存在重复引用问题;
4、在触发FCP及LCP之前是否可以避免XHR请求的阻塞?
5、FCP完全可以在HTML加载完毕后就触发,而不应该在静态资源及XHR请求之后;
6、图片及组件仍有优化空间:异步组件、图片压缩并转为webp、图片预加载。
以常规思路针对每个具体的问题去做分析可能会得到以下办法,比如:
1、通过SSR/SSG来解决XHR请求阻塞和静态资源加载阻塞问题;
2、通过依赖分析及合理分包方案来解决静态资源文件问题;
3、通过预连接解决网络连接耗时;
4、通过预加载解决异步组件以及大图片的加载耗时。
实际优化过程:
除了重构为SSR外,其他方案相对来说成本更低,也更易实施,于是乎我们先应用相对更常规的方案,吭哧吭哧优化完毕后,加载数据表现也基本快达到我们设立的目标了,但是XHR请求和静态文件的阻塞仍然没有解决,页面还存在白屏,用户实际体感上还是有缺陷,最后就不得不考虑令人头痛的SSR重构了......
接下来针对上述问题,我这里来逐一拆解并详细分析,看看最后是如何达到满意的效果的。
工程构建
这里主要是为了解决拆解分析中提到的第2点、第3点。
1、依赖分析
这部分的第一件事情就是打开依赖分析,每次有比较大的变动都先在开发环境看看依赖分析,加载进来的东西是否合理,做到心中有数。
不管是Vite还是Webpack,都有对应的工具,比如Webpack的webpack-bundle-analyzer可视化工具,通过可视化界面以及生成的stats.json,我们就可以进行逐一分析和优化了。
2、重复依赖
这里落地页通过上面的依赖分析就发现了一个很明显的问题:
低代码平台组件库的一些组件的dependencies都写了相同的依赖包,导致npm i的时候,这些依赖被重复安装并且被重复打包:
最简单的方法就是去掉子组件的dependencies,将依赖写在最外层,当然也有更优雅的方法去处理,这里处理掉重复依赖后,生产环境的静态文件少了100KB+。
这里只是其中的一个实例,借助这个工具,同学们也能更轻松的发现一些其他的未知问题。
3、合理分包
关于分包,构建工具会有自己的一套默认策略,但是如果想做到极致,必然是需要针对具体的业务场景做定制化配置的。
针对落地页,我制定了一个分包原则,然后根据这个原则去做细化配置:
前端框架- UI库 - 核心工具库 - 同步组件 - 异步组件 - 跟随默认策略
详细的配置这里就不贴出来了,以Webpack为例就是利用splitChunks配合正则提前配置好分割参数,而其中异步组件的分割则是配合import方法来实现的,这一块儿在后面会有详细的介绍。
下图是做了细化的分包后,npm run build的静态文件列表:
实际上这个列表中的绝大部分文件都是根据页面交互按需加载的(也就是上面提到的异步组件分割),保证了在首页只加载必要内容。同时之前存在的单个大文件(单文件超500KB)也被拆分为多个小文件,图中可以看到Gzip后,最大的文件不超过100KB。
根据浏览器的6个TCP连接并发限制,在HTTP1.1时代你可能觉得这是个负优化,因为那时候还流行CSS雪碧图呢,也是为了降低并发数。但是HTTP2.0的优势在这种情况下却可以得到比较好的发挥,从落地页这边实际实验对比下来,正常网络环境加载单个大文件(800KB左右)比加载5个小文件要慢5%左右,如果是弱网环境,差距会更明显一些。
更重要的,单个大文件不利于缓存管理,一些常年不咋动的模块,比如上面分包原则中的前端框架 、UI库 、核心工具库 ,这些都是属于迭代频率很低的,单独拆出能有效利用缓存。
如果有同学觉得自己的页面加载表现不佳,同样可以尝试借鉴上面的思路去做优化。
预连接、预加载
拆解分析的第1点、第6点都会在下面的动作中得以优化。
1、预连接
预连接顾名思义就是预先建立网络连接,这其中包括DNS、TCP、TLS,当后续请求该域名资源时,可直接使用已建立好的连接。所以需要在比较关键的节点上使用,比如:JS文件域名、主图片域名、第三方重要的SDK。
当前可用方法为:
dns-prefetch,预先对指定域名进行DNS解析。
preconnect,预先建立所有连接,包括DNS、TCP、TLS。
这两个都是HTML特性,落地页这边是两个一起配合使用,在preconnect不支持的时候,以dns-prefetch来提供降级:
在上图落地页实际Connection View的表现中,可以很直观的看到,在preconnect的作用下,实际资源请求之前,TCP和TLS连接已经被预先建立了。
这块儿的时间会被直接节省掉,这还是在有DNS缓存的情况下,如果是新用户没有DNS缓存,那由于DNS延迟带来的时间节省将会更多。
2、预加载
预连接是建立连接但不加载,而预加载是加载但不执行,目前主要用到的是prefetch和preload特性。
2.1、prefetch、preload
- preload提示浏览器以更高的优先级立即加载静态资源并缓存,以期在后续使用时候无需等待,在浏览器中的Priority标识为High
- prefetch提示浏览器未来可能会使用到的某个资源,浏览器就会在闲时去加载对应资源并缓存,在浏览器中的Priority标识为Lowest,是优先级最低的
可见preload适合马上需要用到的资源,或者说是当前页面中的重要资源,以此来提高它们的加载优先级,落地页中,在body标签之前,我们将首页需要的JS文件都设置了preload,这样在body标签开始渲染之前,JS已经开始加载了,同时不阻塞后续标签的解析执行,有点类似于script标签的defer属性。
这里对一张主背景图使用了preload,并且通过fetchPriority将其优先级调整到了High,下面这张图就可以看出使用和不使用“提取优先级”的区别,使用preload并且将优先级设置为High后,LCP 时间从 2.6 秒缩短到 1.9 秒。
上面讲的都是关于preload的,那prefetch的应用场景在哪里?来看看它和异步组件的配合吧。
2.2、如何将预加载应用到异步组件
首先该组件会在打包时被分割为单独的名为“CmsLayerGradeTabSelector”的JS文件。
上面的webpackPrefetch注释会被webpack翻译为对应的rel="prefetch"的link标签。也就意味着该文件会被浏览器在空闲时加载并缓存,当交互触发异步组件的时候,直接去缓存中取文件。
如果不加webpackPrefetch,JS文件会按需加载,也就是交互触发的时候才加载,进而带来的就是交互体验问题了。
2.3、精确控制加载时机
通过上面的代码,在实际场景中,我们可以任意时刻控制某个资源的预加载和执行。比如你知道接下来将会播放一个视频、渲染一张大图,都可以预先加载它们。
由低代码平台引出的问题
上述步骤全部优化完毕后,整体效果提升也比较明显,测得线上的FCP和LCP已经比较接近设立的目标了:FCP低于1000ms,LCP低于2500ms,但是由于XHR和静态资源的阻塞仍未解决,在文章开篇也提到了,这里会导致白屏,用户体感不佳,如果能解决这个,那整体表现会再上一个台阶。
最后针对第4点、第5点,我们发现问题其实是由低代码平台引出的,低代码平台在出码后,直接将内容存到后端,前端需要根据对应页面ID去即时获取页面内容以及业务数据。这样会直接导致首页的白屏,因为即使是在静态资源完全加载完毕后,仍然需要调用接口获取页面数据,而后才能开始渲染页面。
分析下来,其实这里如果能做到页面数据的静态化而非即时拉取,就可以完全解决问题。
SSR
最先想到的可能是将页面由CSR重构为SSR,可以完全解决掉静态资源以及XHR请求问题,但是因为当前页面依托于低代码平台,且已经迭代了多次。重构会涉及到整个B端,低代码平台逻辑复杂,评估下来的开发成本很高。同时大流量的投放页面,采用SSR对服务器要求很高,可能需要大量堆叠机器才能保证性能,这块儿又会增加支出。
数据静态化
退一步想,结合SSR的优点,如果能将XHR请求相关的页面数据预先放在HTML里面,通过HTML直出,就能以最快速度拿到页面数据及结构了,相比SSR来说,就只多了静态资源的加载,而在静态资源加载之前,我们同样也能干很多事情。
页面如何静态化?
1、静态化改造
一个合格的低代码平台完全可以做到这一点,首先看一下没有静态化的页面生成流程:
从这张图可以就看出问题了,C端在通过XHR请求拿到JSON Schema的那一刻才能开始渲染动作,这里需要做的是让JSON Schema数据以静态形式存储在HTML中。在Nginx以及服务端做一些动作可以很好的去实现这个想法:
对比起来发现流程复杂了不少:
每次B端发布一个页面,服务端会从轻舟容器中取出原始HTML,并且将JSON Schema通过script标签嵌入到HTML中,随后标记上对应的projectId存储到OSS中。
C端HTML请求会被Nginx转发到后端服务,后端取出对应的拼接好的HTML返回给前端。
到了这一步,我们已经做到了数据静态化,从而节省掉了静态资源加载、XHR请求获取JSON Schema这两个非常耗时的步骤,提前拿到了页面数据,画一个简易的Waterfall来看看区别:
静态化之后,First Contentful Paint在HTML加载完毕后就会发生,而且是一次有效的内容绘制,因为我们已经知晓页面内容了。
2、提前绘制
接上一节,在知晓页面结构和数据的情况下,如何才能更快更全的渲染出必要内容,用以留住用户,现阶段我们没有JS和CSS,所以只能在Body中写简易逻辑来处理JSON Schema,展示出关键内容:
通过上面的处理,借助页面数据中的base64格式头图,几乎立即就能展示出页面的头图、课程信息、详情图等关键内容,这时候用户已经可以进行浏览了,剩余的完整内容只需要等待静态文件的加载就行了。
处理完毕后的页面表现,清掉缓存,在正常的无线网络环境下,视觉感受几乎是秒开:
如果想在这个时机展示更多内容,我们也可以自由拓展,这里是非常灵活的。
到这里拆解分析中的第5点、第6点就被完全解决了,总结一下:
对于一个成熟的平台,页面的静态化处理肯定是必须的,可以是各种其他的处理形式,借鉴SSR、SSG、ISR等各种渲染方案的优势,来做到最大化贴合自己的业务。
这个思路其实同样也适用于一些小的站点,因为改造成本其实并不高:利用好OSS、改造一下后端存储逻辑、增加一个接口即可实现。
最后
落地页在做完这些优化后,线上中位数FCP从1500ms缩短到了500ms+,LCP从4000ms+缩短到2000ms。
优化完至今已经稳定运行了一段时间,而针对当前现状仍然还有一个可以优化的点:
当前针对落地页静态化后的HTML,由于考虑到页面版本控制、缓存问题、以及线上的业务对各业务线有不同的域名要求,是由Nginx转发到后端接口再去OSS获取的,后续针对一些特殊的对域名要求宽松、变动频率低的业务场景,可以考虑直接申请CDN域名,通过CDN域名投放。这样就省掉了Nginx、后端服务的两层处理带来的时间损耗。
整篇文章涉及到的大部分优化都是比较通用的手段,能带来实打实的提升,但在实际开发中经常被很多同学忽略,其实在项目立项之初,就应该提前考虑好大致的代码组织架构、可以实现的常规以及非常规优化方案,避免在后期再来分析优化从而带来更高额的成本。
作者:胡斌