这是我们团队号工程化系列的第七篇文章,将为大家介绍如何负责并完成团队中的前端性能优化工作。 全系列文章如下,欢迎和大家一同交流讨论:
- 字节三年,谈谈一线团队如何搞工程化一(全景篇)
- ⚡️卡顿减少 95% --- 记一次React性能优化实践(性能篇)
- Modal管理-看这篇文章就够了 (实践篇)
- 🍓中台表单技术选型实践(表单实践)
- 业务中后台用户体验优化方法论总结(体验篇)
团队尚有HC,感兴趣的小伙伴可以私信~(注明期望岗位城市:北京、上海、杭州)
引言
在 🔥一文带你从0到1做性能优化------国际化业务中台性能优化实践(上) 中,我们着重介绍了性能指标选取、目标制定和性能瓶颈分析,本文将为大家介绍我们是如何解决对应问题,并达成优化目标的(为了防止大家忘记,这里再提一嘴我们的目标是 x-fmp < 4s)。
性能优化方案
从上一篇文章的分析可见,我们的系统不论是服务端配置,还是微前端框架和子应用都有比较大的优化空间。为了使行文更加条理清晰,我们将优化改造措施分类进行阐述。实际优化过程中,还是根据改造成本、影响范围以及预期的优化效果对优化措施进行了评估,本着低成本快速见效的原则,优先从低投入高产出的措施开始。
网络层
HTTP 协议升级(1.1 -> 2)
在 HTTP/1.1 中,每个 HTTP 请求占用一个 TCP 连接。Chrome 默认限制每个域名最多存在 6 个 TCP 连接,这意味着浏览器在同一域名下最多同时发起 6 个资源请求,多出的请求需要排队等待。升级到 HTTP/2 以后,多个 HTTP 请求共享一个 TCP 连接,从而显著减轻了并行请求排队的问题,并且还引入了一些压缩逻辑以减少传输量。下图是升级前后页面加载流程对比:
HTTP 1.1:
HTTP 2:
静态资源和主站分属不同的域名,需要分别进行协议升级配置
TLS/SSL配置优化
TLS/SSL 的核心目的是确保服务器的可信性和数据传输的加密,在 HTTP/2 中被强制启用。TLS 需要多次握手,以验证服务器和客户端的身份,并协商加密密钥。其重要升级版本 TLS 1.3 减少了支持的加密算法类型,消除了协商加密算法类型对应的握手,如下图所示。
通常情况下,客户端还会通过在线证书状态协议(Online Certificate Status Protocol,OCSP)验证 CA 证书的有效性。可以在服务端开启 OCSP Stapling,由服务端主动获取 OCSP 查询结果并随着证书一起发送给客户端,从而让客户端跳过自己去获取的过程,提高 TLS 握手效率。
完成 HTTP 协议升级和 TSL/SSL 配置优化后,预期可以对无缓存访问减少 1s 左右的加载时长,从第二天的观测数据来看整体上有 100-200ms 的优化。
CDN
内容分发网络(Content Delivery Network,CDN)通过将源站的内容缓存到离用户较近的边缘节点,使用户可以就近获取内容,提升资源分发速度。下图(来自火山引擎)展示了用户访问未接入 CDN 和接入 CDN 的网站时的请求链路对比。
未接入 CDN 的站点
接入 CDN 后的站点
CDN 全球加速
分析某个系统线上的静态资源加载耗时数据后发现,资源慢加载率较高且受影响的用户比例较大,即使我们把资源慢加载阈值调整到 3s,仍然有 55.94% 以上的用户会受到慢加载的影响。
慢加载时长阈值 | 慢加载率和受影响的用户比例 |
---|---|
0.5s | |
1s | |
2s | |
3s |
类似下图中的这种由于一两个资源加载过慢导致整体耗时较长的情况非常普遍。
从相关系统负责人处了解到,我们使用的资源分发服务仅仅是一个静态资源服务器,并没有接入 CDN。为此我们尝试将部分系统的资源发布切换到接入全球 CDN 加速 的服务。切换后可见对应系统的资源加载耗时分布明显左移,0-2S 内加载的资源数量提升了 ~40%
下图 x 轴为加载时长(ms),y 轴为资源数量(个)
未接入 CDN
接入 CDN 后
动态 CDN
接入全球加速 CDN 后,资源加载速度虽然有所改善,但仍然不够快,究其原因在于 CDN 命中率较低 ------ 仅 30% 左右。当然,这和我们所负责的业务有关,作为国际业务中台,服务的用户均为公司员工,用户数量较少,且比较分散在多个国家和地区。为此,我们还需要对 CDN 回源请求耗时进行优化。
上图统计了对应时间内用户访问站点时请求的所有静态资源中,命中浏览器本地缓存和命中 CDN 节点的资源比例,单独计算 CDN 命中率时应该是所有未命中浏览器本地缓存的静态资源中命中 CDN 节点的资源比例。对应上图中展示的 12.20 的数据 CDN 命中率为 10.74 / (10.74 + 20.99) = 33%。
在之前的前端资源部署方案中,我们会在发布时按照资源的目标发布区域分别进行构建,生成中国、新加坡和美东三份构建产物,原本计划由对应区域的页面托管服务分别进行消费。然而,在实际生产环境中,前端页面托管服务和后端服务使用了同一个域名,而后端服务仅有新加坡区域的可用。因此,无法将对应域名按地区进行解析,即:仅使用了新加坡区域的资源。当用户的请求未命中 CDN 缓存时,需要由 CDN 服务商回源至新加坡机房。
所以 CDN 回源请求耗时优化的方案也比较明确:把中国区和美东地区的构建产物利用起来,按照用户的位置动态确定应该使用哪个地区的 CDN 资源。调整前后的网络链路如下图所示:
接入动态 CDN 前
接入动态 CDN 后
按照上述思路,我们需要把原本打包时就确定下来的资源发布地址,改为在页面托管服务生成 HTML 时动态确定。
Webpack 打包构建时的 publicPath 改为模版字符串,由页面托管服务生成 HTML 时动态替换为有效的 CDN 域名前缀,此外还需要在运行时入口通过 webpack_public_path 动态改变 publicPath,保证非初始 JS 也能正常加载,具体可以参考 webpack.js.org/guides/publ...
所有接入动态 CDN 后,前端的加载执行耗时大幅度下降。
DNS Prefetch
由于我们的静态资源、多语言、前端监测 SDK 等分别部署在不同的域名下,DNS Prefetch 可以帮助我们避类似下面的非预期请求耗时,减少 ~100ms 的 DNS 查询耗时。
配置方式也比较简单,只需要在 HTML 的 header 区域添加如下配置:
ini
<link rel="dns-prefetch" href="https://xxx.yyy.com/" />
应用层
HTML 缓存
为了保证发版后用户能及时访问到更新后的页面,通常不会为 HTML 本身设置强缓存,而我们的页面托管服务会将用户的权限信息注入到页面中,需要保证较高的实效性,所以也没有启用协商缓存。但考虑到发版和权限更新频率均不是很高,我们可以通过 Service Worker 来实现更加灵活的缓存策略。
实现如下:
如上图所示,他的主要作用有2个
- 首次进入页面时,无缓存,正常请求获取页面,同时更新到缓存下
- 非首次进入页面,有缓存,优先从缓存中取值作为响应值,同时后台(Service Worker 独立线程,不阻塞 JS 主线程执行)发起请求并更新缓存,同时在页面上显示通知以提示用户刷新页面,从而使最新的资源生效
接入后,更新通知效果如下:
接入后效果
未命中 Service Worker 缓存的 HTML 请求中 P75 耗时在 700 ms ,相比之下命中 Service Worker 缓存的 HTML 仅需数十毫秒 。从线上数据来看目前 HTML 的 Service Worker 缓存命中率在 60% 以上,效果还是比较明显的。
多语言
在目前我们应用架构设计下,必须在多语言完成加载后再渲染页面,才能保证页面上的多语言正常展示,这是一个典型的瀑布流加载。但是,短期内还没有比较好的方案可以低成本的改造这种设计,所以只能考虑优化多语言初始化流程,优化思路当然还是缓存。
我们使用的多语言工具 Starling 本身提供了缓存配置方案,但是需要业务方实现对应的缓存存取逻辑才会开启。启用缓存后,Starling 请求文案时会携带本地缓存文案的版本号,服务端判断文案没有更新则会直接返回,有更新的情况下也仅会返回更新部分的文案。
vbnet
import localforage from 'localforage';
new Starling({
...
store: {
set(key, val) {
return localforage.setItem(key, val)
},
get(key) {
return localforage.getItem(key)
}
}
})
相较于没有缓存的方案,多语言文案请求速度已经有了一定程度的提升,但检测文案更新的请求仍然会阻塞后续的加载流程。能不能像前面的 Service Worker 缓存那样直接使用本地缓存来响应多语言初始化,并后台静默更新缓存文案呢?经过研究发现 Starling 也已经提供了一个 lazyUpdate 参数用于实现上述逻辑。考虑到日常需求开发上线后,新增的文案在用户本地缓存是不存在的,直接使用缓存可能会导致页面上的文案展示异常,所以我们仅在用户使用英文(代码中兜底文案语言)浏览页面时开启这个参数。下图是无缓存和命中缓存且开启 lazyUpdate 的页面加载流程对比,提升 ~200ms:
无缓存
缓存+lazyUpdate(lazyUpdate 仅在 lang=en 时开启)
打包配置
打包的优化思路主要有两个:
- 合理拆包,尽量减小升级发版造成的缓存失效文件数量和大小;
- 去除多版本重复依赖,减小打包产物体积和浏览器解析之行耗时。
拆包优化
通常业务项目的依赖升级的频次较低,主要是日常的功能维护和迭代,可以通过调整 splitChunks 的 cacheGroups 配置,将源码和依赖分别打到不同的 chunks 中,以最小化每次需求发布时打包产物的更新,分包策略可以参考如下:
一般公司内部的框架都做好了这些默认的打包优化,当需要自己基于webpack等打包工具配置时,可以参考如下策略
- 入口文件单独一个 chunk
- 各个路由页面单独一个 chunk
- 复用的模块单独起一个 chunk
- 不变的依赖根据功能聚合或单独起 chunk 分别打包,控制每一个 chunk 尽量不要大于1MB(gzip后)
最后参考效果如下:
该优化效果因项目/用户而异,分包主要表现在在网络不佳且没有缓存的情况情况下,有着较大的提升。
依赖多版本优化
相信大家在网上也经常看到有人吐槽前端项目的包依赖太多,除了包管理器本身一些机制外,可能也和依赖声明相对混乱有关。很多包的版本命名并不符合 semver 规范,导致使用方出于保守起见会将依赖声明为一个固定的版本,而不是某个范围。当项目中引入很多依赖时很可能由于间接依赖的版本不一致而导致重复打包。
由于很多包并不是由我们自己维护管理,想要从源头修复依赖多版本问题是比较困难的,Yarn 和 pnpm 也都提供了依赖版本重写的机制。pnpm 项目可以结合自己的状况通过 .pnpmfile.cjs 实现相关问题的修复,最后优化结果需要具体项目具体分析。
不同的依赖管理工具写法简介如下:
npm
更详细的可以参考官方文档 docs.npmjs.com/cli/v9/conf...
json
// package.json
{
"name": "xxx",
"version": "1.0.0",
"description": "xxx",
"dependencies": {
// ...
"package-a": "1.0.0",
},
"devDependencies": {
// ...
},
// ...
"overrides": {
// package-a版本将会以1.1.0为准
"package-a": "1.1.0",
}
}
yarn
更详细的可以参考官方文档 classic.yarnpkg.com/lang/en/doc...
json
// package.json
{
"name": "xxx",
"version": "1.0.0",
"description": "xxx",
"dependencies": {
// ...
"package-a": "1.0.0",
},
"devDependencies": {
// ...
},
// ...
"resolutions": {
// package-a版本将会以1.1.0为准
"package-a": "1.1.0",
}
}
pnpm
更详细的可以参考官方文档 pnpm.io/pnpmfile
java
// .pnpmfile.cjs
function readPackage(pkg, context) {
// ...
if (pkg.name === 'package-a') {
// package-a版本将会以2.2.2为准
pkg.dependencies['package-a'] = '2.2.2'
}
return pkg
}
module.exports = {
hooks: {
readPackage,
},
}
对比而言 pnpm 的配置由于是通过代码来实现,使用起来更为灵活。
公共资源共享
各个项目之间同时依赖了很多重复的资源包,这些资源包每次都会打包到各个项目中;这里可以利用webpack的externals 特性,将这些公共的包复用起来,从而减小各个项目打包产物的体积
externals:防止将某些
import
的包(package)打包到 bundle 中,而是在运行时(runtime)再去从外部获取这些扩展依赖 ( external dependencies ) 。
实现如下:
目前该功能还在实验阶段,稳妥起见只提取了几个关键的基础依赖包:
包名 | 版本 |
---|---|
react | 16.14.0 |
react-dom | 16.14.0 |
react-router-dom | 5.3.4 |
优化后打包产物体积 gzip 前减少 123 kB,gzip 后减少 38 kB。虽然相对单个项目来说打包产物大小变化不大,但是考虑到组内众多项目可以复用,对加载性能还是有一定的提升作用,并且整个流程跑通后,后续可以进一步扩大到更多的基础依赖。
防劣化
性能优化是一个长期工作,指标达标以后也不代表我们的工作就结束了,随着业务的迭代,页面后续会出现劣化的可能,所以防劣化的建设是必不可少的一环
防劣化的思路主要可以从监控报警 以及流程规范出发,具体的方案可以根据团队需求自己调整,下面我们的方案只是给大家作为参考
监控报警
- 整体数据的监控, 基于周维度进行性能数据大盘的推送(负责人+团队内),实现方式可以是类似机器人的方式,在性能发生抖动时及时排查定位
- 项目维度的监控, 以天为维度设置单个项目的性能告警,当项目 x-fmp > 4s 时,推送给项目对应的负责人(这里其实跟异常报警是一样的原理)
Tips:告警的设置注意好分类降噪,否则到时候会演变成推送无效/告警太多导致无人关注的情况
流程规范
- 打包卡点
在之前我们做打包产物分析时,产物部分其实是上报到了平台上,在这里我们可以基于平台能力在每一次打包后,进行最近的产物体积对比,将对比结果推送给打包人,当发现体积变化 >= 10% 时,就需要排查是否是增加了重复依赖等
- 发布卡点
由于我们的发布是基于原子节点编排的流水线,所以可以在小流量时利用自动化工具手动访问收集一次性能数据,当性能数据异常时暂停发布流,让触发人手动确认结果来继续发布,可以参考如下图
节点失败后通知如下
除了上述这些,防劣化的建设还有一个重点是要让团队同学都能有意识并重视起来,在团队内部的宣讲以及后续推进也是必不可少的部分,这里大家就需要根据团队来制定策略了,定期的回顾会议,性能优化培训等都是可行的方案。
总结
做完上述优化后,最后在团队内部取得的结果还是令人满意的,截止到当前日期,所有页面加载耗时的 p75 从 6.3s 降低到 3.4s,p90 从 9.8s 降低到 5.5s,整体提升了~50%, 各项方案提升总结如下:
以上篇中的example中的 x-fmp = 6556 ms ****为例,优化后从6556ms 降低到了3513ms ,提升46.4%
类别 | 优化方案 | 数据提升 |
---|---|---|
网络层 | 1. HTTP 协议升级 2. TLS/SSL 配置优化 | ~100ms - 200ms(1.5% - 3% ⬆️) |
网络层 | CDN 加载优化 | ~800ms - 1000ms(**12.2% - 15.2%** ⬆️) |
网络层 | HTML 缓存 | ~400ms(**6.1%** ⬆️) |
网络层 | DNS Prefetch | ~100ms(1.5%⬆️) |
应用层 | 多语言缓存 | ~200ms(3%⬆️) |
应用层 | 打包配置优化 | 各项目需要具体分析,一般平均下来在 600ms - 1000ms(**9.1% - 15.2%** ⬆️) |
应用层 | 公共资源共享 | ~100ms(1.5%⬆️) |
性能优化一直是一个长期的热点话题,本文这里也只是抛砖引玉,优化方案还有很多诸如 SSR,SSG 等,因为这些方案本身不适合在中台系统落地,所以这里并没有提及,这里更多的是从我们自身的实践中总结出来的一些经验,大家在真正做性能优化时还是需要按照自己的业务特性进行取舍。
最后,如果大家有自己不同的看法或者更好的方案也可以在下面与我们一起分享,完结撒花🎉