🔥100+应用加载性能提升50%+——国际化业务中台性能优化实践(上)

avatar

这是我们团队号工程化系列的第六篇文章,将为大家介绍如何负责并完成团队中的前端性能优化工作。 全系列文章如下,欢迎和大家一同交流讨论:

团队尚有HC,感兴趣的小伙伴可以私信~(注明期望岗位城市:北京、上海、杭州)

引言

为什么本地秒开的页面线上用户 10s 都加载不出来?

性能指标那么多,该怎么制定适合团队业务特点的性能指标和优化目标?

前端性能优化是不是只要优化打包就够了?

如果你正在负责自身项目或团队的性能优化工作,想知道应该如何快速推进并达成既定目标; 或者作为前端新人,想了解在实际工作中应该怎样理论结合实际将前端页面加载性能做到极致。 本文将从下图带你完成从0到1的前端性能优化。

WechatIMG158.jpeg

前期准备工作

确定指标及目标值

做好一件事,一个切实可行的目标是不可或缺的

衡量 Web 性能的维度和指标有多种多样,如何定义/选取符合实际业务场景并反映真实用户感知的性能指标,以及确定优化目标对目标的达成有非常关键的影响。Google Chrome 团队的成员与 W3C 网络性能工作组在对网页性能指标定义和评分上进行了大量的工作,多年来一直在努力实现一系列新的 API 和指标的标准化,以便更准确地衡量用户体验的网页性能,定义了 FCP、LCP、TTI 等多个指标。

指标名称指标定义指标评估参考数据
FCP从网页开始加载到页面任意部分内容渲染在屏幕上的时间,即首屏时间
LCP用于度量视口中最大的内容元素何时可见。它可以用来确定页面的主要内容何时在屏幕上完成渲染
TTI从页面开始加载到页面的主要资源已加载并能够响应用户输入的时间

在业务中台项目中,我们的B端用户更关注的是数据展现的时机。从这个角度出发,TTI是比较符合我们的业务场景的。TTI 指标的计算相对其他指标来说会稍微复杂一些,具体逻辑如下:

  1. First Contentful Paint (FCP) 开始。
  2. 向前搜索一个至少 5 秒的静默期,其中静默窗口的定义为:没有长任务,且不超过两个进行中的网络 GET 请求。
  3. 反向搜索静默窗口之前的最后一个长任务,如果找不到长任务,则在 FCP 处停止搜索。
  4. TTI 是静默窗口之前的最后一个长任务的结束时间(如果未找到长任务,则与 FCP 相同)。

实践中 TTI 对离群网络请求和耗时的任务过度敏感,导致该指标出现较高的变化,该指标自提出以来一直是一个实验性的指标(已经在 Lighthouse 10 中移除)。

结合实际业务场景,我们定义页面核心内容完成渲染为时间为页面可交互时间,其中核心内容即列表页中的列表或详情页中的基本信息。判断页面上内容展示的时间实现起来会相对复杂一些,对业务代码有较大的侵入性;另外,受益于现有前端框架本身的优化,从数据到展示的时间通常是毫秒级的,所以我们进一步将可交互的时间简化为核心接口请求完成的时间,并将这个指标命名为 x-fmp。

简化后的计算公式为:x-fmp = 核心接口结束时间 - navigationStart

参考上述表格中各个指标的定义,x-fmp实际介于FCP 与 TTI之间,那么可以得出结论

FCP + API time < x-fmp < TTI

参照上述W3C/Chrome提供的FCP与TTI的评估范围,x-fmp 达标值应该低于 2 + API time(0 - 2s) ,或者低于3.8s

综合取整后,我们最后将 x-fmp < 4s 定为了优化目标

数据上报

有了明确的目标,下面我们就可以收集所有页面的性能数据了

性能上报工具

