优化无止境,爱奇艺中后台 Web 应用性能优化实践
爱奇艺视频生产智能云平台系统在今年进行了一次重大升级,前端团队也趁此机会将底层技术架构从三年前的 Arm.js(内部 MVC 框架)+ Java BFF + Velocity 模板完全切换到了 Vue.js + Node.js BFF 的技术栈。
新的前端应是一个拥有超过十个业务模块的单页面应用,每个模块已经通过路由懒加载进行了拆分,同时公共的第三方依赖也拆分到了单独的 Vendor 文件。不过在上线试用初期,用户还是普遍反馈页面打开速度较老版本有比较明显的下降,存在几秒钟不等的白屏等待时间。
为了提升用户体验和使用效率,团队内部对新版前端应用进行了多次优化,最终效果提升非常显著。本文的主要内容就是针对中后台 Web 应用性能的分析思路及解决方案的总结分享。
问题梳理
我们先通过提问题的方式,从资源文件加载、页面渲染性能、接口响应速度等三个方面分别列出了一些可能存在性能瓶颈的环节。
在一个复杂的 Web 应用中,通常会依赖很多 JS/CSS/Images 等资源文件。如何在最短时间内获取页面所需的最小资源,我们需要考虑以下几个问题:
源码中有无冗余的模块?是否进行了压缩、合并等操作?
服务器响应及网络传输速度是否正常?有没有最大化利用浏览器的并发请求?
资源文件的缓存策略是否合理?是否每次发布上线都需要重新请求所有文件?
首次页面渲染是否下载了不必要的资源文件?每次渲染所需的资源文件能不能提前加载?
页面渲染问题
由于 JS 是在单线程中执行,而 Vue.js 框架的大部分渲染任务都在浏览器端完成。为了解决白屏、卡顿等问题,我们需要考虑以下几个问题:
是否可以通过骨架屏等方式提前渲染核心布局?
主线程是否存在非常耗时的长任务?是否可以进行任务分片、延迟渲染?
是否存在时间复杂度过高的算法?是否存在大量重复计算?
是否重复初始化相同的对象?是否存在内存泄露?
接口速度问题
在列表查询等依赖后台数据展示的页面,接口的响应速度也至关重要。由于我们通过 Node.js 搭建的 BFF 来整合多个服务提供方的接口,因此可能存在以下几个问题:
后端服务提供的接口速度是否响应慢?网关、数据库、索引等服务是否正常?
针对实时性要求较低的数据,是否可以利用缓存服务?
同时调用多方接口时,是否最大化进行并发请求?非必要接口是否可以单独发起请求?
与浏览器脚本一样,是否存在复杂算法、内存泄露等问题代码?
解决方案
带着以上的这些问题,我们开始着手对现有的应用进行一次详细的检查,逐步定位影响性能的关键问题并一一进行解决。
资源加载优化
Webpack 构建问题分析
由于我们的项目通过 Webpack 4.x 构建,因此为了分析资源文件的个数及大小,采用了 Webpack 插件 webpack-bundle-analyzer 对产出的静态资源文件进行了统计,如下图所示(截取了几个体积较大的文件)。
根据统计我们发现了以下几个主要问题:
缓存问题。每次改动任意代码,所有生成的 JS/CSS 等文件的 Hash 值都发生了变化,这意味着每次发布上线,浏览器都需要重新请求全部资源。
文件大小。通过 node_modules 生成的 chunk-vendor 原始大小超过 1.5 M。其中,体积最大的是 ElementUI,超过 650K,其次是 moment.js,体积超过 250K。剩余部分则由 Vue.js、Lodash 等基础类库组成。
重复打包。部分业务模块对应的 chunk 文件原始大小在 500K 左右。其原因是使用到了 d3,echarts 等依赖的模块,直接将它们打包到了对应模块中。而这些第三方库,占整个文件大小的 70% 左右。
资源个数。由 Webpack 自动生成了多个模块间的公共 chunk,大小在几 K 到一百多 K 不等。例如有三个模块 a,b,c,则自动生成的 chunk 包含多种不同的组合 a~b.js,a~c.js,a~b~c.js,请求 a 模块的时候也会同步加载这几个文件。随着模块数量增加,组合也更复杂,无形中也增加了请求的数量。
浏览器加载速度分析
通过浏览器 Network 工具,我们发现服务器缓存、网络传输等对加载速度影响很小,导致慢的几个主要问题如下:
并发数量。通过构建得到的静态资源文件都部署到一个静态域名下面,导致需要排队下载文件。
顺序问题。一些非首次渲染所需要的 JS 文件(如播放器 SDK 、流程图 SDK 等)在页面打开的时候就进行了阻塞加载。
资源构建及部署优化方案
针对以上问题,我们对 Webpack 配置方式做了以下几点改进。
单独部署基础库至 CDN。生产环境将 Vue.js + VueRouter + Vuex + VueCompositionAPI + ElementUI + Lodash 等基础类库通过 webpack.DllPlugin 提前构建为 library.dll.js 并单独部署,同时整个站点中通过 prefetch 提前加载。
单独部署样式主题至 CDN。项目中用到的 ElementUI 组件样式及团队内部开发的 MaterialTheme 主题样式放弃从 NPM 引入 Sass 源码。而是提前构建好 9 种不同颜色的主题,提前部署至 CDN,并通过 prefetch 提前加载。项目中的自定义样式则通过 Sass Mixin 生成不同主题的规则。
将业务代码部署至与基础库不同的域名。提升浏览器并发请求的数量。
将播放器 SDK、流程图 SDK 等非首次渲染必须的 JS 文件通过 defer 等方式进行异步加载,或改为组件初始化时动态请求。
删除 moment.js 等非必须的第三方类库。通过查看项目源码,发现仅几个地方用到了 moment.js 的格式化功能,因此我们选择通过自己实现一个仅几十行的工具函数来替换。此外根据项目实际情况,也可以考虑在项目中引入体积更小的类库,例如 Day.js 等。
优化 Webpack 的 splitChunks 策略。将 d3,echarts 等依赖抽取为单独的 chunk。此外,考虑到不同模块之间自动生成的公共 chunk(类似 a~b~c.js)文件不大,反而增加了请求数量,因此禁用了该项配置。同时,显示地将各模块间公共的部分(项目中统一放在 src/common 目录下)打包至 chunk-common 文件中。
优化构建后文件名中的 Hash。在生产环境改用 contenthash 来命名文件,仅当包含的文件内容发生改变时才会重新生成新的文件名,最大化利用缓存。
经过以上优化,最终构建的 chunk-vendor 大小在 500K 左右,体积大约减小 2/3;新抽取的项目公共文件 chunk-common 大小 300K 左右;各个模块打包的文件大小则在 200K 左右, 体积大约减小 3/5。同时,结合 CDN 部署基础类库,prefetch 预加载及 contenthash 缓存控制等,资源加载的速度大幅度提升。
页面渲染优化
考虑到业务场景及开发成本,新版本的前端应用并没有实现服务器端渲染,存在着较长的白屏时间。而老版本则通过 Java + Velocity 在服务器端完成渲染,两相对比,用户体验相差甚多。
浏览器渲染性能分析
为了解决这个问题,我们通过 Chrome Performance 对页面的渲染性能进行了完整的分析。
由于生产环境代码已经压缩,这里建议在开发环境录制 Profile,可以直接定位到相关源码。录制后的时间线展示参见下面截图。
其中我们需要重点关注的几个维度如下:
Frames:渲染的 FPS 以及不同时间点的渲染结果。
Main:渲染主线程,包括 HTML 解析,JavaScript 执行等任务。
Timings:包括 FP、DCL、FCP、LCP 等指标,以及通过 Performance API 记录的运行时间。Vue.js 2.x 中可以通过 Vue.config.performance = true; 开启组件性能记录。下图的截图展示了 Vue.js 组件的渲染耗时情况。
经过分析,我们发现以下几个主要问题:
路由激活后的首次渲染任务耗时特别长,已经超过了 2 秒。其中,站点导航、侧边栏等就占用了一半以上的时间。
导航组件中,用于判断链接权限的 AuthService.hasURIAuth 方法占用了 80% 的时间。
在通过配置渲染的动态表单页中,核心组件 FormBuilder 渲染时间也在 2 秒左右。
页面渲染整体优化方案
针对以上问题,我们进行了以下几点改进:
通过服务器端渲染骨架屏,包括导航等页面基础布局。从视觉效果上减少用户的心理等待。
减少首屏渲染的组件数量。将初始为隐藏状态的导航二级菜单、站点侧边栏、列表高级搜索弹窗等组件通过 webpack 提取至异步 chunk 中,在用户交互时再异步渲染。
将根据配置进行渲染的动态表单 FormBuilder 手动拆分为多个渲染任务。由于业务场景的复杂性,通常一个表单拥有 80 余个字段。而在 Vue.js 里面,一次数据变化触发的渲染任务是无法直接拆分的。这里我们采取了另一种方式,将表单配置拆分为多段,首次渲染时仅传递第一段配置,然后在后续的渲染周期依次将配置拼接上去。
接口速度优化
BFF 性能分析
由于业务流程复杂,前端会调用多个服务接口,并对数据进行二次处理,因此一直由前端来负责 Java Web 层(BFF)的开发。本次升级为了开发更简便,引入了基于 TypeScript 的 NestJS 框架替换原来 Spring MVC,由 NestJS 封装面向前端的接口给 VueJS 应用。为了定位其中潜在的性能问题,我们做了一些通用的扩展:
为所有封装的接口添加自定义中间件 TimeMiddleware,用于统计接口的整体响应速度。
为 axios 统一添加 interceptor,用于统计 BFF 调用第三方接口的响应速度。
最后,通过日志、Apache JMeter 等工具对核心接口进行分析,我们主要发现以下几个问题:
在同时调用多方服务的接口 B 中,存在不必要的串行。此外,其中一个标签查询服务平均耗时在 700ms 左右,成为影响速度的关键因素。
在获取用户信息的接口 C 中,有 20% 左右的请求耗时在 600ms 左右,而其他的请求仅耗时 50ms。经过定位发现是服务集群中某台服务器跨地区导致。
大部分接口都依赖了一个获取频道列表的基础服务,实时性要求很低,然而每次都是通过接口实时获取,耗时大约 50 ms。
整个应用的日志服务继承了 NestJS 的 logger.service ,它默认是通过 process.stdout 同步输出的。因此日志内容较多时在部分机器上开销也很大,平均耗时 100ms 左右。
BFF 整体优化方案
针对以上问题,我们进行了以下几点改进:
后端同学优化 ES 查询服务,新增多台物理机进行扩容。优化后平均耗时小于 1 秒,速度提升超过 60%。
后端同学为标签查询服务添加缓存机制,优化后平均耗时 200ms 左右,整体提升超过 70%。
移除集群中的跨地区服务器,保证各服务之间尽量在同一个地区、机房。
大化地并行请求,减少请求耗时的关键路径。以其中一个接口为例,优化前平均耗时 1.3 秒,优化后平均耗时仅 700ms,提升 45% 左右。
实时性要求较低的服务通过 Redis 缓存查询结果,例如频道查询服务,平均耗时从 50ms 减少至 15ms,提升 70% 左右。
优化后整体效果展示
资源加载速度展示
通过减少文件大小及个数、缓存、并发、预加载、懒加载等各种优化,获取核心资源整体耗时控制在 200ms 左右。
页面渲染速度展示
通过异步渲染隐藏组件、优化耗时函数、任务分片、骨架屏等方式,让用户尽早看到内容的同时,将首次路由渲染的时间控制在 1 秒以内,结合浏览器自身的优化,在电脑网速及性能正常的情况下,已经感知不到白屏的存在。
接口相应速度展示
通过扩容、缓存、并发、优化耗时函数等方式,我们将核心的几个查询接口的速度也控制在了 1 秒左右。
优化前后核心数据对比
后记
前端的性能优化涉及到方方面面,每一个环节其实都有优化的空间。这次实践,我们针对项目的实际场景,主要从资源加载、渲染性能和接口速度三个方面来分析并解决问题,一步一步提升页面的打开速度,也为用户带来了更好的使用体验。当然,优化无止境,希望本文能起到抛砖引玉的作用,感兴趣的同学可以留言讨论。
原文链接:优化无止境,爱奇艺中后台 Web 应用性能优化实践
评论 (4 条评论)