写点什么

优化无止境,爱奇艺中后台 Web 应用性能优化实践

发布于: 2021 年 01 月 09 日
优化无止境,爱奇艺中后台 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 生成不同主题的规则。


<link  href="//static.iqiyi.com/lego/theme/element-ui/1.0.0/css/cyan.css"  rel="prefetch"/><link  href="//static.iqiyi.com/lego/theme/element-material/2.0.0/css/cyan.css"  rel="prefetch"/>
复制代码


  • 将业务代码部署至与基础库不同的域名。提升浏览器并发请求的数量。


  • 将播放器 SDK、流程图 SDK 等非首次渲染必须的 JS 文件通过 defer 等方式进行异步加载,或改为组件初始化时动态请求。


  • 删除 moment.js 等非必须的第三方类库。通过查看项目源码,发现仅几个地方用到了 moment.js 的格式化功能,因此我们选择通过自己实现一个仅几十行的工具函数来替换。此外根据项目实际情况,也可以考虑在项目中引入体积更小的类库,例如 Day.js 等。


  • 优化 Webpack 的 splitChunks 策略。将 d3,echarts 等依赖抽取为单独的 chunk。此外,考虑到不同模块之间自动生成的公共 chunk(类似 a~b~c.js)文件不大,反而增加了请求数量,因此禁用了该项配置。同时,显示地将各模块间公共的部分(项目中统一放在 src/common 目录下)打包至 chunk-common 文件中。


// webpack config{  optimization: {    splitChunks: {      cacheGroups: {        // 禁用默认拆分的 chunk        default: false,        // 显示抽取项目公共 chunk        common: {          name: 'chunk-common',          test: /src[\\/]common/,          chunks: 'all'        },        // 抽取 d3/echarts 等第三方类库        d3: {          name: 'chunk-d3',          test: /[\\/]node_modules[\\/](d3|dagre|graphlib)/,          priority: 100,          chunks: 'all'        },        echarts: {          name: 'chunk-echarts',          test: /[\\/]node_modules[\\/](echarts|zrender)/,          priority: 110,          chunks: 'all'        }      }    }  },}
复制代码


  • 优化构建后文件名中的 Hash。在生产环境改用 contenthash 来命名文件,仅当包含的文件内容发生改变时才会重新生成新的文件名,最大化利用缓存。


// webpack config{  output: {    filename: 'js/[name].[contenthash].js',    chunkFilename: 'js/[name].[contenthash].js'  }}
复制代码


经过以上优化,最终构建的 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 中,在用户交互时再异步渲染。


// AppLayout.vue{  components: {    AppDrawer: () =>      import(        /* webpackChunkName: 'chunk-async-common' */        './AppDrawer'      ),    AppHeader  },}
复制代码


  • 将根据配置进行渲染的动态表单 FormBuilder 手动拆分为多个渲染任务。由于业务场景的复杂性,通常一个表单拥有 80 余个字段。而在 Vue.js 里面,一次数据变化触发的渲染任务是无法直接拆分的。这里我们采取了另一种方式,将表单配置拆分为多段,首次渲染时仅传递第一段配置,然后在后续的渲染周期依次将配置拼接上去。


<template>    <form-builder :config="formConfig"></form-builder></template>
复制代码


{  created() {    this.getFormConfig().then(() => {      this.startWork();    });  },  methods: {    startWork() {      const work = () => {        // 任务调度器        return scheduler.next(() => {          // 逐步拼接表单配置          this.formConfig = this.concatNextFormConfig();
if (!scheduler.done()) { // 循环执行任务 work(); } }); }; // 启动首次任务 work(); } }}
复制代码


接口速度优化

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 应用性能优化实践


用户头像

科技赋能娱乐,“码”出快乐生活 2020.02.13 加入

爱奇艺技术产品团队秉持高效、开放、创新的理念,分享前沿技术,传达爱奇艺生态理念及技术进展。

评论 (4 条评论)

发布
用户头像
收获颇多
2021 年 01 月 22 日 11:57
回复
用户头像
太赞了!
2021 年 01 月 12 日 10:08
回复
用户头像
总结很棒,学习了
2021 年 01 月 11 日 11:30
回复
用户头像
学习了。赞👍
2021 年 01 月 11 日 10:55
回复
没有更多了
优化无止境,爱奇艺中后台 Web 应用性能优化实践