为了方便组内各项目统一接入,需要封装统一的性能指标上报工具。结合项目中使用的 axios 或 umi-request 请求工具库,性能指标上报工具以 axios interceptor 或 umi-request middleware 的形式提供给业务项目使用,业务项目需要在接口定义中标记哪些接口属于页面核心接口。整体页面加载和上报流程如下图所示:

WechatIMG159.jpeg

需要注意的是,在实际收集数据时,应尽量把相关的数据一起收集上来,比如:window.performance.timing、核心接口路径及耗时等等,这些数据将有助于我们分析页面加载的性能瓶颈。由于我们组内的所有项目之前已统一接入了公司的前端页面监测工具 Slardar,所以在性能上报工具中,我们在 x-fmp 外,增加核心接口路径及耗时的数据,并将这部分数据以自定义事件的形式上报。

数据聚合和异常数据处理

完成性能指标上报接入后,我们需要对数据的准确性和有效性进行分析,包括但不限于:页面 Id 聚合、数据上报环境、数据来源等。

  • 页面 URL 中通常包含路径参数或查询参数,直接使用页面 URL 作为页面 Id 可能会导致数据统计中将原本属于一个页面的访问识别为多个页面(如果页面路径有统一的规律,且数据分析平台支持对原始数据灵活的处理,也可以在数据分析平台进行统一处理,否则需要在数据上报前完成处理)。
  • 通常的系统都会区分线上的生产环境和线下的测试环境,需要注意区分数据的上报环境。
  • 需要确保所有统计数据均为线上真实用户的访问数据,这可能是最容易忽略的一个点。我们前期在分析数据时发现部分页面的性能数据异常的差,深入分析后发现原来是我们的白屏检测工具会定时访问页面,且检测频率远大于常规的用户访问频次。

看板搭建

在上一步我们完成了数据的上报,那么现在需要把收集的数据聚合起来,更直观的观察

在整个优化工作的推进过程中,为了便于日常观察优化效果和准备阶段性的优化进度汇报,需要搭建有一个清晰有效的看板。从看板中我们能够直观的看到:

  1. 优化目标指标 x-fmp 以及相关指标的整体走势——衡量优化进度
  2. 项目或者页面维度的 x-fmp 及相关指标数据——确定哪些项目/页面需求投入主要精力去做优化

经过多次尝试和改进后,我们以天为维度,通过数据平台完成pv TOP50 页面对应看板的搭建:

核心页面访问量和性能指标看板

用户属性和静态资源加载耗时等辅助看板

性能瓶颈分析

了解当前系统的从url输入到上报的全部流程,我们才更容易定位到我们的瓶颈所在

所以这里利用chrome原生自带的performance工具记录下从输入url到上报的全过程

我们对上图进行整合,得到如下图的整个页面渲染流程

image.png

数据如下

时间轴流程简述总流程时长、占比
0 - 446msHTML加载446ms(~6.8%)⬆️
446ms - 2000ms主chunk CDN加载1554ms(~23.7%)⬆️
2000ms -2256ms应用主入口代码执行256ms(~3.9%)
2256ms - 2556ms2256ms - 3756ms2256ms - 2856ms入口部分异步chunk CDN加载全局接口获取多语言初始化300ms(~4.6%)1500ms(~22.9%)⬆️600ms(~9.2%)
3756ms - 5086ms页面异步chunk CDN加载1330ms(~20.3%)⬆️
5086ms - 5256ms页面组件渲染171ms(~2.6%)
5256ms - 6556ms主数据接口成功返回1300ms(~19.8%)⬆️
总计6556ms (x-fmp)⬆️

接下来,我们再根据这些不同阶段数据的问题来进行更细的分析

网络层

首先,我们先分析上述汇总中涉及到网络层的耗时

HTML加载

  • 首先通过观察html返回,发现主域名HTTP协议版本为HTTP 1.1,协议版本过低

  • 再对HTML的请求进行分析

DOC资源虽然只有2.6KB, 但是Waiting for server response时间达到了511 ms

