性能优化作为前端领域一个老生常谈的话题,经常会在不同的开发阶段遇到这个问题,很多用户会抱怨道:"怎么网页加载这么慢啊!!",这个时候可能就需要对网页进行加载优化了,当然性能优化是一个比较笼统的概念,包含了首屏加载优化以及交互优化等等,本文我们主要针对首屏加载优化谈谈如何进行性能优化。
网页加载过程
经典面试题:"从输入一个URL到网页加载完成的过程发生了什么?",理解这个流程可以帮助我们更好的理解性能优化的基本逻辑。以单页面应用加载流程为例:
单页面应用由于HTML文件中只有根节点供后续挂载,所以首次渲染(FCP)也是空白界面,主要加载时长为静态资源的加载解析执行。所以主要解决方案就是围绕这块加载时长来进行优化。
优化方案
网络相关
CDN
减少静态资源加载时长首选方案就是CDN(内容分发网络),由于CDN的分发特性,利用与用户最近服务器建立连接,减少了通信传输的时间。
接入CDN需要在构建的CI流程增加上传CDN的流程,另外需要修改项目的构建配置,以 vue-cli
举例:
js
const IS_PROD = process.env.NODE_ENV === 'production'
module.exports = {
publicPath: IS_PROD
? `https://example.alicdn.cn/`
: '/',
}
DNS解析
主域名以及链接的各种静态资源域名都需要进行域名解析,可以使用 dns-prefetch
的方式提前解析,减少请求时长,这个方式只适合对 HTML 文件中的静态资源链接起作用,因为主域名在加载之前解析已处理。
html
<link rel="dns-prefetch" href="https://www.a.com" />
在目前的一些主流浏览器中均已支持 pre-connect
属性,这个属性相比于 dns-prefetch
不仅可以提前解析DNS,同时还会与对应域名提前建立HTTP链接。
html
<link rel="pre-connect" href="https://www.a.com" />
这里需要注意pre-connect
属性在页面具有较多的第三方域名时,仅对主要域名设置即可,比如CDN的域名,建立链接的域名过多,可能会造成服务占用导致加载速度变慢。
HTTP2.0
HTTP的通信协议建议使用HTTP2.0,对比HTTP1.1协议有两个优点:
- 通过二进制的分帧层作为通信层,服务器使用信息流将对应的数据帧发送到客户端,数据帧在另一端重新组装这些帧,所以可以保证同一个连接多个请求使用,请求之间互不干扰,也就是多路复用。
- 请求头压缩,由于HTTP1.1协议传输都会携带一组请求头,以纯文本的形式发送,当请求头中包含cookie等长文本时会导致体积较大,2.0使用HPACK压缩请求头,减少了请求体积,提升了传输性能。
域名发散
如果某些场景下请求的协议为HTTP1.1,可以使用域名发散的方式提高传输的效率,本质上是因为浏览器为了避免DDOS攻击,对同一个域名下的请求并发数做了限制,比如Chorme的最大并发数为6个。
所有如果有大量的并发请求,可以将请求分散到不同的域名下,提升并发的数量从而加快传输速度,由于域名存在DNS解析的过程,域名过多会导致性能下降,所以根据实际的使用情况添加对应的域名数量就可以。
HTTP压缩
开启HTTP压缩可以减少传输的体积,提升传输速度。目前主流的浏览器在请求资源请求头都会带上 Accpect-Encoding
字段:
makefile
Accept-Encoding: gzip, compress, br
资源服务器也需要在返回头中增加资源的压缩方式:
css
Content-Encoding: br
大部分的CDN托管平台默认值支持HTTP压缩,如果适配的浏览器支持 Brotil
,优先使用这种压缩算法,相对比之下br的压缩效率是最高的。目前浏览器对于压缩方式兼容性如下:
zstd压缩率以及解压缩的速度都略高于br,不过目前还在浏览器的实验性阶段,暂时无法使用
同时项目资源在打包构建时也要对资源进行压缩然后上传CDN,考虑到浏览器的兼容性,一般我们压缩两种格式 br
和 gz
,安装 compression-webpack-plugin
插件处理即可:
js
const CompressionPlugin = require('compression-webpack-plugin')
module.exports = {
plugins: [
new CompressionPlugin({
filename: '[path][base].gz',
algorithm: 'gzip',
test: /.js$|.css$|.html$/,
threshold: 10240,
minRatio: 0.8,
}),
new CompressionPlugin({
filename: '[path][base].br',
algorithm: 'brotliCompress',
test: /.(js|css|html|svg)$/,
compressionOptions: {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
},
},
threshold: 10240,
minRatio: 0.8,
deleteOriginalAssets: false,
}),
]
}
HTTP缓存
增加HTTP资源可以减少同一份资源在二次加载所需时长,将静态服务器的请求头设置 Cache-Control
字段:
arduino
Cache-Control:public, max-age=31536000
max-age
字段用于指定浏览器可以缓存资源的时长,单位为秒。比如示例将时长设置为 31536000
,相当于 1 年:60 秒 × 60 分钟 × 24 小时 × 365 天 = 31536000 秒。
关于缓存的策略有多种,可参考 MDN文档。
使用HTTP缓存需要注意资源的缓存是否更新,如在webpack构建的文件一般都会文件名称中增加文件 Hash,避免文件内容未更新,另外一般入口 HTML 文件一般不设置缓存,避免发布热修无法及时更新,不进行缓存可以做设置如下:
ini
Cache-Control: no-store, max-age=0
Service Worker
Service Worker是一种渐进式增强的功能,可以用于HTTP请求拦截并将请求结果存储在本地,将首屏加载的资源请求以及重要的接口请求缓存在Cache Stroage中,在网页离线或者网络状态较差的情况下使用缓存内容,优化首屏加载体验。
接入Service Worker可以使用workbox,同时这个库也提供了webpack的插件workbox-webpack-plugin
,如在 vue-cli
中使用:
js
const { GenerateSW } = require('workbox-webpack-plugin')
export.default = {
plugins: [
new GenerateSW({
exclude: [/.../, '...'],
maximumFileSizeToCacheInBytes: ...,
navigateFallback: '...',
runtimeCaching: [{
// Routing via a matchCallback function:
urlPattern: ({request, url}) => ...,
handler: '...',
options: {
cacheName: '...',
expiration: {
maxEntries: ...,
},
},
},
}]
})
]
}
资源的缓存有多种策略可以选择,根据业务场景使用合适的缓存方式,参考workbox文档。
请求优先级
浏览器加载CSS、JS以及XHR请求的优先级是按照默认顺序规则进行加载的,在之前要修改加载资源的优先级只能通过一些HACK的方式,如预加载。
在现代浏览器中可以通过 Fetch Priority API 和 fetchpriority
HTML 属性修改请求的优先级,可以针对用户信息请求、页面背景图片等对首屏展示影响较大的请求提高其优先级,优化关键的性能指标。
fetchpriority
的可选值为 high
(提高资源请求优先级)、low
(降低资源请求优先级)、auto
(使用浏览器默认优先级)。
html
<img src="image.jpg" fetchpriority="high">
<img src="image1.jpg" fetchpriority="low">
js
let userInfo = await fetch('/user', {priority: 'high'}))
构建产物相关
构建产物的体积对资源的加载速度至关重要,在webpack构建的项目中可以使用webpack-bundle-analyzer
插件进行体积分析:
资源体积压缩
JS资源
JS资源压缩目前比较常用的有UglifyJS、terser等,这些库通过移除代码中的空行、注释、对变量名称进行缩短重写以及压缩算法等减少文件的体积。
如果项目是基于webpack进行打包编译,默认在 production
模式下会使用 terser-webpack-plugin
插件对打包资源进行混淆压缩。如果需要对插件的配置型自定义,需要安装插件并修改:
js
const TerserPlugin = require("terser-webpack-plugin");
module.exports = {
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
parallel: 4,
})],
},
};
由于terser使用JS进行压缩,所以压缩速度上面比较慢,也可以使用 SWC 或者 ESbuild 提供的压缩机制:
js
const SwcMinifyPlugin = require('swc-minify-webpack-plugin');
module.exports = {
optimization: {
minimize: true,
minimizer: [
new SwcMinifyPlugin({
// 可选的压缩选项
compress: {
// 压缩选项
},
mangle: true, // 是否混淆变量名
}),
],
},
};
js
const { ESBuildMinifyPlugin } = require('esbuild-loader')
module.exports = {
optimization: {
minimize: true,
minimizer: [
new SwcMinifyPlugin({
}),
],
},
};
CSS资源
CSS资源的压缩在webpack中一般使用postcss的 cssnano
插件:
js
module.exports = {
module: {
rules: [
{
test: /.css$/,
use: [
'style-loader',
'css-loader',
{
loader: 'postcss-loader',
options: {
plugins: [
require('cssnano')()
]
}
}
]
}
]
},
}
CSS的压缩机制除了删除空白行、注释之外,也会对颜色值进行格式转换压缩,但是无法通过修改类名的方式压缩体积,所以开发过程中类名的名称长度和语义化之间做衡量。
CSS的样式除了一些容器独有的高宽颜色之外,大部分的样式是重复的,我们可以通过复用样式类的方式减少体积,这也是目前原子化 CSS 比较流行的原因之一,如 tailwindcss。
顺带一提,目前大部分的构建机制都会使用 mini-css-extract-plugin 抽离CSS资源,原因在于webpack的默认构建机制会将所有的资源(如图片、CSS等)当做 JavaScript 模块,便于建立模块关系依赖,这种处理方式在项目规模较小的情况下可以减少HTTP请求的次数,但是随着项目的外部依赖及规模变大,会导致JS资源的体积较大,将CSS抽离出来一方面可以减少JS体积,另一方面在项目的改动更集中的在逻辑层面改动的情况下CSS不发生变动,可以更好的利用浏览器的缓存机制。
另外上述 ESBuildMinifyPlugin
也可以用于CSS的压缩:
js
module.exports = {
optimization: {
minimize: true,
minimizer: [
new SwcMinifyPlugin({
css: true
}),
],
},
};
图片资源
静态资源除了JS、CSS资源体积较大之外就是图片资源,减少图片资源体积最好的方式就是不用图片(没开玩笑,狗头),部分场景下的实现可以使用一些复杂的CSS样式实现(如下图),另外可以使用字体图标,如 iconfont,不仅可以减少体积,也可以增加图标的灵活性。
另外一些场景下使用图标是不可避免的,此时选择一个合适的图标格式可以极大的减少图标的体积,常见的图片格式有以下这些:
-
svg
:一种可缩放矢量格式的图片格式,基于XML格式。可以适配不同分辨率下显示不会失真,适用于一些图标的显示。对于图标中包含大量路径和颜色信息的图标,在一些性能较差或受限制的设备上,由于解析渲染耗费时间以及浏览器单线程的渲染机制,会导致页面渲染阻塞。 -
png
: 支持无损压缩,可以保持图片的细节,压缩后不会失真,不支持缩放,所以一般在适配多分辨率情况下需要UI提供2x图以适配不同设备,但是支持透明底色,所以可以作为logo或者活动图等。 -
jpeg
: 和png格式图片特性上基本类似,但是不支持透明底色,优点在于支持的色域较广,对于色彩追求较高的场景可以使用,但是需要注意压缩为有损压缩,会存在一定的失真。 -
webp
: 由 google 在2010年提出的现代图片格式,具备png
和jpeg
的同时,相比之下可以减少25%的图片体积,如果不需要兼容IE的情况下推荐优先使用这种格式图片,可以通过一些工具将其他格式图片转为webp
。javascriptconst webp = require('webp-converter'); // 将PNG图片转换为WebP格式 webp.cwebp('input.png', 'output.webp', '-q 80');
一般我们从UI获取的图片都是经过压缩的,那么二般的情况下话我们可以通过tinypng 压缩图片体积,也有一些其他的工具如专门针对svg 压缩的 svgo等,可以使用 imagemin
集成工具进行压缩,也可以使用对应的webpack插件image-minimizer-webpack-plugin:
需注意由于imagemin中依赖一些C代码包部署在外网,可能存在下载失败的情况,需自备外网访问环境。
js
const ImageMinimizerPlugin = require("image-minimizer-webpack-plugin");
module.exports = {
module: {
rules: [
{
test: /.(jpe?g|png)$/i,
type: "asset",
},
],
},
optimization: {
minimizer: [
new ImageMinimizerPlugin({
minimizer: {
implementation: ImageMinimizerPlugin.squooshMinify,
options: {
encodeOptions: {
mozjpeg: {
quality: 100,
},
webp: {
lossless: 1,
}
},
},
},
}),
],
},
};
工程配置
工程配置主要是利用构建工具提供的一些特性,从而提高页面的性能,由于目前比较流程的打包工具还是webpack,所以我们就从webpack中一些特性进行讲解,当然目前主流的打包工具如 rollup
也提供了类似的一些功能。
Split Chunk
这个功能是由 SplitChunksPlugin
插件实现的,目的在于解决避免模块之前的重复依赖,用于去重和分离chunk文件。这种做法有以下优点:
- 避免重复打包构建相同模块,减少构建包体积
- 利用浏览器缓存,将通用的模块抽出避免整个文件进行更新
- 增加模块文件数量,避免单个文件体积过大,利用并行下载提高下载速度
webpack在默认情况下开启了这个功能,默认的配置在官方的中文文档中是这么描述的:
开箱即用的
SplitChunksPlugin
对于大部分用户来说非常友好。默认情况下,它只会影响到按需加载的 chunks,因为修改 initial chunks 会影响到项目的 HTML 文件中的脚本标签。
webpack 将根据以下条件自动拆分 chunks:
- 新的 chunk 可以被共享,或者模块来自于
node_modules
文件夹- 新的 chunk 体积大于 20kb(在进行 min+gz 之前的体积)
- 当按需加载 chunks 时,并行请求的最大数量小于或等于 30
- 当加载初始化页面时,并发请求的最大数量小于或等于 30
当尝试满足最后两个条件时,最好使用较大的 chunks。
默认的配置如下:
js
module.exports = {
//...
optimization: {
splitChunks: {
chunks: 'async',
minSize: 20000,
minRemainingSize: 0,
minChunks: 1,
maxAsyncRequests: 30,
maxInitialRequests: 30,
enforceSizeThreshold: 50000,
cacheGroups: {
defaultVendors: {
test: /[\/]node_modules[\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
最大请求并发数设置为30是因为目前大多数的CDN服务器都支持了HTTP2.0,可以多路复用无最大请求并发的限制,如果为HTTP1.1,一般设置最大请求并发数为6。
chunks
的可选值有 async
、initial
、all
。async标识在异步chunk之前共享,initial为非异步之间共享,all为两者都可以共享。
异步模块指的是通过 import()
方式导入的模块,如在 vue-cli
项目中通过路由维度进行模块拆分:
js
// router.js
const user = () => import(/* webpackChunkName: "user" */'./components/user.vue');
const order = () => import(/* webpackChunkName: "order" */'./components/order.vue');
export default {
routes: [
{
path: '/user',
component: user,
},
{
path: '/order',
component: order,
},
]
}
按照默认的配置上面的两个模块都会被拆分为单独的chunk文件,并可以通过 webpackChunkName
设置对应的chunk名称。
node_modules
中的包默认构建到 chunk-vendor
文件中,但是在业务复杂的场景下一方面这个文件体积增大,另一方面可能会引入一些业务的组件库,需要经常更新版本,这个时候可以利用代码分割机制对加载进行优化:
js
// vue.config.js
export.default = {
configureWebpack: {
optimization: {
splitChunks: {
cacheGroups: {
'chunk-vue': {
test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
priority: 10,
chunks: 'initial'
},
'chunk-element-ui': {
test: /[\\/]node_modules[\\/](element-ui)[\\/]/,
priority: 10,
chunks: 'initial'
},
},
},
}
}
}
上述代码针对代码中不经常发生变动的模块(如vue等框架层面的库)进行了抽离,文件不发生变化便于浏览器缓存并减少chunk-vendor
文件体积。另外针对易变动的模块,以 element-ui
类比一些业务模块,将这些模块切割为单独模块,避免单个变动影响整个chunk-vendor
文件缓存。
Tree Shaking
Tree Shaking是基于ESM模块的静态加载机制,通过在编译时进行加载依赖分析和优化,从而移除代码中一些未用到的无效代码(副作用)减少代码体积。
相比之下,CommonJS的模块加载是动态的,这和模块的机制设计之初所支持的环境是有关系的,CommonJS是为Node环境中设计,一般为服务器无需编译所以在运行时加载模块,ESM的设计是服务于Web应用,所以设计了一些适合浏览器环境的加载特性,如异步加载。
所以在日常业务引入包时需要注意包提供的模块形式以及引入方式,避免CommonJS模块影响代码构建体积,如将 lodash
替换为 lodash-es
。
在webpack中设置打包模式为生产模式默认会开启Tree Shaking。但是在编写一些库(如组件库)提供给外部使用的时候需要注意通过package.json文件中的sideEffects字段或者内联注释/@PURE/用于提示构建工具是否支持Tree Shaking从而移除未使用的代码。
json
{
"sideEffects": false
}
浏览器兼容性
说起浏览器兼容性就不得不提Babel,由于用户侧的使用的浏览器不同,导致对一些新的技术特性并不支持,为了保证可以正常运行,就需要对代码的新特性进行兼容,这就是polyfill,也是Babel的重要功能之一。
但是polyfill中兼容的内容一般很多,导致构建代码体积较大,可以利用 browserslist
明确需要兼容的浏览器范围从而减少polyfill内容。创建 .browserslistrc
文件指定浏览器范围,具体配置信息可参考文档:
text
last 2 versions
> 1%
js
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
useBuiltIns: 'usage'
}]
]
}
}
}
]
}
};
browserslist
虽然可以根据目标浏览器减少代码的体积,但是在比较新的浏览器中这些polyfill也是比较多余的,此时我们可以使用动态polyfill的方式,动态polyfill为在线服务,根据请求头中的 User-Agent
返回对应浏览器的对应的polyfill。如阿里提供动态polyfill的服务:
text
https://polyfill.alicdn.com/polyfill.min.js?features=Promise,Reflect
Babel配置仅针对项目中的代码,一般是忽略 node_modules
模块的,因为大部分第三方库是经过编译处理的,如果某个库没有处理需要在配置项中指定进行Babel处理。
资源加载相关
按需加载
首屏指的是在首次加载展示在屏幕可视区域的内容,所以我们需要保证在视图区域的内容优先加载,其他的区域延迟加载或异步加载。
图片懒加载
图片懒加载是业务中比较常见的一种优化形式,即在图片在视图区域或滚动到一定阈值再触发图片加载,比如轮播图。之前的实现方法为监听图片的位置与视图窗口作对比,改变img标签的src属性动态加载图片,不过在现代浏览器中更推荐使用 loading
属性设置懒加载:
html
<img src="a.png" loading="lazy" />
这个属性目前主流浏览器均已支持,如果需要兼容版本的浏览器可以添加对应的polyfill:
js
// <img class="loading-lazy" />
if(!('loading' in document.createElement('img'))) {
import('loading-attribute-polyfill/dist/loading-attribute-polyfill.module.js')
}
JS懒加载
JS资源懒加载可以通过动态 import()
加载模块,可以根据不同的维度对模块进行划分,另外可以对模块标记 prefetch
预加载对应的模块,加速动态加载模块的加载速度:
js
import(/* webpackPrefetch: true */ 'a.js');
CSS懒加载
默认情况下,CSS资源的加载解析会阻塞渲染,在 CSSDOM 构建完成之前,浏览器不会处理渲染内容,所以CSS保留首屏需要的样式即可,一些其余的样式可以通过延迟解析的形式加载,避免阻塞首屏的渲染:
html
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
通过 preload
声明资源提前获取加载资源,但是不会触发解析,通过 onload
事件将 rel属性的值从preload更改为stylesheet,这样CSS资源就是在load事件之后再触发解析渲染。
总结
以上主要通过了不同的几个方面介绍了一些SPA(单页面应用)性能优化的方法,性能优化的重点是要get到浏览器整个加载过程的细节,通过优化每一个细节流程从而提升整体的性能,文章中介绍为我在业务用到的一些方法,也欢迎各位补充一些方法。是经过编译处理的,如果某个库没有处理需要在配置项中指定进行Babel处理。