优化无止境,爱奇艺中后台 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
},
}

  • 优化耗时的 JavaScript 函数。这一步需要结合实际代码实现进行优化,以上面提到的权限判断方法 AuthService.hasURIAuth 为例,其中最突出的问题就是循环内函数重复执行以及正则表达式重复创建。我们通过 Memoization 的方式为耗时函数添加记忆化功能,当参数相同时直接返回记忆值;通过 Cache 将正则表达式实例缓存起来以便重复使用。

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

<form-builder :config="formConfig">
</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 等工具对核心接口进行分析,我们主要发现以下几个问题:

  • 在对千万量级的索引数据进行分页查询的接口 A 中,当前 ES 的查询速度不理想,平均耗时在 2.6 秒左右。

  • 在同时调用多方服务的接口 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% 左右。
  • 生产环境的日志取消输出到 process.stdout,通过 winston 等日志框架将其异步写入至指定文件中。


优化后整体效果展示


资源加载速度展示

通过减少文件大小及个数、缓存、并发、预加载、懒加载等各种优化,获取核心资源整体耗时控制在 200ms 左右。


首次加载主题样式与切换主题示例(Prefetch)

异步加载路由及组件示例(Prefetch)


页面渲染速度展示

通过异步渲染隐藏组件、优化耗时函数、任务分片、骨架屏等方式,让用户尽早看到内容的同时,将首次路由渲染的时间控制在 1 秒以内,结合浏览器自身的优化,在电脑网速及性能正常的情况下,已经感知不到白屏的存在。


接口相应速度展示

通过扩容、缓存、并发、优化耗时函数等方式,我们将核心的几个查询接口的速度也控制在了 1 秒左右。


优化前后核心数据对比

优化环节 优化前 优化后
首次下载脚本资源 实际下载 JS 文件 7 个,整体大小 3.5M 实际下载 JS 文件 4 个,整体大小 1.8M
路由首次渲染时间 平均 2.66 秒 平均 790ms
索引分页查询响应时间 平均 2.60 秒 平均 1.03 秒

后记

前端的性能优化涉及到方方面面,每一个环节其实都有优化的空间。这次实践,我们针对项目的实际场景,主要从资源加载、渲染性能和接口速度三个方面来分析并解决问题,一步一步提升页面的打开速度,也为用户带来了更好的使用体验。当然,优化无止境,希望本文能起到抛砖引玉的作用,感兴趣的同学可以留言讨论。