根据我们上述的系统架构分析,系统为全球使用,但是我们系统域名只部署在了新加坡机房,那么获取HTML的时间将取决于用户所在地(当缓存失效时),欧美地区由于距离新加坡机房较远,他们的HTML获取的时间将会有更多的路由/机房调度消耗,为了验证正确性,我们对各地区页面的HTML加载情况通过Slardar进行数据分析

数据汇总如下:

TTFB:首字节网络请求耗时,从客户端开始和服务端交互,到服务端开始向客户端浏览器传输数据的时间(包括 DNS、Socket 连接和请求响应时间),是能够反映服务端响应速度的重要指标。可以简单理解为返回HTML的时间即可

地区TTFB
美国~400ms
新加坡~30ms
中国~300ms
英国~300ms
巴西~450ms

在新加坡以外的其他地区,TTFB普遍达到了300 ms + ,是新加坡的10倍+

结论:HTML的获取时延超出预期太多,需要采用缓存等策略减少请求次数

CDN 加载

image.png

  • 通过Network层的瀑布流图可以发现,chunk的加载存在并行限制,超出6个资源后,剩余的chunk变成了串行加载,通过观察请求发现请求协议使用为HTTP 1.1,同样有协议版本过低的问题
  • 接下来我们看看CDN的加载情况,由于是全球系统,我们同时可以利用Slardar观察特定地区海外用户的访问链路,这样更贴近于真实情况

这里发现部分chunk加载时间会很长,单个chunk的长加载导致了整个页面都被block住了,这个问题的原因就又涉及到了系统的架构问题了:

传统的访问加速通常是配置动态 DNS 解析,将针对同一个服务的访问解析到就近的机房,然后后续的请求都由此机房对应的服务进行处理。但是,目前我们的系统实际上仅有新加坡机房可用,无法按照传统的方式实现资源的就近加载,导致了各地区CDN在无缓存情况下都需要回源(当 cdn 缓存服务器中没有符合客户端要求的资源的时候,缓存服务器会请求上一级缓存服务器,以此类推,直到获取到。最后如果还是没有,就会回到自己的服务器去获取资源)到新加坡机房。

上述都是无缓存的情况,那么当用户都走缓存的时候,那样就不会存在 CDN 溯源问题了? 我们可以通过我们之前上报收集的数据来观察一下

这张图里汇总了静态资源慢加载率、CDN缓存命中率等数据,通过图不难发现,CDN缓存命中率不足20%, 静态资源慢加载率最高值达到了40%+

结论:频繁的系统发版频率导致了CDN缓存的利用不足,所以静态资源核心问题还是要解决CDN回源到新加坡机房的问题

应用层

页面开始渲染后,后续的优化就是应用层面的了

应用层分析我们从打包、缓存、代码来分别入手

打包

  1. 对业务打包产物利用打包分析平台Perfsee/或者使用webpack-bundle-analyzer进行分析(这里以我们内部平台Perfsee为例),得到如下打包产物分析图谱

通过打包产物平台可以发现,我们产物中出现了45个重复包,这些包导致的体积占用在gzip后为600KB ~ 1MB,部分重复包大版本不一致的可能无法统一,但是大版本统一的包可以进行去重

  1. 继续观察打包产物在浏览器Network面板中的表现

发现如下问题,部分性能表现较差的项目,打包产物只分了3个chunk,单个chunk在大小上都达到了~1MB,在本地开发时发现chunk的拉取速度一般在1s内(尚可接受),并且命中缓存后实际会更快,但是我们本地并非是用户视角,所以我们继续通过Slardar观察普遍的用户侧数据

这里就发现了在部分用户的网络环境下,单个chunk的拉取时间直接到达了1min+ 的超长数值(尤其是网络环境不佳的时候),因此得出如下结论:

  • chunk的hash频繁变化的情况,单个chunk不宜过大
  • 部分不经常变化的包可以打到一个chunk下,这样可以提高缓存命中率,同时可以减少拉取chunk的开销

