从龟速 11s 到闪电 1s,详解前端性能优化之首屏加载
全文共6511字/词,阅读大概需要13分钟,太长不看党请直接移步👉「开始优化」部分直接查看优化手段
背景
前段时间公司服务器网络波动,网站访问变慢,一些性能问题也随之暴露了出来。纷纷反馈在这样的弱网条件下,访问新项目时,加载了近1分钟都没加载出来,而访问其他页面顶多也就30-40s。
在网络恢复后,尝试访问了下页面,无缓存首次打开需要等待近 11s 的时间,最大的资源达到了3.7M...
在对项目做了一些优化处理后,再次无缓存打开可以发现网页几乎是秒开,平均耗时在 1s 以内
在这里总结记录一下,基本上都是一些常规可复制的优化手段,希望能为同样想优化网页性能的你提供思路~
优化效果
Network
Slow3G条件下22-25s加载完成
lighthouse
hiper
关于性能优化
在开始之前,我们需要明白一个原则: 性能优化的最终目的是提升用户体验 。 简而言之就是让用户感觉这个网站很「快」(至少不慢hh),这里的「快」有两种,一种是「真的快」一种是「觉得快」
- 「真的快」:可以客观衡量的指标,像网页访问时间、交互响应时间、跳转页面时间
- 「觉得快」:用户主观感知的性能,通过视觉引导等手段转移用户对等待时间的关注
做好这两方面都能提升用户对网站的性能评价。
权衡取舍
另外就是软件工程没有银弹,一种优化方案可能适用于大多数项目,但是某些特殊情况下很可能会起反效果。
举个🌰,由于浏览器有单域名下并发请求限制,通常我们会将依赖统一打成一个vendor包(vue-cli默认策略),减少首屏请求数,且依赖不变动的情况下文件指纹不变,可以有效利用304缓存。在依赖不多的情况这么处理确实有助于提升加载速度,但一旦依赖多起来,vendor就会特别的大,在弱网条件下,会严重拖慢页面显示。这显然不是我们想要的,所以我们根据情况会对vendor进行拆分,比如拆分到C DN, 或者直接拆分到页面中
因此,我们在做性能优化过程中,必须根据最终能给 用户体验带来的提升 权衡后做出适合当前项目的选择
指标和目标
目标会影响我们在过程中的决策 指标则用来度量我们的目标
目标
首先我们需要确定目标,根据场景和项目复杂度不同,制定的目标也不同,比如希望比竞品快20%,或者符合标准的"2-5-10"原则等等
这里我定下的目标是
- 正常网速下,2s内加载完成
- 弱网下,30s内加载完成
指标
关于指标这块,简单介绍下常见指标
- FCP(First Contentful Paint): 白屏时间 (第一个文本绘制时间)
- Speed Index: 首屏时间
- TTI(Time To Interactive): 第一次可交互的时间
- lighthouse score(performance):Chrome浏览器审查工具性能评分(也可以npm install -g lighthouse,编程式调用)
调试工具
通过性能调试工具可以直观便捷地获取这些指标,比如Newwork、k6、hiper、Lighthouse...。具体可以看我关于性能调试工具的另一篇文章
瓶颈分析
Network分析
优化前Network
从Network上我们发现主要问题在3.2M的chunk-vendor.js上
- 体积太大,下载慢
- 阻塞了其他资源下载
Lighthouse分析
优化前Lighthouse
Performance分析
由于本次不涉及到应用内场景性能优化,Performance分析跳过...
dist目录分析
- 整体体积太大,近5M
- 出现了若干不应出现的静态资源,比如页面上没引用到SVG图标、应该被内联的小图等
- 部分图片资源较大,最大的达到仅400KB
Webpack Bundle分析
优化前Bundle
从webpack bundle可以看出,问题着实不少
- 未剔除项目模板用到的冗余依赖,比如g2、quill、wangEditor、mock等
- 一些没用到的Ant-design组件由于全局注册也一并打包了进去
- 项目中只用到几个Ant-Design/icons,但却被全量引入
- moment和moment-timezone重复,且体积较大
- core-js体积较大
- 打包策略不合理,导致chunk-vendor太大
开始优化
🛫🛫🛫
体积优化
⚡排查并移除冗余依赖、静态资源
内容(点击展开/收起)
- 移除项目模板冗余依赖
- 将public的静态资源移入assets。静态资源应该放在assets下,public只会单纯的复制到dist,应该放置不经webpack处理的文件,比如不兼容webpack的库,需要指定文件名的文件等等
before:4.96M after:4.12M
⚡构建时压缩图片
内容(点击展开/收起)
每次使用在线服务手动压缩较为麻烦,可以直接在构建流程中加入压缩图片
使用image-webpack-loader
// install
npm i image-webpack-loader -D
// vue.config.js
chainWebpack: (config) => {
if (isProd) {
// 图片压缩处理
const imgRule = config.module.rule('images')
imgRule
.test(/\.(png|jpe?g|gif|webp)(\?.*)?$/)
.use('image-webpack-loader')
.loader('image-webpack-loader')
.options({ bypassOnDebug: true })
.end()
-
install或build时如果出现imagemin库下载失败,可以尝试 换源、配置github hosts、install时添加
--user=root
解决 - 由于在图片下载后已经手动用在线工具压缩过,这部分提升不大
before:4.12M after:4.00M
⚡使用webP图片
内容(点击展开/收起)
webP是谷歌推出的新图片格式(2010),同等质量下体积拳打png脚踢jpg,目前 兼容性[1] 还算可以,就苹果家的表现不太理想
转换为webP图片
可以手动,也可以加入构建自动化生成。
- 手动,可以使用 webP-converter 、智图等工具,但建议使用官方webP-converter,除了便捷性,同质量下体积各方面均优于智图。
./cwebp -q 75 login_plane_2.png -o login_plane_2.webp
- 自动化生成,可以使用image-min-webp或其他webpack插件
页面中使用(兼容低版本)
-
HTML中使用,
<picture>
标签兼容
<picture>
<source srcset="hehe.webp" type="image/webp">
<img src="hehe.png" alt="hehe>
</picture>
- CSS中使用,需要配合JS做判断
// main.js
window.addEventListener('DOMContentLoaded', () => {
const isSupportWebP = document.createElement('canvas')
.toDataURL('image/webp')
.indexOf('data:image/webp') === 0
document.documentElement.classList.add(isSupportWebP ? '' : '.no-support-webp');
// css
.support-webp .bg{
background-image: url("hehe.webp");
.no-support-webp .bg {
background-image: url("hehe.png");
- 请务必使用原图进行webp转换,否则会影响体积
- 项目大图不多,最大400KB的图片转换后只有48.9KB
⚡优化SVG图标
内容(点击展开/收起)
这一步我们来优化部分冗余的旧SVG图标被打包进去的情况,一般项目中SVG使用方式都是在iconfont生成JS然后引入。这种做法
- 不直观,每次都得去iconfont复制名称使用
- 每次增删改图标需要重新替换整个JS
- 不能 按需加载 ,没使用到的也会一起打包,特别是UI换图标时一般不会将旧图标删除....
- 添加自定义SVG不友善,必须上传iconfont添加到一起再下载
更优的SVG玩法
-
新增/修改图标
- 在iconfont下载UI上传或者其他地方找的任意SVG图标放入icons/svg/下
- 页面中使用全局svg组件,传入复制下SVG的文件名即可
-
删除
- 只需要去掉使用的地方,然后删除对应图标即可
要实现上述效果,只需要
- 引入svg-sprite-loader
// install
npm i svg-sprite-loader -D
// vue.config.js
chainWebpack: (config) => {
// SVG处理
config.module
.rule('svg')
.exclude.add(resolve('src/icons/svg'))
.end()
config.module
.rule('icons')
.test(/\.svg$/)
.include.add(resolve('src/icons/svg'))
.end()
.use('svg-sprite-loader')
.loader('svg-sprite-loader')
.options({
symbolId: 'icon-[name]'
.end()
- 创建src/icons/svg并将图标放进去,并通过webpack的require.context自动导入
// src/icons/index.js
const req = require.context('./svg', false, /\.svg$/)
const requireAll = requireContext => requireContext.keys().map(requireContext)
requireAll(req)
// main.js
import '@/icons'
- 创建全局组件ca-svg
// src/icons/index.js
import Vue from 'vue'
import CaSVG from '@/components/ca-svg'
Vue.component('ca-svg', CaSVG)
// src/components/ca-svg.vue
<template>
<svg :class="svgClass" aria-hidden="true" v-on="$listeners" :style="svgStyle">
<use :xlink:href="iconName" />
</template>
// name属性为必须字段,其他size或color可以自由定制
压缩优化
SVG通常会有一些冗余信息导致影响体积,这里我们可以使用svgo-loader来进一步压缩
// install
npm i svgo-loader -D
// vue.config.js
// 接上面svg的配置
.end()
.use('svgo-loader')
.loader('svgo-loader')
.end()
⚡优化Ant-design-vue体积
内容(点击展开/收起)
可以看到这部分在chunk-vendor中占的比例不小
按需引入
这部分实际上已经是做了处理的了,具体操作参考ant-design-vue文档,按说明做没啥大坑,效果也符合预期。
快速上手 \- Ant Design Vue[2]
删除冗余组件
部分组件是不经常用的,但却使用了Vue.use()全局引入了。这里去掉不常用和没用到的全局引入,改为页面内import()引入
⚡优化Ant-design-icon体积
内容(点击展开/收起)
这一部分,由于我们在项目中只使用了几个Ant内置图标,不可能有530+KB。根据Ant文档的描述是由于其将ICON全量引入的关系导致的,说法是当前用法如果按需加载的话无法确定使用者会不会在运行时改变icon,比如配置的ICON。
重定向到本地来控制
这个问题,在React版的Ant-Design是已经是做了处理的了(写法上有所调整),但在Ant-Design-Vue-1.x中仍然没有官方解决方案。目前了解到的有两种方案
- 使用 webpack-ant-icon-loader[3] (异步加载)
-
重定向到本地文件来控制 (推荐),使用alia将将
@ant-design/icons/lib/dist
指向项目中的antd-icon.js
,然后在antd-icon
中按需导出即可
// alias配置
resolve: {
alias: {
'@ant-design/icons/lib/dist$': path.resolve(__dirname, './src/icons/antd-icon.js')
// src/icons/antd-icon.js
export { default as LoadingOutline } from '@ant-design/icons/lib/outline/LoadingOutline'
- 注意项目中和Ant组件中使用的ICON都要导出
- 优化后从530K+到30K+
⚡优化moment、moment-timezone体积
内容(点击展开/收起)
这两个包300K + 160K,加起来有460K,也是占的比较多的一项
不打包moment时区文件
这里使用内置的IgnorePlugin即可做到
// webpack plugins
plugins: [
// Ignore all locale files of moment.js
new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
移除moment-timezone
moment-timezone包含了moment,项目中只有一处地方使用到,用来获取东八区的当前时间,moment是可以做到的,不需要额外引入moment-timezone
// old
moment.tz('Asia/Shanghai').format('YYYYMMDDHHmmss')
// new
moment.utcOffset(480).format('YYYYMMDDHHmmss')
使用dayjs替代moment
dayjs实现了moment的大多数功能,Api基本一致,压缩后体积却不到2k,是优秀的替代方案,且多数情况下,dayjs可以完美的替代moment(按需引入插件)
但是这里遇到了使用Ant-Design的第二个坑,Ant-Design实现Date-Picker时使用了moment,所以在项目中不使用moment也不管用,一样会打包进来....
这个问题,在React版的Ant-Design又已经是做了处理的了,允许用户选择dayjs或moment。同样的,Ant-Design-Vue仍没有跟进...
所幸,dayjs作者提供了一个插件,可以将Ant-Design-Vue的moment替换成dayjs👍 虽然文档中只说Ant-Design-React可以用,但实际上在issue可以看到它也适用于antdV,不过需要调整一些配置
使用antd-dayjs-webpack-plugin[4]
// vue.config.js
const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin')
configureWebpack: {
plugins: [
new AntdDayjsWebpackPlugin({ preset: 'antdv3', replaceMoment: true })
replaceMoment参数是用webpack alias来重定向moment到dayjs,由于项目中多处使用moment,这样做可以继续使用moment实际上调用的dayjs,实现无感知替换👍
- 替换成dayjs后,一些功能需要通过引入插件来保持一致,比如utc、updateLocale
- 替换成dayjs后,一些针对moment的优化需要改成dayjs或去掉
⚡优化core-js体积
内容(点击展开/收起)
core-js实际上就是浏览器新API的polyfill,项目是PC端,所以主要是为了兼容IE...
调整.browserslistrc
- 指定项目需要兼容的版本,让babel和auto-prefix少做点兼容性工作,比如移动端不用兼容IE、iOS6.0以下等等
调整useBuiltIns
项目中默认是
useBuiltIns: 'entry'
将所有polyfill都引入了,导致包比较大。我们可以使用
useBuiltIns: 'entry'
调整下策略,按需引入,项目中没使用到的API就不做polyfill处理了
// babel.config.js
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
'@babel/preset-env',
'useBuiltIns': 'usage', // entry,usage
'corejs': 3
plugins
btw,这里更优的做法应该是使用动态polyfill,让服务器根据UA判断是否要返回polyfill
before:4.96M after:2.96M
传输优化
⚡优化分包策略
内容(点击展开/收起)
vue-cli3的默认优化是将所有npm依赖都打进chunk-vendor,但这种做法在依赖多的情况下导致chunk-vendor过大
optimization: isProd ? {
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity, // 默认为3,调整为允许无限入口资源
minSize: 20000, // 20K以下的依赖不做拆分
cacheGroups: {
vendors: {
// 拆分依赖,避免单文件过大拖慢页面展示
// 得益于HTTP2多路复用,不用太担心资源请求太多的问题
name(module) {
// 拆包
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]
// 进一步将Ant组件拆分出来,请根据情况来
// const packageName = module.context.match(/[\\/]node_modules[\\/](?:ant-design-vue[\\/]es[\\/])?(.*?)([\\/]|$)/)[1]
return `npm.${packageName.replace('@', '')}` // 部分服务器不允许URL带@
test: /[\\/]node_modules[\\/]/,
priority: -10,
chunks: 'initial'
runtimeChunk: { name: entrypoint => `runtime-${entrypoint.name}` }
} : {}
- Tips: vue inspect > output.js --mode production 可以查看最终配置
- 分包这块需要根据实际情况做对应处理,才能取得比较好的效果,总之多看文档多试就对了
⚡优化路由懒加载
内容(点击展开/收起)
SPA中一个很重要的提速手段就是路由懒加载,当打开页面时才去加载对应文件,我们利用Vue的异步组件和webpack的代码分割(
import()
)就可以轻松实现懒加载了。
但当路由过多时,请合理地用webpack的魔法注释对路由进行分组,太多的chunk会影响构建时的速度
{
path: 'register',
name: 'register',
component: () => import(/* webpackChunkName: "user" */ '@/views/user/register'),
请只在生产时懒加载,否则路由多起来后,开发时的构建速度感人
⚡开启HTTP2
内容(点击展开/收起)
HTTP2是HTTP协议的第二个版本,相较于HTTP1 速度更快、延迟更低,功能更多。目前来看 兼容性[5] 方面也算过得去,在国内有超过50%的覆盖率。
通常浏览器在传输时并发请求数是有限制的,超过限制的请求需要排队,以往我们通过域名分片、资源合并来避开这一限制,而使用HTTP2协议后,其可以在一个TCP连接分帧处理多个请求(多路复用),不受此限制。(其余的头部压缩等等也带来了一定性能提升)
如果网站支持HTTPS,请一并开启HTTP2,成本低收益高,对于请求多的页面提升很大,尤其是在网速不佳时
Nginx开启HTTP2(>V1.95)
- 调整Nginx配置
// nginx.conf
listen 443 http2;
- 重启Nginx
nginx -s stop && nginx
- 验证效果
HTTP2开启后
多路复用避开了资源并发限制,但资源太多的情况,也会造成浏览器性能损失(Chrome进程间通信与资源数量相关)
⚡Gzip压缩传输
内容(点击展开/收起)
Gzip压缩是一种强力压缩手段,针对文本文件时通常能减少2/3的体积。
HTTP协议中用头部字段
Accept-Encoding
和
Content-Encoding
对「采用何种编码格式传输正文」进行了协定,请求头的
Accept-Encoding
会列出客户端支持的编码格式。当响应头的
Content-Encoding
指定了gzip时,浏览器则会进行对应解压
一般浏览器都支持gzip,所以
Accept-Encoding
也会自动带上
gzip
,所以我们需要让资源服务器在
Content-Encoding
指定gzip,并返回gzip文件
Nginx配置Gzip
#开启和关闭gzip模式
gzip on;
#gizp压缩起点,文件大于1k才进行压缩
gzip_min_length 1k;
# gzip 压缩级别,1-9,数字越大压缩的越好,也越占用CPU时间
gzip_comp_level 6;
# 进行压缩的文件类型。
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript ;
# nginx对于静态文件的处理模块,开启后会寻找以.gz结尾的文件,直接返回,不会占用cpu进行压缩,如果找不到则不进行压缩
gzip_static on
# 是否在http header中添加Vary: Accept-Encoding,建议开启
gzip_vary on;
# 设置gzip压缩针对的HTTP协议版本
gzip_http_version 1.1;
构建时生成gzip文件
虽然上面配置后Nginx已经会在响应请求时进行压缩并返回Gzip了,但是压缩操作本身是会占用服务器的CPU和时间的,压缩等级越高开销越大,所以我们通常会一并上传gzip文件,让服务器直接返回压缩后文件
// vue.config.js
const CompressionPlugin = require('compression-webpack-plugin')
// gzip压缩处理
chainWebpack: (config) => {
if(isProd) {
config.plugin('compression-webpack-plugin')
.use(new CompressionPlugin({
test: /\.js$|\.html$|\.css$/, // 匹配文件名
threshold: 10240, // 对超过10k的数据压缩
deleteOriginalAssets: false // 不删除源文件