性能优化体系
这个性能优化体系具体有哪些内容呢?它主要包括三部分:性能优化流程、性能指标采集及上报、性能监控预警平台
前端性能优化方法论
第一:性能优化流程
其中性能指标设定,说的是我们要选择什么样的指标。比如页面打开慢,我们想要优化它,该从哪些地方入手,优化完后怎么知道问题解决了?这些就需要明确的指标来衡量。设定指标之后,接下就是确定性能标准,也就是我们性能优化目标是怎样的,优化到什么程度合适。例如,我们要优化 App 里面的 H5 页面打开速度,确定的指标是秒开率,那一秒内可以打开的请求比例就是它的性能标准。如果仅仅判断性能指标是否优化到位还好,但很多时候,为了让产品同学感觉我们是为产品服务,而不是又在造轮子,我们还需要关联产品目标进行比如,列表页到详情页的转化率能不能提升?用户跳出率可不可降低?接下来,我们就可以把业务代码接入性能监控预警平台,根据性能标准给出诊断清单。假如诊断出性能问题,我们就可以结合性能标准和诊断清单,确定相应的优化手段。即经过优化之后发起项目上线,并跟踪进行效果评估,结合场景把这些项目成果以文档或代码的形式沉淀下来,给后来者使用参考。比如之前有个同事通过懒加载解决了滚动列表下拉慢的问题,后来的新同事再遇到同样问题,就可以通过查看这个文档快速解决。
第二:性能指标采集与上报
它的主要内容是把前面提到的性能指标以代码的形式分解落地,确保可以采集,然后在 SDK 封装后集合统计埋点,最后根据实际情况,制定上报策略。在上报之前,我们还需要注意将一些"脏数据"(也就是明显异常的数据)丢弃掉,避免占用用户网络带宽。
第三:性能监控预警平台
主要是通过分析上一步采集到的性能数据,再对比性能标准进行监控。当指标超过某一监控阈值时,性能监控预警平台会通过邮件或者短信,给我们发送预警信息。在构造上,性能监控预警平台包括:能数据处理后台和性能可视化展现前台两部分。其中,性能数据处理后台,主要是在性能采集数据上报到性能平台后,对数据进行预处理、数据清洗和数据计算,然后生成前台可视化所需数据。性能可视化展现前台包括性能展示、性能监控预警,主要是对核心数据指标进行可视化展现,对性能数据波动进行监控,对超出阈值的数据给出短信或邮件报警。
第四:指标制定
你觉得整个性能优化体系当中,最开始要做的是哪个?比如说,现在出现了一个 6.18 活动页加载数据卡顿的性能问题,我们想要优化它,那么该怎么做?要先确定它的性能指标及其标准是什么。因为只有设定好了性能指标,知道了它的标准,接下来我们才知道该围绕着什么来开展性能优化。但实际当中指标有那么多,比如 FPS、白屏、首屏、可操作等,最关键的是哪个?这里,我也分两部分来介绍,一个是关注什么样的指标,一个是关键指标的设定及标准。根据经验和业界情况,要确定关键的性能指标,必须满足两点:(1)可衡量,就是可以通过代码来度量;(2)关注以用户为中心的关键结果和真实体验。
第一点好理解,无法衡量就无法优化,而第二点说的"关键结果和真实体验"是什么意思呢?所谓关键结果,就是用户真正关心什么。举例来说,当用户进入商品详情页面,他关心的是这个商品怎么样,什么价格,具体到页面上就是商品描述、商品头图、商品价格和购买按钮这些关键信息。我们要保证无论什么情况下都能让用户看到这些信息。而真实体验,就是用户使用产品的感受。比如当用户进入列表页,在滑动过程中,页面加载突然跳出一个弹窗,他会不会觉得烦?这就是一种真实体验。所以,基于这两点,在性能指标方面,我选定加载、交互性和视觉稳定性这三个方向,来带你一起了解性能指标及其标准设定。
所谓加载,就是进入页面时,页面内容的载入过程。比如,当你打开一些网站时,你会发现,有的网站首页上的文字、图片出现很缓慢,而有的则很快,这个内容出现的过程就是加载。加载缓慢严重消耗用户的耐心,会让用户离开页面。所谓交互,就是用户点击网站或 App 的某个功能,页面给出的回应。比如我们点击了一个"点赞"按钮,立刻给出了点赞数加一的展示,这就是交互体验好,反之如果很长时间都没回应,这就是交互体验不好。视觉稳定性指标,我们叫它 CLS(Cumulative Layout Shift),也就是布局偏移量,它是指页面从一帧切换到另外一帧时,视线中不稳定元素的偏移情况。比如,你想要购买的商品正在参加抢购活动,而且时间快要到了。在你正要点击页面链接购买的时候,原来的位置插入了一条 9.9 元包邮的商品广告。结果会怎样?你点成了那个广告商品。如果等你再返回购买的时候,你心仪商品的抢购活动结束了,你是不是很气?所以,CLS也非常重要。
在性能优化关键指标方面,目前业界主要集中在加载方面,特别是白屏时间和首屏时间。它们直接和用户体验相关,且相关的衡量标准已经达成共识。在采集方式上,除了手动采集之外,还可以自动化采集。而交互性和视觉稳定性关键指标,业界还在探索,没有统一的衡量标准,且必须手动采集。比如交互方面,有的公司用 FID 指标 (First Input Delay,首次输入延迟), 指标必须尽量小于 100ms,如果过长会给人页面卡顿的感觉。还有的公司使用 PSI(Perceptual Speed Index,视觉变化率),衡量标准是小于20%。而视觉稳定性指标CLS 比较前沿,2020 年 5 月 Google 公司才发布了一篇文章关于 CLS 指标定义及相关介绍的文章。它的采集方法,除了依赖 Google 的 Lighthouse 做本地采集,目前还没有好的方案。在应用上,其他公司或者沿用 Google 的或者很少使用。接下来我就重点介绍这目前已经确定的加载关键指标,具体就是白屏时间和首屏时间的设定及其标准。
什么叫白屏时间呢?它指的是从输入内容回车(包括刷新、跳转等方式)后,到页面开始出现第一个字符的时间。这个过程包括 DNS 查询,建立 TCP 连接,发送首个HTTP请求(如果使用HTTPS还要介入 TLS 的验证时间),返回HTML文档,HTML文档 Head 解析完毕。它的标准时间是 300ms。如果白屏时间过长,用户会认为我们的页面不可用,或者可用性差。如果超过一定时间(如 1s),用户注意力就会转移到其他页面。哪些因素会导致白屏时间过长?原因有很多,有可能是 DNS 查询时间长,建立 TCP 请求链接太慢,或者是服务器处理请求速度太慢,客户端下载、解析、渲染时长过长,没有做 Gzip 压缩,缺乏本地离线化处理,等等。接下来我们看首屏时间,它是怎么计算的?首屏时间=白屏时间+渲染时间。它是指从浏览器输入地址并回车后,到首屏内容渲染完毕的时间。这期间不需要滚动鼠标或者下拉页面,否则无效。
怎么理解呢?我们来看,下面是网站速度和性能优化 GTmetrix 官网的一个首屏时间示意图。从开始加载到第二帧时,这段时间是白屏时间,到第三帧时,首屏才开始加载,到第四帧结束时,这段时间是首屏时间。在加载性能指标方面,相比于白屏时间,首屏时间更重要。为什么?从重要性角度看,打开页面后,第一眼看到的内容一般都非常关键,比如电商的头图、商品价格、购买按钮等。这些内容即便在最恶劣的网络环境下,我们也要确保用户能看得到。从体验完整性角度看,进入页面后先是白屏,随着第一个字符加载,到首屏内容显示结束,我们才会认为加载完毕,用户可以使用了。白屏加载完成后,仅仅意味着页面内容开始加载,但我们还是没法完成诸如下单购买等实际操作,首屏时间结束后则可以。首屏时间的标准,最初只是根据这个页面对时间是否敏感来判定,主要以用户平均首屏加载时间来计算,并没有详细区分 2G/3G/4G/WiFi 这些网络环境。比如,下图是百度文库做性能优化项目时,队定的性能标准。
如果一个站点对时间敏感,首屏时间在 1s 内,用户感觉会很快;如果首屏时间超过 2.5s,用户就会感觉很慢。但是在 1s 内打开页面,人们对这么短的时间并不敏感,体验不出 10ms 和 50ms 有什么差别。但当到了 2G/3G 弱网环境,或者网络不稳定的环境(如坐火车或乘飞机时),用户联网加载的时间会特别长,严重影响整体指标。就好像 100 个穷人和马云一起,看平均资产差不多每人 5 个亿,但实际上多数人并没有那么多钱。性能也如此,前端工程师在使用过程中,越来越觉得用平均值来表示加载时间并不准确可靠。于是,人们又开始采用中位数,做正态分布,看分位值统计。在对首屏时间进行数据分析和可视化展现时,经常用到的是 P50(50分位值)、P90(90分位值)、P99(99分位值)。它们是怎么得出的呢?以 P99 为例,我们是把所有首屏时间排序,得出排在第 99 位的首屏时间就是 P99。不过这样处理起来还是比较麻烦,后来为了计算简单,也便于理解,我们引入了秒开率的指标,即 1s 内打开用户的占比。这个概念最早来自阿里巴巴,后来被业界普遍采用。按照秒开率建立的首屏时间标准。
但还有一个问题,首屏时间毕竟粒度太粗了,如果首屏时间长,白屏时间短,到底是哪里的问题?是数据接口的问题,还是页面渲染问题?所以我们还必须把这个指标进一步拆解细化。首屏时间可以拆分为白屏时间、数据接口响应时间、图片加载资源等。白屏时间前面已经提到了,数据接口响应时间可以直接从后端服务中获取,不需要前端再重复计算,我们只需取完放在性能平台即可。最后的图片资源需要我们单独采集。以上就是前端性能体系中的关键指标,还有一些不太重要的指标,如卡顿、完全加载时间、资源加载时间等,我在后期介绍采集时再聊。
第五:性能瓶颈
工作当中不知道你有没有遇到这样的情况,团队对首屏时间的要求是 1200ms,目前首屏时间长达 2s,离要求还有不小的差距。为此,你精简了首屏内容,合并了请求资源,对图片尺寸也进行了压缩优化,但最后的首屏时间还是没有降下来。为什么?实际上,想要对 Web 前端进行性能优化,除了了解性能体系、关键性能指标之外,还需要了解页面加载全过程。通过这个过程,我们可以找到其中影响性能的关键点、瓶颈点,接下来才好有的放矢。我们可以假设一个人正在上网,当他在浏览器输入 URL 并回车后,为了把 URL 解析为 IP 地址,浏览器会向 DNS 服务器发起 DNS 查询,获取 IP 地址。然后浏览器通过 IP 地址找到目标服务器,发起 TCP 三次握手和 TLS 协商,从而建立起 TCP 连接。在建立连接后,浏览器就可以发起 HTTP 请求,而服务端接收到后,对请求进行响应。浏览器从响应结果中拿到数据,并进行解析和渲染,最后在用户面前就出现了一个网页。以上的整个过程大致可以分为三个阶段:客户端发起请求阶段、服务端数据处理请求阶段、客户端页面渲染阶段。下面我就根据这三个阶段来和你介绍下 Web 前端都有哪些性能瓶颈点。
(1)客户端请求阶段的瓶颈点
客户端发起请求阶段,是指用户在浏览器输入 URL,经过本地缓存确认是否已经存在这个网站,如果没有,接着会由 DNS 查询从域名服务器获取这个 IP 地址,接下来就是客户端通过 TCP 的三次握手和TLS协商向服务器发起 HTTP 请求建立连接的过程。在这个过程中,本地缓存、DNS查询、HTTP 请求很容易成为影响前端性能的瓶颈点。
- 本地缓存
为什么说本地缓存会成为前端性能的瓶颈点之一呢?我们知道,本地缓存可以让静态资源加载更快,当客户端发起一个请求时,静态资源可以直接从客户端中获取,不需要再向服务器请求。而想要让本地缓存发挥这个作用,就需要先在服务器上进行配置。但在实际当中,许多前端同学往往会忽视这点。这会出现一个什么情况呢?以 58 同城的列表页项目为例,在客户端请求阶段,DNS 查询时间大概是 385 ms,TCP 三次握手及 TLS 协商时间 436 ms,数据返回 412 ms。一个请求下来大约是 1233 ms,这还是强网(WIFI/4G)情况下。如果是弱网(3G/2G)情况,一个请求连接都需要 2s 。但使用缓存的话,几乎可以说是几毫秒内完成请求,对比非常明显。怎么实现本地缓存呢?本地缓存一般包括强缓存和协商缓存两种形式。强缓存是指浏览器在加载资源时,根据请求头的 expires 和 cache-control 判断是否命中客户端缓存。如果命中,则直接从缓存读取资源,不会发请求到服务器,反之还需要走完整的资源请求流程。协商缓存是指,浏览器会先发送一个请求到服务器,通过 last-modified 和 etag 验证资源是否命中客户端缓存。如果命中,服务器会将这个请求返回,但不会返回这个资源的数据,依然是从缓存中读取资源。 如果没有命中,无论是资源过期或者没有相关资源,都需要向服务器发起请求,等待服务器返回这个资源。
- DNS查询
DNS 之所以会成为前端性能瓶颈点,是因为每进行一次 DNS 查询,都要经历从手机到移动信号塔,再到认证 DNS服务器的过程。这中间需要很长的时间。但用户是不想等待的。想要节省时间,一个办法就是让 DNS 查询走缓存。幸好浏览器提供了 DNS 预获取的接口,我们可以在打开浏览器或者 WebView 的同时就进行配置。这样真正请求时,DNS 域名解析可以检查一下浏览器缓存,一旦缓存命中,就不需要去 DNS 服务器查询了。
- HTTP请求
(1)在 HTTP 请求阶段,最大的瓶颈点来源于请求阻塞。所谓请求阻塞,就是浏览器为保证访问速度,会默认对同一域下的资源保持一定的连接数,请求过多就会进行阻塞。那么,浏览器同域名的连接数限制是多少呢?一般是 6 个。如果当前请求书多于 6 个,只能 6 个并发,其余的得等最先返回的请求后,才能做下一次请求。所以我们在项目之初,做一些域名规划很重要。我们可以先看看当前页面需要用到哪些域名,最关键的首屏中需要用到哪些域名,规划一下这些域名发送的顺序。除了域名规划,为了解决同域名下的阻塞问题,还可以做域名散列,通过不同的域名,增加请求并行连接数。常见做法是,将静态服务器地址 pic.google.com,做成支持 pic0-5 的 6 个域名,每次请求时随机选一个域名地址进行请求。因为有 6 个域名同时可用,最多可以并行 36 个连接。当然,这个域名个数不是越多越好,太分散的话,又会涉及多域名之间无法缓存的问题。
(2)服务端数据处理阶段的瓶颈点,是指 WebServer 接收到请求后,从数据存储层取到数据,再返回给前端的过程。具体来说,服务端程序接收到 HTTP 请求后,会做一些请求参数处理以及权限校验。校验完成后,它会将请求参数发送到数据存储服务。然后服务端程序会从数据存储中取到数据,进行数据加工聚合处理,最后再通过 jsonp 或者 ajax 接口返回给前端。这个过程中的瓶颈点,就在于是否做了数据缓存处理、是否做了 Gip 压缩,以及是否有重定向。其中,Gzip 压缩是一种压缩技术,服务器端通过使用 Gzip,传输到浏览器端的文本类资源(有别于图片等二进制等资源)的大小可以变为原来的 1/3 左右。因此通过 Gzip 压缩,资源的下载速度会快很多,能大大提升页面的展示速度。因为多数情况下,运维团队都会默认开启 Gzip 压缩,所以在这里我就不展开介绍了,接下来我会重点介绍一下数据缓存和重定向,这两个瓶颈点。
数据缓存分为两种:借助 Service Worker 的数据接口缓存、借助本地存储的接口缓存和CDN(Content Delivery Network,内容分发网络)其中 Service Worker 是浏览器的一个高级属性,本质上是一个请求代理层,它存在的目的就是拦截和处理网络数据请求。如果没有 Service Worker,请求每次直接落到 WebServer 上,需要走一次后端数据存取链路的流程,这会延长页面打开时间。借助本次存储的接口缓存是指,在一些对数据时效性要求不高的页面,第一次请求到数据后,程序将数据存储到本地存储(store 或者 localStorage、甚至客户端本身的存储),下一次请求的时候,先去缓存里面取将取数据,如果没有的话,再向服务器发起请求。谓 CDN,它的基本思路是,通过在网络各处放置节点服务器,构造一个智能虚拟网络,将用户的请求导向离用户最近的服务节点上。比如,一个深圳的用户想要访问京东电商首图的资源,如果没有使用 CDN ,图片服务器有可能在北京。但如果使用了CDN 的话,因为 CDN 的节点遍布全国,它就可以选择距离深圳最近的节点返回首图数据。为什么数据缓存会成为性能瓶颈点呢?这是因为每请求一次数据接口,需要从客户端到后端服务器,再到更后端的数据存储层,一层一层返回数据,最后再给到客户端,耗时很长,如果能够减少一次这个请求,为首屏时间争取了宝贵的时间。
前面我们介绍了数据缓存是如何影响性能的,接下来我们聊一下另外一个瓶颈点------页面重定向。所谓重定向,是指网站资源(如表单,整个站点等)迁移到其他位置后,用户访问站点时,程序自动将用户请求从一个页面转移到另外一个页面的过程。在服务端处理阶段,重定向分为三类:服务端发挥的302重定向,META 标签实现的重定向和前端 Javasript 通过window.location 实现的重定向。它们都会引发新的 DNS 查询,导致新的 TCP 三次握手和 TLS 协商,以及产生新的 HTTP 请求。而这些都会导致请求过程中更多的时间,进而影响前端性能。所以重定向也是一个需要注意的性能瓶颈点,在优化的时候需要注意。
(3)页面解析和渲染阶段的瓶颈点
在页面加载过程中,当前服务端对数据加工聚合处理后,客户端拿到数据,接下来就会进入解析和渲染阶段。所谓解析,就是 HTML 解析器把页面内容转换为 DOM 树和 CSSOM树的过程。什么叫 DOM 树呢?DOM 树全称为 Document Object Model 即文档对象模型,它描述了标签之间的层次和结构。HTML 解析器通过词法分析获得开始和结束标签,生成相应的节点和创建节点之间的父子关系结构,直到完成 DOM 树的创建。而 CSSOM 树,即 CSS 对象模型。主要描述样式集的层次和结构,HTML 解析器遇到内联的 style 标签时,会触发 CSS 解析器对样式内容进行解析,与上面解析器解析 HTML 过程类似,CSS 解析器遍历其中每个规则,将 CSS 规则解析浏览器可解析和处理的样式集合,最终结合浏览器里面的默认样式,汇总形成具有父子关系的 CSSOM 树。
解析完后就是渲染。主线程会计算 DOM 节点的最终样式,生成布局树。布局树会记录参与页面布局的节点和样式。完成布局后,紧跟着就是绘制。绘制就是把各个节点绘制到屏幕上的过程,绘制结果以层的方式保存。当文档中各个部分以不同的层绘制时,相互重叠时,就必须进行合成,以确保他们可以以正确的顺序绘制和展示。以上就是解析和渲染阶段,这个阶段流程环节多,逻辑复杂,瓶颈点也多,比如,DOM 树构建过程,CSSOM 树生成阶段,重排和重绘过程等。但因为篇幅所限,在这里我会重点介绍一下 DOM 树构建和布局两个环节的瓶颈点。
- 构建 DOM 树的瓶颈点
解析器构建 DOM 树的过程中,有三点会严重影响前端性能。
(1)当 HTML 标签不满足 Web 语义化时,浏览器就需要更多时间去解析 DOM 标签的含义。特别解析器是对标签的容错,比如将
标签少写了关闭符号,又或者表格嵌套不标准,标签层次结构复杂等。遇到这些情况时,浏览器会进行语法纠错。这就会导致页面总的解析和渲染阶段需要更长的时间,严重影响页面展示性能。
(2)DOM 节点的数量越多,构建 DOM 树的时间就会变长,进而延长解析时间,拖慢页面展示速度。
(3)最后一个是文档中包含
- 布局中的瓶颈点
在布局阶段,浏览器会根据样式解析器给出的样式规则,来计算某个元素需要占据的空间大小和屏幕中的位置(比如电商详情页一张 banner图片的高度、宽度和位置),借助结算结果,来进行布局。而主线程布局时,使用的是流模型的布局方式。所谓流模型,就是像水流一样,需要从左到右,从上到下遍历一遍所有元素。假设我们在页面渲染过程运行时修改了一个元素的属性,比如在电商的商品详情页加入一条广告数据。这时布局阶段受到了影响。浏览器必须检查所有其他区域的元素,然后自动重排页面,受到影响的元素需要重新绘制,最后还得合成,相当于整个渲染流程再来了一遍。除此之外,因为浏览器每次布局计算都要作用于整个 DOM,如果元素量大,计算出所有元素的位置和尺寸会花很长的时间。所以布局阶段很容易成为性能瓶颈点。说一个我之前实际工作中遇到的布局问题。在做 列表页性能优化项目时,我们一开始布局的时候,并没有确定列表页图片的初始大小,只给定了一个基础的占位尺寸。这就导致了,当图片加载完毕后,主线程才知道了图片的大小,不得不重新进行布局计算,然后再次进行页面渲染,也就是重排或重绘,无疑这大大延长了页面展示时间。
以上我们以页面加载的三个阶段简单介绍了前端性能展示会遇到哪些瓶颈点。其实,页面加载全过程很复杂,内容也很多,在这里,我主要介绍了前端领域我们能改变的瓶颈点,还有其他方面,我没有提到。比如,偏硬件领域,像 GPU 绘图、操作系统 GUI 和 LCD 显示等瓶颈点;网络层和服务层,如拥塞预防、负载均衡和慢启动;还有一些页面解析和渲染的算法,如解析算法、标记化算法和树构建算法,等等。现在很多业务在解决性能问题的时候都会采用节流和防抖的方案,那么它们到底是解决了页面加载哪个阶段的问题?
个人理解,防抖本质上是不允许某一行为的发生,节流的本质是允许某一行为的发生,但是发生的频率不能那么高。如果行为涉及到UI变更,那么他们两个都可以减少回流和重绘,优化的也就是在页面的渲染阶段;如果行为还涉及到网络请求的处理的话,那就减少了http请求数,也优化了页面请求的阶段。
六:首屏时间采集具体方法
在实际当中,首屏指标采集有手动采集和自动化采集两种,接下来我就来为你分别介绍下。
- 手动采集办法及优缺点
所谓手动采集,一般是通过埋点的方式进行, 比如在页面开始位置打上 FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。以电商平台为例,如果是电商类商品详情页,首屏包括头图、购买、商品信息、下单按钮等,就在这些内容加载完毕的位置打上首屏结束的点。如果是电商列表页,瀑布流型的页面,需要根据各个机型下的首屏位置,估算一个平均的首屏位置,然后打上点。如果是直播型的页面,页面核心是一个直播框,就需要在直播框的结束位置,打上点。手动采集都有哪些优点和缺点呢?首先是它兼容性强,业务同学知道在这个业务场景下首屏结束点在哪里,可以随情况变动。其次是去中心化,各个业务负责自己的打点代码,有问题时业务同学去排查即可,假如一条业务出现问题,并不会影响其他业务。缺点方面,手动采集会和业务代码严重耦合,如果首屏采集逻辑调整,业务代码也需要修改;还有,它的覆盖率不足,因为要手动采集,业务一旦忙起来,性能优化方案就会延迟排后。最后,手动采集的统计结果并不精确,因为依赖于人,每个人对首屏的理解有偏差,经常打错或者忘记打点。
- 自动化采集优势及办法
自动化采集的好处是独立性更强,接入过程更自动化。具体的自动化采集代码,可以由一个公共团队来开发,试点后,推广到各个业务团队。而且统计结果更标准化,同一段统计代码,标准更统一,业务侧同学也更认可这个统计结果。当然,它也有缺点,最明显的是,有些个性化需求无法满足,毕竟在工作中,总会有一些特殊业务场景。所以,采用自动化采集方案必须做一些取舍。既然是自动化采集,具体怎么采集呢?都有哪些办法?首屏指标自动化采集,需要考虑是服务端模板业务,还是单页面(SPA)应用开发业务,业务场景不同,对应的采集方法也不同。下面我来分别介绍下。
(1)服务端模板业务下的采集办法
提到服务端模板业务,很多人可能会问,现在不都是 Vue 和 React 这些单页面应用的天下了吗?其实在一些 B 端业务的公司用的还是服务端模板,如 Velocity、Smarty 等。另外大名鼎鼎的 SSR 用的也是服务端模板。这些业务后端比较重,前端偏配合,出于效率方面的考虑,前后端并没有解耦。因此,公司内部研发同学既做前端又做后端,这时候如果使用现在流行的 Vue/React,无疑会增加学习成本。那服务端模板项目的加载流程是怎样的呢?大致流程是这样的:HTTP 请求 → HTML 文档加载解析完成 → 加载样式和脚本文件 → 完成页面渲染。其中,HTML 文档加载解析完成的时间点,就是首屏时间点,而要采集这个首屏时间,可以用浏览器提供的 DOMContentLoaded 接口来实现。
我们来直观看一下什么是 DOMContentLoaded。打开 Chrome 浏览器调试工具,进入 Network 选项,重新加载网页,我们就会得到这么一张图。
右侧中间竖向的一条蓝线,代表了 DOMContentLoaded 这个事件触发的时间,而下面的蓝色文字(DOMContentLoaded 1.02s),代表 HTML 元素加载解析完成用了 1.02 秒。根据服务端模板项目加载流程,我们就知道这个时间就是首屏时间。那么,DOMContentLoaded 时间具体的采集思路是怎样的呢?当页面中的 HTML 元素被加载和解析完成(不需要等待样式表、图片和一些脚本的加载过程),DOMContentLoaded 事件触发。此时我们记录下当前时间 domContentLoadedEventEnd,再减去页面初始进入的时间 fetchStart,就是 DOMContentLoaded 的时间,也就是我们要采集的首屏时间。即首屏时间=DOMContentLoaded 时间=domContentLoadedEventEnd-fetchStart。那么,这种采集方法可以照搬到单页面应用下吗?答案是不可以。
(2)单页面(SPA)应用业务下的采集办法
SPA 页面首屏时间采集会有什么不同?如果也使用 Performance API 会有什么问题?我举个例子,在 2018 年 6 月的 GMTC 大会上,阿里云曾分享了他们的一个首屏指标采集结果:使用 Performance API 接口采集的首屏时间是 1106ms,实际的首屏时间是 1976ms。为什么偏差如此大呢?原来在 Vue 页面中,整体加载流程是这样的。用户请求一个页面时,页面会先加载 index.html,加载完成后,就会触发 DOMContentLoaded 和 load。而这个时候,页面展示的只是个空白页。此时根本不算真正意义的首屏。接下来,页面会加载相关脚本资源并通过 axios 异步请求数据,使用数据渲染页面主题部分,这个时候首屏才渲染完成。正是这个原因造成了用 Performance 接口取得的时间是 1106ms,实际时间则是 1976ms,差距如此之大。可以说,SPA 的流行让 Performance 接口失去了原来的意义。那么,这种情况下怎么采集首屏指标呢?可以使用 MutationObserver 采集首屏时间。SPA 页面因为无法基于 DOMContentLoaded 做首屏指标采集,最初我们想过使用技术栈的生命周期来解决这个问题。比如,我们以 Vue 为例,记录首屏各个组件 mounted 的时间,最终在 onload 时,统计出最后一个组件 mounted 的时间,做为首屏时间。但很快,我就发现这个方案存在以下问题。如果一个首屏页面的内容没有被组件化,那么首屏时间无法被统计到,除非各个业务都定一套组件标准,首屏内容必须封装成组件。前面也提过 onload 的时间并非最终时间,可能 onload 时,首屏还没加载完。没有考虑首屏是张图片的情况,在这种情况,首屏虽然加载完成了,可是图片是异步的,图片并没有加载,试想你会在看不到商品图片的情况下,直接下单吗?当时我们就想,如果能在首屏渲染过程中,把各个资源的加载时间记录到日志中,后续再通过分析,确定某一个资源加载完的时间,就是首屏时间。而 MutationObserver 恰恰可以做到这些。简单来说, 使用 MutationObserver 能监控页面信息的变化,当页面 body 变化最剧烈的时候,我们拿到的时间数据,就是首屏时间。首先,在用户进入页面时,我们可以使用 MutationObserver 监控 DOM 元素 (Document Object Model,文档对象模型)。当 DOM 元素发生变化时,程序会标记变化的元素,记录时间点和分数,存储到数组中。数据的格式类似于 [200ms,18.5]。为了提升计算的效率,我们认为首屏指标采集到某些条件时,首屏渲染已经结束,我们需要考虑首屏采集终止的条件,即计算时间超过 30 秒还没有结束;计算了 4 轮且 1 秒内分数不再变化;计算了 9 次且分数不再变化。接下来,设定元素权重计算分数。递归遍历 DOM 元素及其子元素,根据子元素所在层数设定元素权重,比如第一层元素权重是 1,当它被渲染时得 1 分,每增加一层权重增加 0.5,比如第五层元素权重是 3.5,渲染时给出对应分数。为什么需要权重呢?因为页面中每个 DOM 元素对于首屏的意义是不同的,越往内层越接近真实的首屏内容,如图片和文字,越往外层越接近 body 等框架层。最后,根据前面的得分,计算元素的分数变化率,获取变化率最大点对应的分数。然后找到该分数对应的时间,即为首屏时间。分数部分核心计算逻辑是递归遍历元素,将一些无用的标签排除,如果元素超过可视范围返回 0 分,每一层增加 0.5 的权重,具体请看下面代码示例。
首屏渲染时间计算过程:
. 利用 MutationObserver 监听 document 对象,每当 DOM 元素属性发生变更时,触发事件。 . 判断该 DOM 元素是否在首屏内,如果在,则在 requestAnimationFrame(),performance.now() . 将最后一个 DOM 元素的绘制时间和首屏中所有加载的图片时间作对比,将最大值作为首屏渲染时间。
js
function CScor(el, tiers, parentScore){ let score = 0; const tagName = el.tagName; if ("SCRIPT" !== tagName && "STYLE" !== tagName &&"META"!== tagName &&"HEAD"!== tagName) { const childrenLen = el.children ? el.children.length : 0; if (childrenLen > 0) for(let childs = el.children, len = childrenLen -1; len >= 0; len--) { score += calculateScore(childs[len], tiers + 1, score > 0); } if(score <0 &&!parentScore) { if(!(el.getBoundingClientRect &&el.getBoundingClientRect().top < WH)) return 0; } score += 1 + .5 * tiers; } return score; }
变化率部分核心计算逻辑是获取 DOM 变化最大时对应的时间,代码如下所示
```js
calFinallScore() { try { if (this.sendMark) return; const time = Date.now() - performance.timing.fetchStart; var isCheckFmp = time > 30000 || SCORE_ITEMS && SCORE_ITEMS.length > 4 && time - (SCORE_ITEMS && SCORE_ITEMS.length && SCORE_ITEMS[SCORE_ITEMS.length - 1].t || 0) > 2 * CHECK_INTERVAL || (SCORE_ITEMS.length > 10 && window.performance.timing.loadEventEnd !== 0 && SCORE_ITEMS[SCORE_ITEMS.length - 1].score === SCORE_ITEMS[SCORE_ITEMS.length - 9].score); if (this.observer && isCheckFmp) { this.observer.disconnect(); window.SCORE_ITEMS_CHART = JSON.parse(JSON.stringify(SCORE_ITEMS)); let fmps = getFmp(SCORE_ITEMS); let record = null for (let o = 1; o < fmps.length; o++) { if (fmps[o].t >= fmps[o - 1].t) { let l = fmps[o].score - fmps[o - 1].score; (!record || record.rate <= l) && (record = { t: fmps[o].t, rate: l }); } } // this.fmp = record && record.t || 30001; try { this.checkImgs(document.body) let max = Math.max(...this.imgs.map(element => { if(/^(\/\/)/.test(element)) element = 'https:' + element; try { return performance.getEntriesByName(element)[0].responseEnd || 0 } catch (error) { return 0 } })) record && record.t > 0 && record.t < 36e5 ? this.setPerformance({ fmpImg: parseInt(Math.max(record.t , max)) }) : this.setPerformance({}); } catch (error) { this.setPerformance({}); // console.error(error) } } else { setTimeout(() => { this.calFinallScore(); }, CHECK_INTERVAL); } } catch (error) {
依次遍历 fmps,如果 下一个得分score 与 前一个得分score 差值大于record.rate 则表示后面有新的 dom 元素渲染到页面中,则取下一个 time。
看完前面的流程,不知道你有没有这样的疑问:如果页面里包含图片,使用上面的首屏指标采集方案,结果准确吗?结论是:不准确。上述计算逻辑主要是针对 DOM 元素来做的,图片加载过程是异步,图片容器(图片的 DOM 元素)和内容的加载是分开的,当容器加载出来时,内容还没出来,一定要确保内容加载出来,才算首屏。这就需要增加一些策略了,以下是包含图片页面的首屏计算 demo。
js
<!doctype html><body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super"> <img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super"> <style type=text/css> background-image:url('https://www.baidu.com/img/dong_8f1d47bcb77d74a1e029d8cbb3b33854.gif); </style> </body> <html> <script type="text/javascript"> (() => { const imgs = [] const getImageDomSrc = { _getImgSrcFromBgImg: function (bgImg) { var imgSrc; var matches = bgImg.match(/url\(.*?\)/g); if (matches && matches.length) { var urlStr = matches[matches.length - 1]; var innerUrl = urlStr.replace(/^url\([\'\"]?/, '').replace(/[\'\"]?\)$/, ''); if (((/^http/.test(innerUrl) || /^\/\//.test(innerUrl)))) { imgSrc = innerUrl; } } return imgSrc; }, getImgSrcFromDom: function (dom, imgFilter) { if (!(dom.getBoundingClientRect && dom.getBoundingClientRect().top < window.innerHeight)) return false; imgFilter = [/(\.)(png|jpg|jpeg|gif|webp|ico|bmp|tiff|svg)/i] var src; if (dom.nodeName.toUpperCase() == 'IMG') { src = dom.getAttribute('src'); } else { var computedStyle = window.getComputedStyle(dom); var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background'); var tempSrc = this._getImgSrcFromBgImg(bgImg, imgFilter); if (tempSrc && this._isImg(tempSrc, imgFilter)) { src = tempSrc; } } return src; }, _isImg: function (src, imgFilter) { for (var i = 0, len = imgFilter.length; i < len; i++) { if (imgFilter[i].test(src)) { return true; } } return false; }, traverse(e) { var _this = this , tName = e.tagName; if ("SCRIPT" !== tName && "STYLE" !== tName && "META" !== tName && "HEAD" !== tName) { var el = this.getImgSrcFromDom(e) if (el && !imgs.includes(el)) imgs.push(el) var len = e.children ? e.children.length : 0; if (len > 0) for (var child = e.children, _len = len - 1; _len >= 0; _len--) _this.traverse(child[_len]); } } } getImageDomSrc.traverse(document.body); window.onload=function(){ var max = Math.max(...imgs.map(element => { if (/^(\/\/)/.test(element)) element = 'https:' + element; try { return performance.getEntriesByName(element)[0].responseEnd || 0 } catch (error) { return 0 } } )) console.log(max); } } )() </script>
它的计算逻辑是这样的。首先,获取页面所有的图片路径。在这里,图片类型分两种,一种是带 IMG 标签的,一种是带 DIV 标签的。前者可以直接通过 src 值得到图片路径,后者可以使用 window.getComputedStyle(dom) 方式获取它的样式集合。接下来,通过正则获取图片的路径即可。然后通过 performance.getEntriesByName(element)[0].responseEnd 的方式获取到对应图片路径的下载时间,最后与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。目前来说,这是市面中最好的首屏指标采集方案,它兼容了单页面应用和服务端模板的页面。
七、白屏、卡顿等指标采集方法
这里我们来聊聊前端其他的性能指标采集,比如白屏、卡顿和网络环境。浏览器的白屏和卡顿直接影响用户的体验,影响用户对平台的信任。而网络环境呢,它的影响更大,同时也是性能优化的盲区,我们就专门聊聊这三方面的指标采集。
(1)白屏指标采集
白屏时间是指从输入内容回车(包括刷新、跳转等方式)后,到页面开始出现第一个字符的时间。白屏时间的长短会影响用户对 App 或站点的第一印象。白屏指标怎么采集呢?我们先来回顾一下前面讲过的浏览器的页面加载过程,客户端发起请求>下载 HTML 及 JS/CSS 资源 >解析 JS 执行 >JS 请求数据>客户端解析 DOM 并渲染 >下载渲染图片>完成渲整体染。在这个过程中,客户端解析 DOM 并渲染之前的时间,都算白屏时间。所以,白屏时间的采集思路如下:白屏时间 = 页面开始展示时间点 - 开始请求时间点。如果你是借助浏览器的 Performance API 工具来采集,那么可以使用公式:白屏时间 FP = domLoading - navigationStart。
这是浏览器页面加载过程,如果放在 App场景下,就不太一样了,App下的页面加载过程:初始化 WebView>客户端发起请求 >下载 HTML 及 JS/CSS 资源 >解析 JS 执行 > JS 请求数据 >服务端处理并返回数据 > 客户端解析 DOM 并渲染 >下载渲染图片 >完成整体渲染。App下的白屏时间,多了启动浏览器内核,也就是 Webview 初始化的时间。这个时间必须通过手动采集的方式来获得,而且因为线上线下时间差别不大,线下采集即可。具体来说,在 App 测试版本中,程序在 App 创建 WebView 时打一个点,然后在开始建立网络连接打一个点,这两个点的时间差就是 Webview 初始化的时间。
(2)卡顿指标采集
所谓卡顿,简单来说就是页面出现卡住了的不流畅的情况。提到它的指标,你是不是会一下就想到 FPS(Frames Per Second,每秒显示帧数)?FPS 多少算卡顿?网上有很多资料,大多提到 FPS 在 60 以上,页面流畅,不卡顿。但事实上并非如此,比如我们看电影或者动画时,素虽然 FPS 是 30 (低于60),但我们觉得很流畅,并不卡顿。FPS 低于 60 并不意味着卡顿,那 FPS 高于 60 是否意味着一定不卡顿呢?比如前 60 帧渲染很快(10ms 渲染 1 帧),后面的 3 帧渲染很慢( 20ms 渲染 1 帧),这样平均起来 FPS 为95,高于 60 的标准。这种情况会不会卡顿呢?实际效果是卡顿的。因为卡顿与否的关键点在于单帧渲染耗时是否过长。但难点在于,在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。以 H5 为例,H5 场景下获取 FPS 方案如下:
js
var fps_compatibility= function () { return ( window.requestAnimationFrame || window.webkitRequestAnimationFrame || function (callback) { window.setTimeout(callback, 1000 / 60); } ); }(); var fps_config={ lastTime:performance.now(), lastFameTime : performance.now(), frame:0 } const fpsList = [] var fps_loop = function() { var _first = performance.now(),_diff = (_first - fps_config.lastFameTime); fps_config.lastFameTime = _first; fps_config.frame++; if (_first > 1000 + fps_config.lastTime) { var fps = Math.round( ( fps_config.frame * 1000 ) / ( _first - fps_config.lastTime ) ); console.log(`time: ${new Date()} fps is:`, fps); fpsList.push(fps) fps_config.frame = 0; fps_config.lastTime = _first ; }; fps_compatibility(fps_loop); } fps_loop(); function isBlocking(fpsList, below=20, last=3) { var count = 0 for(var i = 0; i < fpsList.length; i++) { if (fpsList[i] && fpsList[i] < below) { count++; } else { count = 0 } if (count >= last) { return true } } return false } isBlocking(fpsList)
配合 raf 会在每次重绘前执行,我们可以计算在 1 秒内,统计调用 raf 的次数 fps_config.frame。当时间间隔超过 1 秒后,就读取这个 fps_config.frame,然后重置 fps_config.frame 和fps_config.lastTime。利用 requestAnimationFrame 在一秒内执行 60 次(在不卡顿的情况下)这一点,假设页面加载用时 X ms,这期间requestAnimationFrame 执行了 N 次,则帧率为1000* N/X,也就是FPS。由于用户客户端差异很大,我们要考虑兼容性,在这里我们定义 fps_compatibility 表示兼容性方面的处理,在浏览器不支持 requestAnimationFrame 时,利用 setTimeout 来模拟实现,在 fps_loop 里面完成 FPS 的计算,最终通过遍历 fpsList 来判断是否连续三次 fps 小于20。如果连续判断 3次 FPS 都小于20,就认为是卡顿。
那么,在 App 侧,怎么采集卡顿指标呢?App 侧可以拿到单帧渲染时长,直接让 App 取到单帧渲染时长,如果在 Android 环境下,可以直接取到单帧渲染时长。代码如下:
js
private void calculateLag(long frameTimeNanos){ /*final long frameTimeNanos = mChoreographer.getFrameTimeNanos();*/ mLastFrameTimeNanos = System.nanoTime(); if (mLastFrameTimeNanos != 0) { long costTime= (frameTimeNanos - mLastFrameTimeNanos)/ 1000000.0F;//计算成毫秒 //严重卡顿,单帧超过250ms if (costTime>= bigJankTime) { bJank = true; } else if (costTime>= criticalBlockTime) {//超过50ms mCriticalBlockCount++; } else { if (bJank) { //严重卡顿上报逻辑 } else if (mCriticalBlockCount >= cStuckThreshold) { //卡顿上报逻辑,5次50ms } } } mLastFrameTimeNanos = frameTimeNanos; }
通过 mChoreographer.getFrameTimeNanos 和 System.nanoTime 计算出单帧渲染时长,如果单帧渲染时长超过 250ms,则严重卡断,反之连续 5 次超过 50ms,判定为卡顿。
如果是 iOS 场景,要复杂一些,需要借助 CFRunLoop 来取到单帧渲染时长(CFRunLoop,它负责监听输入源,并调度处理)。代码如下:
js
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { MyClass *object = (__bridge MyClass*)info; // 记录状态值 object->activity = activity; // 发送信号 dispatch_semaphore_t semaphore = moniotr->semaphore; dispatch_semaphore_signal(semaphore); } - (void)registerObserver { CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL}; CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,kCFRunLoopAllActivities,YES,0,&runLoopObserverCallBack,&context); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); // 创建信号 semaphore = dispatch_semaphore_create(0); // 在子线程监控时长 dispatch_async(dispatch_get_global_queue(0, 0), ^{ while (YES) { // 假定连续5次超时50ms认为卡顿(当然也包含了单次超时250ms) long st = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC)); if (st != 0) { if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting) { if (++timeoutCount < 5) continue; // 检测到卡顿,进行卡顿上报 } } timeoutCount = 0; } }); }
通过 CFRunLoopObserverContext 将休眠、唤醒的状态通知 Observer,然后通过 dispatch_async 在子线程时监控节点之间的时间,来计算主线程的时长。这里监控主线程是否卡顿这块儿,借鉴了导航 App 对交通堵塞问题的判断逻辑。导航 App 无法判断某个地点是否出了问题,如车坏在当路,正在施工,或者发生事故剐蹭了这些,但可以借助 GPS 和定位仪,拿到你两个节点之间的行驶速度,就可以推断出这个地点是否拥堵。这里的监控思路也正是如此,使用状态kCFRunLoopBeforeSources和kCFRunLoopAfterWaiting 两个节点之间的运行时间,和某个阈值(250ms)做比较,根据比较结果判定主线程是否出现卡顿。为什么会出现 App 白屏时间过长或卡顿问题呢?一般 WebView 初始化慢、DNS 解析慢、视图树过于复杂和主线程被阻塞等都会导致问题出现,但很多情况下白屏时间和卡顿都和网络环境有关。为了保证页面顺畅,我们需要做一些服务降级处理,比如对电商网站来说,高清图可以用文本代替,仅展示购买按钮和价格等核心内容。而要实现这个功能,就必须先做好网络环境采集。
(3)网络环境采集
为什么不能直接拿到网络环境数据呢?如果在 App 内, 我们可以通过 App 提供的接口获取到网络情况,但在端外(App 外部环境,比如微信里面的页面,或者PC站、手机浏览器下的页面)我们就没法直接拿到当前网络情况了。这时怎么办呢?一个做法是拿到两张不同尺寸图片的加载时间,通过计算结果来判定当前网络环境.具体来说,我们在每次页面加载时,通过客户端向服务端发送图片请求,比如,请求一张 11像素的图片和一张33像素的图片,然后在图片请求之初打一个时间点,在图片 onLoad 完成后打一个时间点,两个时间点之差,就是图片的加载时间。接着,我们用文件体积除以加载时间,就能得出两张图片的加载速度,然后把两张图片的加载速度求平均值,这个结果就可以当作网络速度了。因为每个单页面启动时,都会做一次网速采集,得到一个网络速度,我们可以把这些网络速度做概率分布,就能得出当前网络情况是 2G (750-1400ms)、3G (230-750ms)、4G或者WiFi(0-230ms)。根据这张图,你会发现自己的用户都停留在什么网段。比如,58 同城的用户测速分布,50% 的用户停留在 2G 水平。知道了这点,优化手段就会更多侧重 2G 下的网络优化方案了。
八、性能SDK及上报策略设计
SDK 是指开发者为特定的软件、平台、操作系统提供的开发工具集,比如,微信为小程序提供的微信 JS-SDK。我主要介绍性能 SDK,即为公司各个产品业务提供性能统计的 JS SDK。它包括 API 接口、工程引入、文档平台、开发调试工具,将性能采集代码和上报策略封装在一起,通过采集首屏、白屏等指标数据,然后上报到性能平台后端进行处理。由于性能 SDK 最终是给各个业务使用的,所以它的设计要满足在接入性能监控平台时,简单易用和运行平稳高效,这两个要求。那么,如何实现这两个目的呢?要保证 SDK 接入简单,容易使用,首先要把之前首屏、白屏和卡顿采集的脚本封装在一起,并让脚本自动初始化和运行。
具体来说,首屏采集的分数计算部分 API(calculateScore)、变化率计算的 API(calFinallScore)和首屏图片时间计算 API(fmpImg)可以一起封装成 FMP API。其中首屏图片计算 API 因为比较独立,可以专门抽离成一个 util,供其他地方调用。白屏和卡顿采集也类似,可以封装成 FP API 和 BLOCK API。还有一个 ExtensionAPI 接口,用来封装一些后续需要使用的数据,比如加载瀑布流相关的数据(将首屏时间细分为DNS、TCP连接等时间),这些数据可以通过浏览器提供的 performance 接口获得。为了进行首屏、白屏、卡顿的指标采集,我们可以封装 Perf API,调用 FMP、FP、BLOCK、ExtensionAPI 四个 API 来完成。因为是调用 window.performance 接口,所以先做环境兼容性的判断,即看看浏览器是否支持 window.performance。最终我们接入时只要安装一个 npm 包,然后初始化即可,具体代码如下:
上报策略设计
报策略是指在性能指标采集完成后,上报到性能平台所采用的具体策略。比如通过 SDK 上报到性能平台后端,是数据直接上传还是做一些过滤处理,是全量上传数据还是抽样,是选择 H5 接口上报还是 native 接口上报,等等,这些都需要我们确定一下。
(1)日志数据过滤
我的建议是,在采集性能指标之后,最好先对异常数据进行过滤。异常数据分一般有两类,第一类是计算错误导致的异常数据,比如负值或者非数值数据,第二类是合法异常值、极大值、极小值,属于网络断掉或者超时形成的数值,比如 15s 以上的首屏时间。值的性能指标数据影响很大,它会严重拖低首屏时间,也会把计算逻辑导致负值的问题给掩盖掉。还有首屏时间是非数值数据的时候也非常麻烦,比如首屏时间是 "200",我这里使用引号是因为它是字符串类型,在采集过程中计算时,遇到加法时,会出现 "200"+30=20030,而不是你预期的 230 的情况。后来遇到负值数据和非数值数据后,我都会用程序打印日志记录,并上报到错误异常平台。
(2)数据抽样策略
性能 SDK 上报数据是全量还是抽象,需要根据本身 App 或者网站的日活来确定,如果日活10万以下,那抽样就没必要了。如果是一款日活千万的 App,那就需要进行数据抽样了,因为如果上报全量日志的话,会耗费大量用户的流量和请求带宽。像 58 同城,做的就是 10% 的抽样率,这也有百万级的数据了。除了在 SDK 里面设置抽样策略,业界还有通过服务器端下发数据抽样率的方式,来动态控制客户端向服务器端上报性能数据的量。比如,双十二运营活动当天,日活跃用户激增,抽样率由10%降低到5%,可以大大降低运营活动时统计服务器的负载。
(3)上报机制选择
一般,为了节省流量,性能 SDK 也会根据网络能力,选择合适的上报机制。在强网环境(如 4G/WIFI),直接进行上报;在弱网(2G/3G)下,将日志存储到本地,延时到强网下再上报。除了网络能力,我们还可以让 SDK 根据 App 忙碌状态,选择合适的上报策略。如果 App 处于空闲状态,直接上报;如果处于忙碌状态,等到闲时(比如凌晨 2-3 点)再进行上报。除此之外,还有一些其他的策略,如批量数据上报,默认消息数量达到 30 条才上报,或者只在 App 启动时上报等策略,等等。你可以根据实际情况进行选择。在上报能力选择方面,由于使用 native 接口上报时,SDK 可以复用客户端的请求连接,采取延时上报或者批量上报等策略。所以虽然我们支持 H5 和 native 两种接口上报方式,但实际工作中建议优先使用 native 接口进行数据上报
九、前端性能平台
后台的性能数据处理和前台的可视化展示两部分。其中,数据处理后台主要是对 SDK 上报后的性能指标进行处理和运算,具体包括数据入库、数据清洗、数据计算,做完这些后,前台会对结果进行可视化展现,我们借助它就可以实时监督前端的性能情况。下图是性能平台大盘页的效果,主要对当前用户关注的性能模块进行展示,内容包括首屏时间、秒开率和采样PV。
那么,我们该如何搭建这样一个性能平台呢?这是具体的技术架构图,从底层到前台大致情况如下:
数据接入层,主要是接收 SDK上报的性能数据,做数据处理后入库,包含的技术有 Node.js、Node-sechdule、Node-mailer;数据计算层,会对性能数据做计算处理,需要的技术有 Kafka、Spark、Hive、HDFS;存储层,包括 MySQL + MongoDB ,性能平台需要的数据会来这里;平台层,也就是展示给用户的部分,需要的技术有 React、Ant design、Antv、Less。
(1)性能数据处理后台
想要搭建性能平台,我们先来看它的性能处理后台情况。一般性能 SDK 上报数据的处理过程是这样的:客户端借助 SDK 上报性能数据指标,数据接入层(图中绿色部分)接收相应数据,并做协议转换等简单处理后,作为生产者向 Kafka 写入数据;数据计算层(图中橙色部分)作为消费者,从 Kafka 读数据存入 Hive(Hadoop平台的存储表),Hadoop 平台借助 Spark 做数据分析计算;借助 Hive 提供的接口,数据计算层使用 SQL 语句从 Hive 拉取计算后的数据到数据库平台(MongoDB),平台层取出数据,准备数据可视化展现的数据。
上述数据流程,对应的性能数据后台的搭建过程如下:
第一步是入库,客户端借助 SDK 上报性能数据指标后,需要后端服务层的处理,这里我们选取的是 NodeJS 做后端,利用 Controller 层对数据做处理。为了避免数据库出现"脏数据"(如空数据、异常数据),影响后续数据处理,我们将 SDK 上报的数据通过 URL 解析成 key-value 格式的数据,对数据进行空数据删除,异常数据舍弃等操作。然后我们让数据写进消息队列 Kafka。为什么不是直接存入 Hive 呢?因为客户端上报的性能数据量和用户规模有关。如果直接入库到 Hive,遇到高并发的时候,会因为服务器扛不住而导致数据丢失。与此同时,因为数据下游(数据的使用方,如数据清洗计算平台,性能预警模块)会有多个数据接收端,直接入库的话也会造成数据重复。所以最好我们选择 Kafka,先让数据写进消息队列。Kafka 能通过缓存,慢慢接收这些数据,降低流量洪峰压力。同时,消息队列还有接收数据后将其删除的特点,可以避免数据重复的问题。
第二步,对 Kafka 中的数据,做数据清洗和数据计算。数据清洗,是指针对性能上报单条数据进行核对校验的过程。所清洗的数据包括:对重复数据的处理,即同一个用户网络出错时,多次重试导致上传了好几条首屏时间相关的数据;对缺失数据的处理,虽然上报了首屏时间,但白屏时间或者卡顿时间计算时没能给出;对错误数据的处理,即数据超出正常范围,出现负值或者超出极大值的情况。这几种类型数据问题如果不处理,最终会影响计算结果的准确性。那么该怎么处理呢?遇到重复数据,直接去重删除即可。遇到缺失数据,我们在 Spark 平台上,先根据上报的 Performance 数据进行计算补全,如果无法补全的,就直接舍弃掉,不然会出现后续无法入库的情况。遇到超出正常范围的数据,如负值或者超过 10 秒以上的数据,把它当作无效数据,直接舍弃掉。做完数据清洗之后,我们还需要使用 Spark 做数据计算,为可视化展现准备数据。具体需要做以下数据计算:首屏时间分布的计算,1s ~ 2s 占比多少,2s ~ 4s 占比多少;秒开率的计算,首屏时间小于等于 1 秒的数据占比;页面瀑布流时间的计算。其中,页面瀑布流时间是对首屏时间的细分,包括 DNS 查询、TCP链接、请求耗时、内容传输、资源解析、DOM 解析和资源加载的时间。这些细分时间点,是我们根据 SDK 上报的 Performance 接口数据指标计算出来的,前端工程师根据页面瀑布流时间,可以快速定位性能瓶颈点出现在哪个环节。
第三步,准备性能前台所需的可视化数据。为了完成前台展现,性能平台需要登录功能,还需要做一些用户关注的模块信息,比如前端开发者添加关注的业务模块。我们可以用关系数据库去存储这些数据,具体可以选择 MySQL完成账号权限系统和关注业务模块对应的数据表。而性能数据,因为都是单条性能信息,相互之间并没有什么关系,可以用 MongoDB 做存储。具体来说,我们可以用 NodeJS 提供的定时脚本(Node-sechdule)从 Spark 取到数据导入到 MongoDB 中。
(2)前端数据可视化展示前台
前端数据可视化展现前台,整体上只有两个页面,大盘页和详情页。大盘页包括一个个业务的性能简图。每一个性能简图包括首屏时间、秒开率、采样 PV 数据。点击性能简图上的"进入详情"链接,就可以进入详情页。初次进入大盘页的时候,需要你登录并关注相关的业务,然后就可以在大盘首页看到相关的性能情况。详情页的设计的初衷是为了对性能简图做进一步的补充,除了展示对应性能简图的秒开率、性能均值细节、白屏均值细节之外,还会展示终端信息,比如多少比例在IOS端,多少比例在Android端,以方便用户根据不同场景去做优化。同时,为了解秒开率不达标原因或者首屏时间变慢的细节在哪里,我们会给出页面加载瀑布流,前面数据处理阶段已经提到可以使用的数据(包括 DNS 查询、TCP链接、请求耗时、内容传输、资源解析、DOM 解析和资源加载的时间),套用 AntV (阿里巴巴集团的数据可视化方案)的瀑布流模板即可完成数据展现。
首先是前端展示技术栈的选择,对应技术架构图中的淡黄色部分,因为这两个页面都属于 PC 端后台页面,主要给公司前端开发者使用,功能上更多是数据可视化展示,非常适合用 React 技术栈做开发。为了更好实现首屏时间、秒开率和采用 PV 的功能效果,我们使用 AntdPro 的模板,相关的配套的数据可视化方案,我推荐 Antv,因为它能够满足我们在首屏时间、秒开率等性能指标的展示需求,用起来比较简单(开箱即用),功能灵活且扩展性强(比如秒开率部分,要自定义一些图形,能够较好满足)。大盘页和详情页的数据展示效果比较丰富多样,相应的 CSS 代码逻辑就比较复杂,为了让 CSS代码更容易维护和扩展,CSS 方面可以选用 Less 框架。
接下来是前后端交互方面,为了让前后台更独立,大盘页、详情页与后端的通信通过 HTTP 接口来实现,使用 nginx 作为 Web Server。为了让传输更高效,我们采用 compression 对 HTTP 传输内容进行 GZip 压缩处理。
(3)后台服务部分
为了让性能平台开发过程更简单,效率更高,同时平台本身的性能体验更流畅,后台服务方面可以选用 Egg.js(基于 NodeJS 的开发框架)做开发,进行数据处理和存储服务。为了解决监控预警的问题,我们借助 Node-schedule 做调度和定时任务的处理,通过 node-mailer 进行邮件报警。
十、监控预警
搭建性能平台的主要目的是监控预警和对性能问题快速诊断,监控预警部分,我们借助 Node-schedule 做调度和定时任务的处理,通过 node-mailer 进行邮件报警。具体来说我们通过以下几步来实现。
第一步,准备预警数据。
在做完数据清洗之后,一个分支使用 Spark 做计算,另外一个分支使用 Flink 实时数据计算。这两者的区别在于后者的数据是实时处理的,因为监控预警如果不实时的话,就没有意义了。有关数据的处理,我是这样做的:超过 2s 的数据,或者认定为卡顿的数据,直接标记为预警数据。实际当中你也可以根据情况去定义和处理。
第二步,我们借助 Node-schedule,用一批定时任务将预警数据通过 Node.js,拉取数据到 MongoDB 的预警表中。
第三步,预警的展示流程。根据预警方式不同,样式展示也不同。具体来说,预警的方式有三种:企业微信报警通知、邮件报警通知、短信报警通知。
以手机列表页为例,性能标准是首屏时间 1.5s,秒开率 90%,超过这个标准就会在性能平台预警模块展示,按照严重程度倒序排列展示。如果超出 10%,平台上会标红展示,并会发企业微信报警通知;如果超过 20%,会发借助 node-mailer 做邮件报警;如果超出 30%,会发短信报警通知。
十一、优化手段:首屏秒开的4重保障
在性能诊断和优化当中,首屏秒开是其中非常重要的一环,可以说前端性能优化中最重要的一个目标就是保证首屏秒开。那么,如何优化来保证这一点呢?介绍 4 个方法------懒加载,缓存,离线化,并行化。
(1)懒加载
懒加载是性能优化的前头兵。什么叫懒加载呢?懒加载是指在长页面加载过程时,先加载关键内容,延迟加载非关键内容。比如当我们打开一个页面,它的内容超过了浏览器的可视窗口大小,我们可以先加载前端的可视区域内容,剩下的内容等它进入可视区域后再按需加载。如果首屏只需要几条数据,后端接口一次可以吐出50 条数据,这会导致请求时间过长,首屏特别慢。这种情况就非常适合用懒加载方案去解决。具体怎么做呢?我们可以先根据手机的可视窗口,估算需要多少条数据,比如京东 App 列表页是 4 条数据,这时候,先从后端拉取 4 条数据进行展现,然后超出首屏的内容,可以在页面下拉或者滚动时再发起加载。那么如果首页当中图片比较多,比如搜索引擎产品的首页,如何保证首屏秒开呢?同样也可以采用懒加载。以百度图片列表页为例,可视区域范围内的图片先请求加载,一般会根据不同手机机型估算一个最大数据,比如 ihone12 Pro 屏幕比较大, 4 行 8 条数据,我们就先请求 8 条数据,用来在可视区域展示,其他位置采用占位符填充,在滑动到目标区域位置后,才使用真实的图片填充。这样,通过使用懒加载,可以最大限度降低了数据接口传输阶段的时间。
(2)缓存
如果说懒加载本质是提供首屏后请求非关键内容的能力,那么缓存则是赋予二次访问不需要重复请求的能力。在首屏优化方案中,接口缓存和静态资源缓存起到中流砥柱的作用。
- 接口缓存
接口缓存的实现,如果是端内的话,所有请求都走 Native 请求,以此来实现接口缓存。为什么要这么做呢?App 中的页面展现有两种形式,使用 Native 开发的页面展现和使用 H5 开发的页面展现。如果统一使用 Native 做请求的话,已经请求过的数据接口,就不用请求了。而如果使用 H5 请求数据,必须等 WebView 初始化之后才能请求(也就是串行请求),而 Native 请求时,可以在 WebView 初始化之前就开始请求数据(也就是并行请求),这样能有效节省时间。那么,如何通过 Native 进行接口缓存呢?那么,如何通过 Native 进行接口缓存呢?我们可以借助 SDK 封装来实现,即修改原来的数据接口请求方法,实现类似 Axios 的请求方法。具体来说就是,把包括 post、Get 和 Request 功能的接口,封装进 SDK 中。这样,客户端发起请求时,程序会调用 SDK.axios 方法,WebView 会拦截这个请求,去查看 App 本地是否有数据缓存,如果有的话,就走接口缓存,如果没有的话,先向服务端请求数据接口,获取接口数据后存放到 App 缓存中。
- 静态资源缓存
据接口的请求一般来说较少,只有几个,而静态资源(如 JS、CSS、图片和字体等)的请求就太多了。以京东首页为例,177 个请求中除了 1 个文档和 1 个数据接口外,其余都是静态资源请求。那么,如何做静态缓存方案呢?这里有两种情况,一种是静态资源长期不需要修改,还有一种是静态资源修改频繁的。资源长期不变的话,比如 1 年都不怎么变化,我们可以使用强缓存,如 Cache-Control 来实现。具体来说可以通过设置 Cache-Control:max-age=31536000,来让浏览器在一年内直接使用本地缓存文件,而不是向服务端发出请求。至于第二种,如果资源本身随时会发生改动的,可以通过设置 Etag 实现协商缓存。具体来说,在初次请求资源时,设置 Etag(比如使用资源的 md5 作为 Etag),并且返回 200 的状态码,之后请求时带上 If-none-match 字段,来询问服务器当前版本是否可用。如果服务端数据没有变化,会返回一个 304 的状态码给客户端,告诉客户端不需要请求数据,直接使用之前缓存的数据即可
- 离线化
离线化是指线上实时变动的资源数据静态化到本地,访问时走的是本地文件的方案。说到这里,你是不是想到了离线包?离线包是离线化的一种方案,是将静态资源存储到 App 本地的方案,不过,在这里的是离线化的另一个方案--把页面内容静态化到本地。
离线化一般适合首页或者列表页等不需要登录页面的场景,同时能够支持 SEO 功能。那么,如何实现离线化呢?通常情况下,Vue项目是单页项目,也就是渲染出来的项目,只有一个index.html。这样的缺点很明显:部署到Nginx,需要做try_files <math xmlns="http://www.w3.org/1998/Math/MathML"> u r i uri </math>uriuri/ /index.html。SEO不友好,搜索引擎收录效果不佳。而预渲染,就是把原来的单index.html,渲染成多个目录,每个目录又有一个index.html。这样就不需要内部重定向访问路由,也更利于搜索引擎收录。其实,打包构建时预渲染页面,前端请求落到 index.html 上时,已经是渲染过的内容。此时,可以通过 Webpack 的 prerender-spa-plugin 来实现预渲染,进而实现离线化。Webpack 实现预渲染的代码示例如下:
上述配置大意是到相应的目录,根据router的信息将有关的页面预先加载渲染成静态页面,并且将静态页面以独立文件夹的形式保存下来。
- 并行化
懒加载、缓存和离线化都是在请求本身上下功夫,想尽办法减少请求或者推迟请求,并行化则是在请求通道上功夫,解决请求阻塞问题,进而减少首屏时间。这就像解决交通阻塞一样,除了限号减少车辆,还可以增加车道数量,我们在处理请求阻塞时,也可以加大请求通道数量------借助于HTTP 2.0 的多路复用方案来解决。TTP 1.1 时代,有两个性能瓶颈点,串行的文件传输和同域名的连接数限制(6个),到了HTTP 2.0 时代,因为提供了多路复用的功能,传输数据不再使用文本传输(文本传输必须按顺序传输,否则接收端不知道字符的顺序),而是采用二进制数据帧和流的方式进行传输。其中,帧是数据接收的最小单位,流是连接中的一个虚拟通道,它可以承载双向信息。每个流都会有一个唯一的整数 ID 对数据顺序进行标识,这样接收端收到数据后,可以按照顺序对数据进行合并,不会出现顺序出错的情况。所以,在使用流的情况下,不论多少个资源请求,只要建立一个连接即可。文件传输环节问题解决后,同域名连接数限制问题怎么解决呢?以 Nginx 服务器为例,原先因为每个域名有 6 个连接数限制,最大并发就是 100 个请求,采用 HTTP 2.0 之后,现在则可以做到 600,提升了 6倍。你一定会问,这不是运维侧要做的事情吗,我们前端开发需要做什么?我们要改变静态文件合并(JS、CSS、图片文件)和静态资源服务器做域名散列这两种开发方式。具体来说,使用 HTTP 2.0 多路复用之后,单个文件可以单独上线,不需要再做 JS 文件合并了。因为原先遇到由 A 和 B 组成的 C 文件,其中 A 文件稍微有点修改,整个C 文件就需要重新加载的情况,如今由于没有同域名连接数限制了,也就不需要了。此外, 02 讲性能瓶颈点我提到过,为了解决静态域名阻塞,提升请求并行能力,需要将静态域名分为 pic0-pic5。虽然通过静态资源域名散列的办法解决了问题,但这样做的话,DNS 解析时间会变长很多,同时还需要额外的服务器来满足,如今,采用 HTTP 2.0 多路复用之后,也不需要这样做了。
十二、白屏300ms和界面流畅优化技巧
上面介绍了如何保障首屏秒开,除了它,在性能监控当中白屏时间和卡顿也是两个值得注意的优化指标。所谓白屏时间,一般是当用户打开一个页面,从开始等待到页面第一个字符出现的时间,白屏时间越短,给人感觉 App 速度快,体验好,能有效降低跳出率。而卡顿,想必你也不陌生,当用户浏览页面,下拉商品列表时,如果页面停止不动,无疑会对业务转化率有很大的影响。
- 白屏优化
现在我们假设一个场景,有一天你想要在某电商 App 上买个手机,于是你搜索后进入商品列表页,结果屏幕一片空白,过了好久还是没什么内容出现,这时候你是不是会退出来,换另外一个电商 App 呢?这就是白屏时间过长导致用户跳出的情形。作为前端开发者,我们遇到这种问题如何解决呢?首先去性能平台上查看白屏时间指标,确认是否是白屏问题。问题确认后,我们可以基于影响白屏时间长短的两个主要因素来解决--DNS 查询和首字符展示。
DNS 查询优化
DNS 查询是指浏览器发起请求时,需要将用户输入的域名地址转换为 IP 地址的过程,这个转换时间长短就会影响页面的白屏时间。那么如何对 DNS 查询进行优化呢?根据 DNS 查询过程,我们可以从前端和客户端这两部分采取措施。前端侧,可以通过在页面中加入dns-prefetch,在静态资源请求之前对域名进行解析,从而减少用户进入页面的等待时间。如下所示:
其中第一行中的 x-dns-prefetch-control 表示开启 DNS 预解析功能,第二行 dns-prefetch 表示强制对 s.google.com 的域名做预解析。这样在 s.google.com 的资源请求开始前,DNS 解析完成,后续请求就不需要重复做解析了。不要小看这个标签哦,它可以为你减少 150ms 左右的 DNS 解析时间。
客户端侧呢?可以在启动 App 时,同步创建一个肉眼不可见的 WebView(例如 11 像素的 webview),将常用的静态资源路径写入这个 WebView 中,然后对它做域名解析并放入缓存中。这样后面需要使用 WebView 打开真正所需的页面时,由于已经做过域名解析了,客户端直接从缓存中获取即可。当然如果是端外页面,因为没在 App 里面,就没法使用 11 WebView 的策略了,我们可以使用 iframe ,也能达到类似效果。
首字符展示优化
所谓首字符展示,通常我们会在页面加载过程中出现一个 loading 图,用来告诉用户页面内容需要加载,请耐心等待。但这样一个 loading 图既无法让用户感受到页面加载到什么程度,也无法给用户视觉上一个焦点,让人们的注意力集中在上面。如何解决这个问题呢?我们可以使用骨架屏。骨架屏(Skeleton Screen)是指在页面数据加载完成前,先给用户展示出页面的大致结构(灰色占位图),告诉用户页面正在渐进式地加载中,然后在渲染出实际页面后,把这个结构替换掉。骨架屏并没有真正减少白屏时间,但是给了用户一个心理预期,让他可以感受到页面上大致有什么内容。那么,如何构建骨架屏呢?因为考虑到每次视觉修改或者功能迭代,骨架屏都要配合修改,我建议采用自动化方案而不是手动骨架屏方案(也就是自己编写骨架屏代码)。骨架屏的实现方法有以下三个步骤。
步骤一,确定生成规则,遍历所有的 DOM 元素。针对特定区块(如视频、音频)生成相应的代码块,获取原始页面中 DOM 节点的宽度、高度和距离视窗的位置,计算出当前设备快高对应的大小,转换成相应的百分比,然后来适配不同的设备。
步骤二,基于上述规则结合 CLI 工具可以通过脚手架自动生成骨架屏。
步骤三,将骨架屏自动化注入页面,再利用 Puppeteer 把骨架屏代码注入页面中自动运行。
卡顿治理
卡顿现象,一般可以通过用户反馈或性能平台来发现。比如我们接到用户说某页面比较卡,然后在性能平台上查看卡顿指标后,发现页面出现连续 5 帧超过 50ms ,这就属于严重卡顿。如何处理呢?一般和以下两种情形有关:浏览器的主线程与合成线程调度不合理,以及计算耗时操作。浏览器的主线程与合成线程调度不合理。
比如,在某电商 App 页面点击抽奖活动时,遇到一个红包移动的效果,在红包位置变化时,页面展现时特别卡,这就是主线程和合成线程调度的问题。怎么解决呢?一般来说,主线程主要负责运行 JavaScript,计算 CSS 样式,元素布局,然后交给合成线程,合成线程主要负责绘制。当使用 height、width、margin、padding 等作为 transition 值时,会让主线程压力很大。此时我们可以使用 transform 来代替直接设置 margin 等操作。比如红包元素从 margin-left:-10px 渲染到 margin-left:0,主线程需要计算样式 margin-left:-9px,margin-left:-8px,一直到 margin-left:0,每一次主线程计算样式后,合成线程都需要绘制到 GPU 再渲染到屏幕上,来来回回需要进行 10 次主线程渲染,10 次合成线程渲染,这给浏览器造成很大压力,从而出现卡顿。
如何解决呢?我们可以利用 transform 来做,比如 tranform:translate(-10px,0) 到 transform:translate(0,0),主线程只需要进行一次tranform:translate(-10px,0) 到 transform:translate(0,0),然后合成线程去一次将 -10px 转换到 0px。这样的话,总计 11 次计算,可以减少 9 步操作,假设一次 10ms,将减少 90ms。
除了主线程和合成线程调度不合理导致的卡顿,还有因为计算耗时过大导致的卡顿。遇到这类问题,一般有两种解法:空间换时间和时间换空间。空间换时间方面,比如你需要频繁增加删除很多 DOM 元素,这时候一定会很卡,在对 DOM 元素增删的过程中最好先在 DocumentFragment (DOM文档碎片)上操作,而不是直接在 DOM上操作。只在最后一步操作完成后,将所有 DocumentFragment 的变动更新到 DOM上,从而解决频繁更新 DOM 带来的卡顿问题。至于时间换空间,一般是通过将一个复杂的操作细分成一个队列,然后通过多次操作解决复杂操作的问题。
十三、Hybird下的性能优化整体分析##
Hybrid 开发模式借助 WebView,把 Native 和 H5 的各自优势进行了结合。具体来说,它既具备 Native 体验好、操作硬件能力强、代码安全等优势,又具备了 H5 发版节奏快、Web 标准开发效率高等优势。通过它,前端工程师几分钟内就能完成需求上线,不用等 App一周的发版周期,不用等待审核。除此之外,使用 Hybrid 开发模式还可以将一个大的横向需求,切分到各个业务前端团队并行工作,大大提升了需求吞吐率和迭代速度。如今,许多 App,如美团、去哪儿、淘宝都在使用 Hybrid 开发模式。不过它的缺点也明显,比如加载性能问题、白屏问题、界面展示和操作的局限性,无法使用系统功能等等。所以,这一讲,我将结合 H5 的加载流程,介绍 Hybrid App下的性能优化整体分析,然后接下来几讲详细介绍与之相关的具体优化,如离线包、图片骨架屏、服务端渲染、接口预加载、客户端代理数据接口等。H5 是 Hybrid App当中的一个核心,它可以通过 SDK 访问 App 底层系统,让前端页面获取调用传感器、存储、日历/联系人等原生能力。一般 H5 加载大致流程如下:进入 App → 初始化 WebView → 客户端发起请求 → 下载HTML 及 JS/CSS 资源 → 解析JS执行 → JS 请求数据→服务端处理并返回数据 → 客户端解析 DOM 并渲染 → 下载渲染图片 → 完成整体渲染。从大的时间节点来看,初始化 WebView 之前都算是 App 启动阶段,从初始化 WebView 到客户端解析 Dom 并渲染,属于白屏时间,剩下的环节就是整体渲染后首屏结束,我们把这段称为页面首屏渲染阶段。所以,接下来我就以这三个阶段------App 启动、页面白屏、页面首屏渲染来介绍下相关的优化方案。
App 启动阶段的优化方案
App 启动,尤其是冷启动(首次启动 App)时,并不会直接初始化 WebView,而是在创建 WebView 实例时,才会创建它的基础框架。系统打开 WebView 时,也不是直接建立连接发起请求,而是又一个启动浏览器内核的过程。那么这中间大致需要多长时间呢?我们在一次二手业务列表页测试时发现,首次启动 WebView 平均需要400ms 左右,二次启动平均有 220ms。按照页面秒开的目标,WebView 启动就占了 40% 的时间,所以我们要想办法来优化它。对此,我建议使用WebView 全局的优化方式,即在 App 启动时启动一个 WebView 后让其全局化。或者更彻底一点,把 Webview 的实例保存在一个公共池中,当用户访问这个 WebView 时,直接从公共池取来加载网页,而不是重新初始化一个新的 WebView。通过这个办法,可以大大减少后续 WebView 在 App 中打开的时间。以我的一个优化实践经验为例,可以减少 200ms 左右的启动时间。
页面白屏阶段的优化方案
在页面白屏阶段,也就是 H5 页面加载的下载HTML及JS/CSS资源环节当中,会有哪些情况影响性能,以及会用到哪些优化方案呢?一般情况下,前端工程师将静态资源上线到 CDN 上后, WebView 会发起网络请求去获取。当用户在弱网或者网络比较差的环境下,页面白屏时间会特别长。此时,如果我们能将静态资源提前下载到本地,WebView 获取静态资源时就可以直接从本地获取,这样会大幅降低白屏时间,这就是离线包的作用。所谓离线包,就是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内,App 预先内置该压缩包到本地,然后当用户在客户端打开 H5 页面时,直接从本地加载就行了。这样无须再向服务器端请求,最大程度地摆脱网络环境对 H5 页面的影响。如果离线包是提前下载到本地,那么更新问题怎么解决呢?比如一个业务的静态资源更新了,我们该怎么保证用户请求到的离线包资源也是最新的呢?我们可以在生成离线包的同时,生成一个配置文件,让App 先根据这个静态文件判断是需要更新离线包,还是直接向业务服务器进行请求。但如果静态资源本地版本实在太老,此时客户端将直接向服务器端请求资源(如离线包无法命中的情况),这该怎么办呢?它的优化方案就是--骨架屏。虽然说骨架屏可以"加快"过程中的等待时间,但请注意,这个加快只是视觉和心理上的效果,实质上等待时间还是那么多。那有没有办法减少最后的渲染时间呢?我们可以使用 SSR 来优化。
SSR (Server Side Rendering,服务端渲染)是指客户端发起页面请求后,服务端直接将组件和页面内容渲染成 DOM 结构,返回给客户端。你可以通过 Chrome 的调试工具 DevTools 打开页面后,看到相关的源文件内容。提到 SSR ,就不得不提下 CSR(Client Side Rendering)。目前许多人在移动端上还在使用 CSR,但其实在 CSR 方案下,它的页面资源请求和数据渲染流程相当烦琐冗长。具体来说,在 CSR 方案中,HTML 文件仅作为入口,客户端在请求时,服务端不做任何处理,只返回一个 index.html 文件,然后客户端根据页面上的 JS 脚本去请求内容,生成 DOM 添加到 HTML 页面中,形成最终的内容。我以一个图为例,你可以看出同一个页面在不同技术方案下面白屏方面的差异。从图中可以看到,在 SSR 下第三帧就可以看到内容,而在 CSR 下需要第六帧才能看到。
首屏渲染阶段的优化方案
处理完静态资源后,马上进入服务端处理和返回数据请求过程,这就是首屏渲染阶段。这阶段要解决的主要问题是怎样减少数据接口加载的时间。有什么优化方案呢?一方面,我们可以通过服务端优化来降低后端接口的响应时间,比如从 200ms 降低到 100ms。但优化到 100ms 之后,其优化空间就非常有限了。另一方面,可以从接口和页面并行加载的角度去做优化加载时间,也就是接口预加载。它有以下几种形式。
第一种,通过客户端代理数据接口请求,在客户端初始化 WebView 的同时,直接由 Native 发起网络请求,H5 页面初始化完成后(对于 CSR 页面,也就是 index.html 加载完成后)直接通过 SDK 向 Native 获取数据。
第二种,根据业务场景选择预加载。举个例子,我们在滚动下拉列表的页面,根据用户滚动条的位置,提前加载一页的展示数据,这样用户在滚动下拉时,不会有停滞的感觉,非常流畅。
第三种,在一些旅行类 App上,用户在订酒店的时候,当他进行了目的地和日期选择操作后,将结果上传服务端后,服务端会根据用户的操作路径,判断打开搜索结果页的概率。如果概率超过某一个值,就会启动搜索结果页的数据获取,这样在进入搜索结果页后,已经有了接口数据,大大节省了时间。
十四、保证首次加载为秒开的离线包设计##
离线包的可以最大程度地摆脱网络环境对 H5 页面的影响。有关利用它来保证页面秒开。记得那是我们业务 App 2.0 升级的时侯,技术 VP 报了一个手机首页访问时间长的问题,我们详细定位性能平台后给出了一个回复是,弱网环境下导致白屏时间过长。因为之前常规的优化手段已经做了,但效果还是不明显,技术 VP 就问,如果是弱网的问题,为啥淘宝首页还可以打开?我们仔细验证了一下,发现原来淘宝使用了离线包。于是我们开始了离线包实现之路。我们先看一下离线包的整体实现方案。
具体来说,离线包的前端源码主要包括 HTML、SCSS、Img 等内容,FE 工程师在需求开发过程中,先从 GitLab 中下载前端工程到本地,通过 CI/CD 将离线包版本上线。接下来,为了便于管理这个离线包,我们需要开发一个它的管理后台,使用的技术包括 Egg 框架和 MongoDB。最后,我们将离线包存储在 CDN 上,当用户进入 App 向服务器发起静态资源请求时,客户端会拦截下来并根据内置离线配置,请求离线包管理后台。离线包管理后台返回结果,客户端决定是直接使用全量包,还是请求差分包。当这部分工作完成后,客户端将请求代理到它内置的离线资源中,返回给用户页面内容。整个过程包含离线包生成、离线包管理后台、离线包部署及优化三部分。
离线包生成
如何生成离线包呢?我们可以把需要离线的资源,如首页要用到的 JS、CSS 和图片,通过 webpack 插件ak-webpack-plugin打包生成压缩包。这个过程大致分为三步:
第一步,将前端项目从 Git 仓库中 clone 出来,然后打出一个 offline 分支。
第二步,拷贝离线包专用的 webpack 配置文件到项目中,对package.json做对应的修改。
第三步,通过 npm i 安装所需的包,并执行命令 npm run build 查看效果,然后同步修改config/offline.js 中的对象 URL 为页面真实 URL,修改导出静态资源的路径为真实的 CDN 资源路径。这一步决定了离线包的资源对应关系,如果出问题,线上会出现 404 ,所以千万要注意。offlinepath 对应的就是你需要拦截的静态资源路径,拦截这个路径后, 客户端从对应本地的离线包中加载资源第三步,通过 npm i 安装所需的包,并执行命令 npm run build 查看效果,然后同步修改config/offline.js 中的对象 URL 为页面真实 URL,修改导出静态资源的路径为真实的 CDN 资源路径。这一步决定了离线包的资源对应关系,如果出问题,线上会出现 404 ,所以千万要注意。offlinepath 对应的就是你需要拦截的静态资源路径,拦截这个路径后, 客户端从对应本地的离线包中加载资源
从中你可以看到,线上资源路径和离线包资源路径映射对应,我们设计时把 URL path 转换成了本地的文件目录,可以让客户端代理时规则解析更简单。在这过程中,我们不需要关注资源之间加载的依赖关系,比如首页资源依赖,也不需要关注具体的业务逻辑是列表页还是详情页,只需要关注 webpack 最终构建出的内容结构即可。如果一些资源不需要走离线包,比如非首屏的图片资源,可以选择 webpack 排除的方式设置,做完这些操作后,我们可以将离线包功能封装进脚手架里,这样在初始化一个项目时就可以直接接入离线包。前面提到过,离线包是下载本地之后生成的,那如何保证离线包的内容是最新的呢?这就要用到离线包管理后台了。
离线包管理后台主要是提供离线包监控及配置管理的平台,我们可以通过它查看某条业务的离线包使用情况,比如是否在使用,离线包版本是多少,启用时间多长等等。除此之外,还可以通过它开启和关闭离线包。在这个后台上,主要通过三个核心页面------全局页、离线包列表页、详情页来完成对离线包的管理功能。全局页,它提供离线包管理功能,可以开启和关闭离线包。由于这个开启和关闭是针对所有离线包的,所以它的权限设置要高一些,防止误操作带来的线上问题。其次是离线包列表页,主要是对所有的离线包资源进行展示和操作。这个列表页包括展示业务名称、版本号、包类型、发布时间、在线情况。此外这个列表页上还有可以点击在线和离线的按钮,方便离线包上下线操作,以及进入详情页的链接。离线包列表页里包类型是我们实现的一个大亮点,离线包的类型一般包括差分和全量包,我们可以通过它们大大减少用户的流量使用,降低下载离线包的时间。具体来说,在 App 里面,我们会先内置一份全量包作为基线版本。但如果我们判断当前版本不是最新的话,一种做法就是下载一个全新的版本,第二种做法是下载一个差分包的版本,第三种做法是直接绕过离线包,请求线上接口。显然第三种最不可取,它等于没有了离线包优化的效果,第二种呢,全量包体积都比较大,以我们公司业务为例,将近 600K 的大小,而如果用差分包,平均 200K 左右的大小,并不影响性能。那怎么实现差分包呢?这就需要用到 BSDP 了------一个基于二进制 diff 的 Node 工具包。它有两个核心模块:bsdiff 和 bspatch,其中 bsdiff 是个库函数,用于对源文件和目标文件求 diff,生成差分包。当我们要升级离线包版本(比如升级首页金刚位功能),就可以使用它来发布一个差分包,放在 CDN 上 ,同时生成配置文件,如 2021030701 放到管理配置后台上。这样客户端请求静态资源之前,先查看本地配置文件,发现配置文件是 2021030606 版本的,就直接去请求对应的差分包。而 bspatch 主要是用来根据差分包的内容,合并本地版本成一个新的全量包。最后是详情页因为随着业务规模的扩大,每条业务都想使用离线包来提升页面性能,离线包体积就越来越大,这时候必须得设置优先级,给流量大的业务优先使用离线包,其他业务暂停使用。我们现在仅针对金刚位( App 首页核心导航位)提供离线包功能。。详情页主要是方便我们查看下载离线包检查内容是否正确,以及设置业务优先级。为什么要设置业务优先级呢?
离线包部署及优化
前端工程师也就是图中的 FE,将前端工程打包,生成离线包的入口页面 index_sonic.html (支持离线包的index.html),然后通过前端的静态资源发布系统(我们公司使用的是 beetle,类似jerkens 上线,增加了 web 界面)上线到 CDN。接下来,FE 将静态资源(如 index.js、home.css、banner.jpg)打包成全量离线包到 CDN,然后同步增加离线管理后台的配置,离线管理后台会根据基础包生成差分包上传到 CDN。这就是整体的部署流程。如果离线包功能异常(如出现无法访问),该怎么快速解决?我建议一定要做好离线包的开关功能。在出现问题时,通过在离线包后台操作,及时关掉离线包功能,就可以及时确保用户功能恢复正常。
十五、瞒天过海的骨架屏及SSR优化手段##
- 使用骨架屏方案优化页面性能
为了让骨架屏内容和页面结构更类似,提升用户体验,我们一般采用的是:图片骨架屏。在 App 业务功能设计时,设计师会针对这个页面制作一张离线包的图片,在 WebView 启动时,客户端把这张图片覆盖在页面上,页面开始进入请求资源的流程。当页面 WebView 加载完成或者前端页面通知客户端加载完成时,客户端通过渐变动画隐藏这张图片,将准备好的页面展现给用户。
第一步:先让 UI 设计师设计一张当前页面对应离线包的图,作为骨架屏展示图片
第二步,在业务需求开发过程中,前端工程师拿到图后,把这张图片上传到 CDN 上面
第三步,在客户端代码,增加启动时读取图片骨架屏的配置文件
首先是传入设备分辨率,比如 400 * 500,然后设置状态码 code。code 是 0 代表成功,-1 代表关闭图片骨架屏功能。 data 对象是具体的数据,m.58.com/enjoy-given/eg/index.html 是对应的页面 URL,骨架屏那个图片的地址是m.58.com/pic.png?400*500。当用户打开 WebView 时,客户端对 URL 进行解析,取得该 URL 对应的 host 和pathname,然后将这两个数据分别与 data 和 routes 中的数据做比较。如果都匹配上,说明要展示骨架屏,此时可根据 routes下面的 id 和 imgName 获取到对应的图片文件。当三个字符串拼起来,即zzSkeleton + id +imgName,就可获得最终的图片名字。其中 zzSkeleton 就是一个字符串, ID 我设置成 10001,imgName 表示具体的图片名称,比如代码中的 pic.png,最终图片名称为 "zzSkeleton10001pic.pn"。
在实现过程中要注意以下三点:
第一,注意区分首次使用和二次使用。首次使用的话,客户端遍历上述配置文件,下载数据中对应图片即可;二次使用时,需要拿当前的配置文件对比之前的配置文件,如果图片名称不同,需要下载新的图片。
第二,需要客户端在内存中建立图片,以加快图片骨架屏的加载速度。
第三,图片骨架屏展示过程中会出现拉伸问题。这是因为分辨率不同造成的,我们可以让 App在获取配置文件时,加上当前页面的分辨率,这样接口会根据分辨率返回最合适的图片。
- 使用 SSR 方案优化页面性能
一般来说,一个 Web 页面的渲染主要由客户端或者浏览器端来完成,大致过程是:客户端先从服务端请求到 index.html,然后加载脚本文件,Web 应用通过 ajax 请求到页面数据,并把相应的数据填充到模板,最终形成完整的页面来呈现给用户。SSR(Server Side Rende,服务端渲染) 则把数据请求(也就是前面提到 ajax 请求)放在了服务端,服务端收到返回结果时,把数据填充到模板形成完整的页面,由服务器端把渲染完成的完整页面返回给客户端。这样减少了一些客户端到服务器端的数据接口请求,加快了首屏展现速度。利用服务端的性能优势,尽量在服务端完成资源加载、首屏切分等工作,利用服务端统一缓存机制,对数据接口、页面和组件做缓存。
第一,服务端渲染的最大优势,就是后端服务性能要远高于手机,所以请求数据接口和渲染时,耗时会更短。以我们手机业务列表页为例,CSR 下面渲染需要 600ms,到了 SSR下,渲染只需要 300ms。为此,我们可以把很多原本客户端做到的事情挪到了服务端,比如模块文件加载,首屏切分等。
第二,服务器端缓存与客户端最大不同是,服务端属于统一公用,也就是说,只要某一个用户访问过一次,后续所有用的访问都可以使用这份缓存。我们可以利用这一特点,采用 LRU(Least Recently Used,最近最少使用缓存机制)和 Redis 做好缓存功能,降低白屏时间。具体来说,LRU 属于页面级缓存,对于数据统一性页面(有别于千人千面数据的页面),利用 LRU-Cache 可以缓存当前请求的数据资源。为了降低缓存的颗粒度,提高缓存的服用行,我们还可以用它来对渲染后的 vue 组件进行缓存。而使用 Redis 可以对跨页面的数据接口进行缓存,将整体渲染时间再减少 100ms。为什么呢?因为 SSR 应用程序部署在多服务、多进程下,该进程下的缓存并不是共享的,这就造成缓存命中效率低下,而使用 Redis 可以解决这个问题,进而更好实现跨页面数据缓存。
十六、WebView层及代码架构层面优化##
- WebView 性能优化
WebView 是一个基于 WebKit 引擎、展现 Web 页面的控件, App 打开 WebView 的第一步不是请求连接,而是启动浏览器内核。这意味着,在浏览器端,我们输入地址就开始请求加载页面,但在 App 内,我们还需要先初始化 WebView 然后才能请求和加载。这会造成什么结果呢?同一个页面,在 App 端外反而比端内打开速度更快。因为在 App 内,WebView 还需要先进行初始化,这需要时间,且这个初始化时间还和 WebView 类型有关。其中 Android 下只有一个 WebView,而iOS 下却分 UIWebView 和 WKWebView。以我们 iOS 端使用的 UIWebView 为例,需要 400ms 左右,如果是 WKWebView,时间会更短,但基本也会占首屏时间的 30%左右。怎么解决这个问题呢?这就需要进行 WebView 优化了, 一般它的优化包括资源缓存、并行初始化、资源预加载和数据接口请求优化,以及更换 WebView 内核等。其中缓存选用方面比较简单,直接选用的浏览器默认缓存。而更换 WebView 内核,往往会因为需要进行灰度处理,必须一段时间内(通常几个月)并行两套 WebView 方案,很容易出现系统性风险,比如修改一个严重 Bug 后,前端工程师不知道用户端什么时候生效。所以,在这里,我着重介绍下 WebView 优化里面的并行初始化、资源预加载、数据接口请求优化三个方案。
(1)并行初始化
所谓并行初始化,是指用户在进入 App 时,系统就创建 WebView 和加载模板,这样 WebView 初始化和 App 启动就可以并行进行了,这大大减少了用户等待时间。如果是使用 native 开发的应用,根据用户在首页的访问路径,选择初始化策略,操作体验会更好。以携程 App 为例,假设用户进入首页后,停留在西双版纳自由行区域,直接加载 WebView 和模板,两者同时运行,此时首屏主要工作就变成加载接口请求数据和渲染模板部分的工作了。为了减少 WebView 再次初始化的时间,我们可以在使用完成后不进行注销,将里面数据清空,放进 WebView 池子里面,下次使用时,直接拿过来注入数据使用即可。注意,使用时,要对 WebView 池子进行容量限制,避免出现内存问题。另外还需注意一点,由于初始化过程本身就需要时间,我们如果直接把它放到 UI 线程,会导致打开页面卡死甚至 ANR(Application Not Responding,应用无响应),所以,我建议将初始化过程放到子线程中,初始化结束后才添加到 View 树中。
(2)资源预加载
资源预加载,是指提前在初始化的 WebView 里面放置一个静态资源列表,后续加载东西时,由于这部分资源已经被强缓存了,页面显示速度会更快。那么,要预加载的静态资源一般可以放哪些呢?一定时间内(如 1 周)不变的外链;一些基础框架,多端适配的 JS(如 adapter.js),性能统计的 JS(如 perf.js)或者第三方库(如 vue.js);基础布局的 CSS 如 base.css。一般在 App 启动时,系统就加载一个带有通用资源模版的 HTML 页面,虽然这些静态资源不经常变化,但如果变化呢?怎么避免因变化导致 App 频繁发布版本的麻烦呢?一个办法是通过静态资源预加载后台进行管理。具体的话,我们不需要从 0 到 1 搭建,只需要在离线包后台添加一个栏目即可。在业务接入预加载功能时,前端工程师通过静态资源预加载后台发布出一个静态资源列表页,然后把它的 URL 提供给 App,App 启动时会对这个 URL 下页面中的静态资源进行预加载。之后,前端工程师就可以查看静态资源的编号 ID、URL 和类型,进行删除、添加等管理操作。不要小看这一点,通过这种做法,我们手机列表页 13 个文件缓存后,首屏时间从 1050ms 降低到了 900ms。
(3)数据接口请求优化
数据接口请求优化,主要是通过同域名策略和客户端代理数据请求来实现。其中,同域名策略是指前端页面和资源加载,尽量和 App 使用的数据接口在同一个域名下,这样域名对应的 DNS 解析出来的 IP,由于已经在系统级别上被缓存过了,大大降低了加载时间。比如,58 App 客户端请求域名主要集中在 api.58.com,请求完这个地址后,DNS 将会被系统缓存,而前端资源的请求地址在 i.58.com,打开 WebView 后,由于请求了不同的地址,还需要重新去 DNS 服务器去查询 i.58.com 对应的 IP,而如果前端也改到 api.58.com后,DNS 查询的时间可以从原来的将近 80ms 降低到几 ms。客户端代理数据请求,则是指把前端的数据请求拦截起来,通过客户端去发送数据请求。因为正常的页面加载顺序是,前端在 HTML,CSS,JS 拉取下来之后才开始由 JS 发起前端的 ajax 请求,获取到数据后程序才开始进行填充。而我们通过客户端代理数据请求,可以把前端的 ajax 请求提前到与页面加载同时进行,由客户端请求数据,等 H5 加载完毕,直接向客户端索要即可。如此一来,便缩短了总体的页面加载时间。
- 前端架构性能调优
前端架构性能优化,是指通过在前端开发、编译、打包发布环节所作的优化,以此来提升前端性能的方案。因为我们比较关注首屏时间,对这方面贡献比较大的是开发和打包发布这两个环节,所以接下来我着重介绍下 Vue 开发过程中的长列表性能优化和 webpack 打包分析层面的优化。
(1)长列表性能优化
一般,Vue 会借助 Object.defineProperty 这个 ES5 规范的方法,对数据进行劫持,即通过在某个对象上定义一个新属性或者修改一个属性,实现视图响应数据的变化。这会造成什么影响呢?在一些纯展示的场景里面,比如电商列表页面,如果还允许 Vue 劫持我们的数据,会花费很多的组件初始化时间。这种情况下,怎么做呢?可以使用 Object.freeze 冻结这个对象从而避免修改。
以前面提到的列表页面优化为例。我先定义一个 goodsList 的空对象,通过 async 将 created 钩子函数的返回值(也就是一个商品列表)封装成一个异步 Promise 对象,然后在 created 钩子函数中向 getGoodsList 接口获取数据。其中,Vue 的生命周期里对外暴露的 created 钩子,表示 Vue 实例被创建但还没有渲染到浏览器的阶段;await 表示当拿到返回的数据结果后,Vue 实例才会通过 Object.freeze 把 goodsList 结果冻结,即 goodsList 对象展示过程中,数据变化时,视图将不再更新。通过以上步骤,最终提升商品列表页的性能。
(2)打包优化
打包优化方面,我们可以通过 webpack 插件来完成。 wepack 输出的代码可读性较差,而且文件比较大,我们很难了解打包后的情况,更别说如何优化了。为了直观分析打包结果,我们可以使用一个 webpack 插件------webpack-bundle-analyzer,通过它可以对打包结果进行可视化分析。
比如,有次游戏业务发现打包目录超过了10MB,仔细定位发现是有些 game.map 的文件打包上来了。虽然 game.map 文件便于我们开发时调解 bug,准确定位错误的位置,但在这里却影响了我们的性能体验。找到原因后,解决它也很简单了,直接在打包时,关闭 sourcemap,即在配置文件中增加productionSourceMap:false 就可以了。
十七、详解预请求、预加载及预渲染机制
前面好几讲我介绍过怎么进行首屏时间优化,但其实在性能影响因素里,后端接口的耗时也不可忽略。以某电商 App 列表页为例,后端接口返回数据需要 200ms,参考首屏秒开的标准,它就占了 20% 的时间。在以前我一直采用的是缓存或者静态化的方案去解决,能不请求实时数据就不请求,用历史数据去代替。直到有一天,我遇到了机票和酒店相关的业务,因为它们的价格和库存会实时变化,如果页面打开时间长(首屏时间过长),用户会担心买不到合适的机票,转而离开去往竞品购买(机票和酒店类产品的留存率很低,用户忠诚度有限)。为了避免这种情况,我必须实时拉取后端接口,做到极致秒开。但具体怎么解决呢?那就是采用预请求、预加载和预渲染的方式来解决问题。所谓预请求,就是对后端请求参数的事先拼装,预加载是指对后端数据接口的提前加载,预渲染则是对要渲染页面预先进行渲染。
- 预请求
想要通过拉取后端接口来降低首屏时间,我们需要先实现接口的预加载。而实现它要先解决预请求的逻辑,也就是统一拼装请求参数的逻辑。具体怎么进行统一拼参呢?这就涉及前端正常的数据请求过程了。以机票业务为例,我们进入列表页后,输入出发地和目的地后,比如从北京到深圳,选择日期为 2021-06-06,前端应用通过解析页面 URL 路径,拿到所需的一些参数(如 from=shanghai&to=beijing&date=20210606),然后调用 Native 的 schema 进入参数解析环节,找到 Native 对应的协议和参数(如://search?terminal=app),然后再通过参数初始化,拼装成对应的参数(如://search?terminal=app&from=shanghai&to=beijing&date=20210606)。如果预请求走上述流程的话,面临的一个问题是,没有预请求的页面 URL 参数,也没法通过 Native 获取到。这需要自己根据逻辑拼装,所以往往会单独做出一套流程,结果就是不但容易出错,还会因为需要用类似两份代码去实现这个功能,反过来拉长页面的首屏时间。所以,我们使用了同样的流程,将预请求封装成preReq 功能,把所有的功能都包括起来,用同一份代码实现。在做完这个统一拼参逻辑后,预请求实现起来就容易了。具体来说,如果你已经使用了 Native 统一请求,直接走客户端逻辑发送即可。如果还没有走 Native 统一请求,我们可以借助Axios 库函数来完成。第一步,我们需要封装一下 Axios 库函数,在 post 和 get 之前,通过添加一个钩子函数 BeforeFetch,对 URL 参数进行解析和 Native 参数补全。第二步,业务侧使用与请求时,因为 Axios 库是整体打包引入的,所以使用时,可以直接使用 Axios.fetch 方法来实现预请求功能。
- 预加载
在完成预请求参数拼装之后,紧接着就是预加载逻辑了。首先是要把握预加载的时机。以机票列表页为例,我们需要判断用户操作的特定路径。如果用户操作命中了这个特定路径,就会做预加载,去请求列表页的接口。路径是我们和后端的一个约定,有具体的编号,比如用户"进入首页"编号是 0,"输入出发地和目的地"操作路径是 1,"输入日期"操作路径是 2,"切换关键词"是 3,点击"我的位置"是 4。后端在用户进入列表页时,以接口的方式返回一个操作路径的数组,当用户的操作路径命中这个数组后,比如 [1,2,3],意思是用户从首页进入,选择了出发地和目的地,并且输入了日期,接下来开始进行预加载。当用户点击"开始搜索"后,前端应用就会去判断有没有预加载下级页面(搜索页面)的接口,是否有搜索页的预加载数据,而且这个数据又没有过期,就直接跳转下级页;如果没有可用的预加载数据,此时我们进行一次搜索页的预加载,减少从列表页到搜索页的跳转时间和搜索页的初始化时间。预加载是怎么实现的呢?如果 Native 已经提供这个功能,我们直接使用 Native 的预加载接口即可。反之,我们还是需要扩展 Axios 库函数来实现。具体来说,在 Axios 进行数据请求后,封装一个 afterFetch 的钩子方法,负责将加载完成的数据存储到本地,供下一个路由使用。这就完成了预加载。当业务侧使用时,先在 aftereFetch 钩子里面定义好取到数据后做什么,然后直接使用 fetch 方法即可。比如手机列表页,有一个场景是提前获取下一页的数据做排版,数据预加载完成后,在 afterFetch 里面就会将这些数据存储到内存中。即便是预加载,也要做好缓存处理。 我们要先在内存里面 check 一下是否存在之前预加载的数据。有的话,直接用预加载数据,做后续操作,如果没有,就继续走预加载逻辑,然后设置缓存数据。
- 预渲染
预渲染是指在用户访问这个页面之前,完成页面渲染的准备。还是以机票列表页为例,比如说用户命中特定路径的时候,前端进行判断并会把搜索结果页先渲染出来,只不过在列表页可视区域下方,用户是不可见的。当用户点击开始搜索时,前端会去 check,如果已经有了预渲染的页面,只需要把页面显示出来的操作, push 到顶层即可。这样就省去了初始化页面、请求数据和渲染的时间。具体怎么实现呢?这就需要用到"客户端"渲染技术。你看我在这里加了个引号,其实就是说,它有别于 CSR,而是 NSR(Native side rendering,客户端渲染),即通过客户端(Native 侧)进行页面结构拼接,进而实现页面渲染的处理技术。具体见下图所示:
SR 优化时,需要离线包提供模板等资源(如 HTML、JS、CSS ),预加载提供数据,把页面作为数据经过模板函数变化后产生的结果,然后通过 v8 引擎在客户端渲染出来。
NSR 是怎么实现的呢?
首先是模板和数据的准备,用户点击页面链接进入后,这个页面的所有资源是准备好的。具体可以使用前面提到的离线包,以及预请求和预加载方案来做。
其次,由于页面是动态的而 URL 是静态的,需要实现一种页面与模版的映射机制,一般为多对一,这个机制有助于 Native 快速定位到所需模版。
最后,在 Native 侧实现一种类似前文 SSR 方案的 Native 本地渲染服务。
实现完 NSR 之后,业务就可以使用预渲染功能了。在使用时,前端代码不需要做什么改动,业务侧前端工程师接入 NSR,把后置流程准备好就可以了。所谓的后置流程,就是指渲染好下级页面后放置在可视区域之外。