缓存

CDN的缓存在网络层我们都已经聊过了,这里我们主要聊一聊我们前端侧的本地缓存,本地缓存跟系统本身逻辑是强关联的

  • 比方说我们系统因为是全球系统,所以会涉及到多语言的拉取以及初始化,我们来看看这一部分的性能表现如何

starling以及mon-va的相关请求即为多语言的请求,可以从图中发现,我们进入页面以后,会先去请求多语言的相关资源,然后进行初始化,后面才会走到页面的渲染工作上,整个多语言的初始化是阻塞页面渲染流程的。

从业务上来说,多语言的展示并不需要实时性,所以这边对多语言的配置做缓存,如果有多语言更新,那么再更新缓存,同时增量更新页面即可(即懒更新策略)

  • 阻塞渲染流程的可能不仅仅有这些基础配置的初始化,经过观察,部分页面的初始化流程中还会有一些全局枚举的获取,我们找一个例子🌰

image.png 在页面渲染前,这里调用了2个接口去获取枚举/配置信息,配置接口成功后才会进行页面的渲染,这里也是阻塞性的

由于接口枚举是静态化配置,所以其实这里也可以放到缓存下,当枚举接口有更新时,再去动态更新缓存,避免接口阻塞页面的渲染

代码

  1. 请求优化

在部分未达标的页面观察中发现,存在上报接口的调用时机晚于预期的情况,通过分析发现如下图

image.png

queryQuoteBasicDetail接口为主数据接口(即需要进行x-fmp上报的接口),从瀑布流观察,该接口被前置的几个数据接口阻塞了,此时不排除接口之间有依赖关系才出现这个问题,所以我们进一步进行代码的排查,发现如下代码

useEffect(() => {
   ;(async () => {
      try {
         setLoading(true)
         if (id) {
            const config = await fetchAPI(apis.quote.getConfig, { quoteId: id })
            setBaseConfig(config)
            const detail = await fetchAPI(apis.quote.getDetail, { id })
            setDetail(detail)
            setAdvId(detail.advertiserId)
         }
      } catch (error) {
         logger.error(error, {
            loggerId: 40,
         })
      } finally {
         setLoading(false)
      }
   })()
}, [id, detailFlag])

通过标黄处的代码发现,这两个请求其实没有直接依赖关系,async/await导致了他们变为了串行调度,而apis.quote.getDetail为主数据接口,导致了性能的劣化,这里完全使用并行调度进行替代

  1. 组件优化

在上述页面渲染流程中计算可知,组件渲染层耗时入口 + 页面 ~= 400 ms 占比约为10% 不到

在我们的业务中,部分核心数据接口的请求在组件渲染后才会调用,所以组件层的渲染优化对我们的x-fmp也会有一定的提升

// apiPromise为核心数据上报接口,并且依赖组件的渲染

const PageA = () => {
    // ...
    return (
        <ManagerShell
          apiPromise={apiPromise as IManagerShellProps['apiPromise']}
          formatParams={formatManagerShellParams}
          refreshDeps={[currentPolicy, refreshId, sorter]}
          tableConf={{ 
              // ...
          }}
          filterConf={{
              // ...
          }}
        />
    )
}

export default PageA;

这种情况需要具体情况具体分析,主要利用useMemo,useCallback等hooks来减少组件的重渲来提升性能,目前团队文章已经有了一篇更加详尽的文章来教大家如何手把手进行组件的优化,这里不再赘述,这里直接贴链接

⚡️卡顿减少 95% — 记一次React性能优化实践(性能篇)

总结

上篇我们主要介绍了如何做性能优化的前期工作以及分析,受限于文章长度,接下来我们下篇将会主要着重解决分析出的问题(更多的干货),大家可以期待一波🎉,(PS:未完结,不撒花),下篇已出炉,欢迎大家围观

🔥100+应用加载性能提升50%+——国际化业务中台性能优化实践(下)