从龟速 11s 到闪电 1s,详解前端性能优化之首屏加载
背景
前段时间公司服务器网络波动,网站访问变慢,一些性能问题也随之暴露了出来。纷纷反馈在这样的弱网条件下,访问新项目时,加载了近1分钟都没加载出来,而访问其他页面顶多也就30-40s。
在网络恢复后,尝试访问了下页面,无缓存首次打开需要等待近11s的时间,最大的资源达到了3.7M...
在对项目做了一些优化处理后,再次无缓存打开可以发现网页几乎是秒开,平均耗时在1s以内
在这里总结记录一下,基本上都是一些常规可复制的优化手段,希望能为同样想优化网页性能的你提供思路~
优化效果
Network
Slow3G条件下22-25s加载完成
lighthouse
hiper
关于性能优化
在开始之前,我们需要明白一个原则:性能优化的最终目的是提升用户体验。
简而言之就是让用户感觉这个网站很「快」(至少不慢hh),这里的「快」有两种,一种是「真的快」一种是「觉得快」
「真的快」:可以客观衡量的指标,像网页访问时间、交互响应时间、跳转页面时间 「觉得快」:用户主观感知的性能,通过视觉引导等手段转移用户对等待时间的关注
做好这两方面都能提升用户对网站的性能评价。
权衡取舍
另外就是软件工程没有银弹,一种优化方案可能适用于大多数项目,但是某些特殊情况下很可能会起反效果。
举个🌰,由于浏览器有单域名下并发请求限制,通常我们会将依赖统一打成一个vendor包(vue-cli默认策略),减少首屏请求数,且依赖不变动的情况下文件指纹不变,可以有效利用304缓存。在依赖不多的情况这么处理确实有助于提升加载速度,但一旦依赖多起来,vendor就会特别的大,在弱网条件下,会严重拖慢页面显示。这显然不是我们想要的,所以我们根据情况会对vendor进行拆分,比如拆分到CDN,或者直接拆分到页面中
因此,我们在做性能优化过程中,必须根据最终能给用户体验带来的提升权衡后做出适合当前项目的选择
指标和目标
目标会影响我们在过程中的决策
指标则用来度量我们的目标
目标
首先我们需要确定目标,根据场景和项目复杂度不同,制定的目标也不同,比如希望比竞品快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中使用, 标签兼容
复制代码
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
...
// 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 // 不删除源文件
}))
}
}
复制代码
插件的默认压缩等级是9,最高级的压缩 图片文件不建议使用gzip压缩,效果较差
⚡Prefetch、Preload
内容(点击展开/收起)
标签的rel属性的两个可选值。
Prefetch,预请求,是为了提示浏览器,用户未来的浏览有可能需要加载目标资源,所以浏览器有可能通过事先获取和缓存对应资源,优化用户体验。
Preload,预加载,表示用户十分有可能需要在当前浏览中加载目标资源,所以浏览器必须预先获取和缓存对应资源。
Prefetch、Preload可以在某些场景下可以有效优化用户体验。举些场景
首屏字体、大图加载,CSS中引入字体需要等CSS解析后才会加载,这之前浏览器会使用默认字体,当加载后会替换为自定义字体,导致 字体样式闪动
,而我们使用Preload
提前加载字体后这种情况就好很多了,大图也是如此优惠券的背景图加载,同样CSS中url引用在DOM没挂载之前是不会加载图片的,进入卡包页/收银台时可以提前使用Prefetch加载,这样用户在进行优惠券页就可以立马看到加载完成的图片了
Vue-Cli3默认会使用preload-webpack-plugin对chunk资源做preload、prefetch处理,入口文件preload,路由chunk则是prefetch。
一般来说不需要做特别处理,如果判断不需要或者需要调整在vue.config.js
中配置即可
理论上prefetch不会影响加载速度,但实际测试中,是有轻微影响的,不过这个见仁见智,我认为总体体验上还是有所提升的, 类似字体文件这种隐藏在脚本、样式中的首屏关键资源,建议使用preload 移动端流量访问时慎用
⚡托管至OSS + CDN加速
内容(点击展开/收起)
当应对一些弱网地区时,OSS + CDN无疑是很强力的提速手段。
OSS,对象存储
海量,安全,低成本,高可靠的云存储服务。可以通过简单的REST接口,在任何时间、任何地点上传和下载数据,也可以使用WEB页面对数据进行管理。
OSS的特点:
稳定,服务可用性高,多重备份保障数据安全 安全,多层次安全防护,防DDoS 大规模,高性能,从容应对高并发
另外,OSS还提供一些方便的服务
图片处理,支持压缩、裁剪、水印、格式转换等 传输加速,优化传输链路和协议策略实现高速传输
这里推荐直接购买阿里家的OSS,OSS虽然也有传输加速服务,但对于静态热点文件的下载加速场景还是需要CDN加速
CDN,内容分发网络
CDN加速原理是把提供的域名作为源站,将源内容缓存到边缘节点。当客户读取数据时,会从最适合的节点(一般来说就近获取)获取缓存文件,以提升下载速度。
由于没申请到资源,项目并没有上OSS+CDN。但如果有条件还是建议上,提升很大
感知性能优化
😎白屏时的loading动画
内容(点击展开/收起)
首屏优化,在JS没解析执行前,让用户能看到Loading动画,减轻等待焦虑。通常会在index.html上写简单的CSS动画,直到Vue挂载后替换挂载节点的内容,但这种做法实测也会出现短暂的白屏,建议手动控制CSS动画关闭
😎首屏骨架加载
内容(点击展开/收起)
首屏优化,APP内常见的加载时各部分灰色色块。针对骨架屏页的自动化生成,业界已有不少解决方案。
感知优化的一些补充
首屏以外的一些场景优化,更多相关内容比如图片懒加载、组件懒加载等 后续文章会做介绍
😎渐进加载图片
内容(点击展开/收起)
一般来说,图片加载有两种方式,一种是自上而下扫描,一种则是原图的模糊展示,然后逐渐/加载完清晰。前者在网速差的时候用户体验较差,后者的渐进/交错式加载则能减轻用户的等待焦虑,带来更好的体验
渐进/交错格式图片
浏览器本身支持这种图片的模糊到清晰的扫描加载方式,只需要将处理好资源即可
UI生成,PS导出选择「连续」,Sketch导出选择「渐进/交错」 转换,使用第三方库处理
渐进加载图片
先加载小图,模糊化渲染,图片加载完成后替换为原图,最典型的例子就是Medium,模糊化可以用filter或者canvas处理
加载占位图
先加载全局通用loading图或者用CSS填充色块,图片加载完成后替换为原图。简单粗暴,在弱网条件下很有用
几种方式可以同时搭配使用 渐进/交错格式图片会占用一定CPU和内存,酌情使用
😎路由跳转Loading动画
内容(点击展开/收起)
弱网优化手段,用了懒加载后用户如果在弱网条件下点击下一个页面在下个页面加载完成前页面内容不可用,用户会理解为卡顿。在VueRouter的路由守卫中处理即可
结尾
本文只介绍了首屏加载场景下的性能优化,实际上性能优化远不止这些内容,SPA的加载性能指标采集光靠Lighthouse、slow 3G模拟真的可信吗?除了加载场景外的其他方面呢?构建速度、操作流畅性...
业务
性能优化影响的,不仅是用户体验,还影响了转化率、搜索引擎排名,这些因素都会对最终的流量、销量等收入造成影响
来自Google的数据表明,一个有10条数据0.4秒能加载完的页面,变成30条数据0.9秒加载完之后,流量和广告收入下降90%。
Google Map 首页文件大小从100KB减小到70-80KB后,流量在第一周涨了10%,接下来的三周涨了25%。
亚马逊的数据表明:加载时间增加100毫秒,销量就下降1%。
立场不同,看问题角度也会变化,比如对老板来说最终目的其实是搞钱hh,用户体验这些花里胡哨的,别人不一定懂。
用户体验 × money √
所以如果必要,请在过程前后做好性能收益的数据监控和分析,在性能优化和产品指标之间建立正向联系,方便自上而下的推动技术方案的执行。这才是你说服上司或领导投入成本到性能优化上的重要依据
个人提升
性能优化算是老生常谈的话题了,但部分人在面对怎么做性能优化的问题时,仅仅只是罗列出各种常见优化手段,更有深度的答案应该是遇到什么性能问题,针对这个问题围绕某些性能指标采取了什么手段,手段是否带来了其他问题,怎么权衡,最终达到了什么样的效果。
参考
2018 前端性能优化清单[6] 我是如何让公司后台管理系统焕然一新的\(上\)-性能优化[7] ...
作者:夜雪暮歌
https://juejin.cn/post/6949896020788690958