前端性能优化之首屏、白屏与卡顿优化手段

优化手段

首屏秒开

首屏秒开主要可以分为 4 个方法——懒加载,缓存,离线化,并行化。

懒加载

懒加载是指在长页面加载过程时,先加载关键内容,延迟加载非关键内容。比如当我们打开一个页面,它的内容超过了浏览器的可视窗口大小,我们可以先加载前端的可视区域内容,剩下的内容等它进入可视区域后再按需加载。

缓存

懒加载本质是提供首屏后请求非关键内容的能力,那么缓存则是赋予二次访问不需要重复请求的能力。在首屏优化方案中,接口缓存和静态资源缓存起到中流砥柱的作用。

接口缓存

对于不常变的数据可以做本地缓存,比如一些配置接口。同时还可以进行版本号管理,后端返回数据时同时返回版本号,在数据发生变更时(可以使用监听机制),后端将版本号更新。前端请求时携带之前拿到的版本号,如果版本号一致,后端直接返回无变化,不用再查数据库。

静态资源缓存

资源长期不变的话,比如 1 年都不怎么变化,我们可以使用强缓存,如 Cache-Control 来实现。具体来说可以通过设置 Cache-Control:max-age=31536000,来让浏览器在一年内直接使用本地缓存文件,而不是向服务端发出请求。

至于第二种,如果资源本身随时会发生改动的,可以通过设置 Etag 实现协商缓存。具体来说,在初次请求资源时,设置 Etag(比如使用资源的 md5 作为 Etag),并且返回 200 的状态码,之后请求时带上 If-none-match 字段,来询问服务器当前版本是否可用。如果服务端数据没有变化,会返回一个 304 的状态码给客户端,告诉客户端不需要请求数据,直接使用之前缓存的数据即可。

离线化

离线化是指线上实时变动的资源数据静态化到本地。打包构建时预渲染页面,前端请求落到 index.html 上时,已经是渲染过的内容。此时,可以通过 Webpack 的 prerender-spa-plugin 来实现预渲染,进而实现离线化。Webpack 实现预渲染的代码示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// webpack.conf.js
var path = require('path')
var PrerenderSpaPlugin = require('prerender-spa-plugin')
module.exports = {
// ...
plugins: [
new PrerenderSpaPlugin(
// 编译后的html需要存放的路径
path.join(__dirname, '../dist'),
// 列出哪些路由需要预渲染
[ '/', '/about', '/contact' ]
)
]
}

并行化

懒加载、缓存和离线化都是在请求本身上下功夫,想尽办法减少请求或者推迟请求,并行化则是在请求通道上功夫,解决请求阻塞问题,进而减少首屏时间

代码逻辑上,能并行的逻辑尽量并行处理,如使用Promise.all()。根本上可以使用HTTP 2.0 的多路复用方案来解决。突破同域名的连接数限制(6个),解决HTTP阻塞问题。

白屏优化

所谓白屏时间,一般是当用户打开一个页面,从开始等待到页面第一个字符出现的时间。我们可以基于影响白屏时间长短的两个主要因素来解决——DNS 查询和首字符展示

DNS 查询优化

前端侧,可以通过在页面中加入 dns-prefetch,在静态资源请求之前对域名进行解析,从而减少用户进入页面的等待时间。如下所示:

1
2
3
4
<meta http-equiv="x-dns-prefetch-control" content="on" />

<link rel="dns-prefetch" href="https://s.google.com/" >

其中第一行中的 x-dns-prefetch-control 表示开启 DNS 预解析功能,第二行 dns-prefetch 表示强制对 s.google.com 的域名做预解析。这样在 s.google.com 的资源请求开始前,DNS 解析完成,后续请求就不需要重复做解析了。

字符展示优化

方案一是使用loading图,但是体验稍差。

方案二可以使用骨架屏,可以使用切图,但是可能整页切图质量较大,也会占用网络资源。也可以参考社区自动化方案如page-skeleton-webpack-plugin

卡顿

首先也还是问题的定位,先通过 charles 等工具抓包看一下数据接口, 好好的利用浏览器工具performance

配置文件

构建工具

你用的构建工具 webpack 或者是 rollup
对应的项目的配置文件: vue.config.js webpack.config.js vite.config.js

1
2
3
4
5
6
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
configureWebpack: {
plugins: [new BundleAnalyzerPlugin()]
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// import { visualizer } from 'rollup-plugin-visualizer';  // 
import { defineConfig } from "vite";
import { analyzer } from "vite-bundle-analyzer";

export default defineConfig({
plugins: [
// ...your plugin
analyzer({
analyzerMode: "static" // 使用静态模式,会生成一个可以直接打开的html文件
fileName: "report" // 生成Html的名称
}),
],
});

// If you are using it in rollup you can import 'adapter' from package.
// Then use it with adapter(analyzer())

前端性能优化之首屏、白屏与卡顿优化手段 流程图

gzip压缩

通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右

vite.config.js
1
2
3
4
5
6
7
8
9
10
import viteCompression from 'vite-plugin-compression';
plugins: [
viteCompression({
filter: /\.(js|css|json|txt|html|ico|svg)(\?.*)?$/i, // 需要压缩的文件
threshold: 1024 * 50, // 文件容量大于这个值进行压缩
algorithm: 'gzip', // 压缩方式
ext: 'gz', // 后缀名
deleteOriginFile: true, // 压缩后是否删除压缩源文件
})
],
vue.config.js webapck.config.js
1
2
3
4
5
6
7
8
9
10
11
//pnpm i -D compression-webpack-plugin
configureWebpack: config => {
const CompressionPlugin = require('compression-webpack-plugin')
config.plugins.push(new CompressionPlugin(
test: /\.(js|css)$/,
algorithm: 'gzip', // 压缩方式
threshold: 1024 * 50 , // 超过50kb的文件就压缩
deleteOriginalAssets:true, // 不删除源文件
minRatio: 0.8
))
}

Brotli 的性能比 Gzip 提高了 17-25%

Brotli 压缩只在 https 下生效,因为 在 http 请求中 request header 里的 Accept-Encoding是没有 br 的,只有gzip, deflate 。并且 Brotli 和 gzip 是可以并存的,因此无需关闭 gzip,客户端可以根据其能力选择最适合的压缩算法

浏览器渲染帧 流程图

vite.config.js
1
2
3
4
5
6
7
import brotli from 'rollup-plugin-brotli';
export default defineConfig({
plugins: [
vue(),
brotli()
]
})

浏览器渲染帧 流程图

nginx需要配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
http {
# 启用 Brotli 压缩
brotli on;

# 设置 Brotli 压缩级别
brotli_comp_level 6;

# 设置启用压缩的最小文件大小
brotli_min_length 20;

# 指定要压缩的文件类型
brotli_types text/plain text/css application/javascript;

# 配置 Brotli 压缩的缓冲区大小
brotli_buffers 16 8k;

# 其他的 Nginx 配置项...
}

CDN减少打包体积

  1. 使用 cdn 文件来减少工程到打包体积,也可以按需加载。
  2. 在 /public/index.html 中引入需要的js和css文件

浏览器渲染帧 流程图

src/main.js
1
2
3
4
5
import App/ from 'App.vue'
Vue.confg.porductonTip = false
new Vue ({
render: h=>(App),
}).$mount('#app');
vue.config.js webpack.config.js
1
2
3
4
5
import App/ from 'App.vue'
Vue.confg.porductonTip = false
new Vue ({
render: h=>(App),
}).$mount('#app');
vue.config.js webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
// 生产环境下替换路径为cdn路径
publicPath: isProd ? process.env.VUE_APP_PUBLIC_PATH_PROD : process.env.VUE_APP_PUBLIC_PATH,
configureWebpack:{
externals:{
vue: 'Vue',
'vue-router': 'VueRouter',
axios: 'axios',
echarts: 'echarts'
}
}
}

浏览器渲染帧 流程图

1
2
3
4
// .env 文件变量
VUE_APP_PUBLIC_PATH=/
// cdn存储路径
VUE_APP_PUBLIC_PATH_PROD=https://jz-pro-server.oss-cn-hangzhou.aliyuncs.com/cdn/a_项目名称

Vite项目

https://github.com/MMF-FE/vite-plugin-cdn-import/tree/master

vite.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

import { visualizer } from 'rollup-plugin-visualizer'
// 文档的用法会报错, 要这样引入才可以
import { Plugin as importToCDN, autoComplete } from 'vite-plugin-cdn-import'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
visualizer({ open: true }),
importToCDN({
modules: [
autoComplete('vue'), // vue2 使用 autoComplete('vue2')
{
// 引入时的包名
name: '@arco-design/web-vue',
// app.use(), 全局注册时分配给模块的变量
var: 'ArcoVue',
// 根据自己的版本号找到对应的CDN网址
path: 'https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco-vue.min.js',
// 根据自己的版本号找到对应的CDN网址
css: 'https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco.css',
},
],
}),
],
})
main.js
1
2
3
4
5
6
7
8
9
10
11
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

import ArcoVue from '@arco-design/web-vue'
// 这里 css 也用 CDN 导入
// import '@arco-design/web-vue/dist/arco.css'

const app = createApp(App)
app.use(ArcoVue)
app.mount('#app')

使用 rollup自带的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
// 文档的用法会报错, 要这样引入才可以
// import { Plugin as importToCDN, autoComplete } from 'vite-plugin-cdn-import'

import externalGlobals from 'rollup-plugin-external-globals'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
visualizer({ open: true }),
// importToCDN({
// modules: [
// autoComplete('vue'), // vue2 使用 autoComplete('vue2')
// {
// // 引入时的包名
// name: '@arco-design/web-vue',
// // app.use(), 全局注册时分配给模块的变量
// var: 'ArcoVue',
// // 根据自己的版本号找到对应的CDN网址
// path: 'https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco-vue.min.js',
// // 根据自己的版本号找到对应的CDN网址
// css: 'https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco.css',
// },
// ],
// }),
],
build: {
rollupOptions: {
external: ['vue', '@arco-design/web-vue'],
plugins: [
externalGlobals({
vue: 'Vue',
'@arco-design/web-vue': 'ArcoVue',
}) as any,
],
},
},
})

https://github.com/eight04/rollup-plugin-external-globals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 手动引入 vue、arco-design、arco.css -->
<script src="https://unpkg.com/vue@3.3.4/dist/vue.global.js"></script>
<script src="https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco-vue.min.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/@arco-design/web-vue@2.47.1/dist/arco.css"
/>
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

强缓存、协商缓存

ServiceWorker Http缓存(强缓存–本地缓存 ) 协商缓存(304)

强缓存 200

浏览器渲染帧 流程图

协商缓存 304

浏览器渲染帧 流程图

资源预加载

提前加载资源,当用户需要查看时可直接从本地缓存中渲染

preload
1
2
3
<!-- 对sty1e.cs和 index.js进行pre1oad预加载 -->
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="index.js" as="script">
prefetch
1
2
3
<!--对资源进行 prefetch预加载-->
<link rel="prefetch" href="next.css">
<link rel="prefetch" href="next.js">
dns-prefetch
1
<link rel="dns-prefetch" href="//example.com">
defer async
1
2
3
4
5
<script src="d.js" defer></script>
<script src="e.js" defer></script>

<script src="b.js" async></script>
<script src="c.js" async></script>

Vite中使用cdn来加载需要的库文件

在vite运行build时,默认会把所有的库文件合并到一个大文件中,产生一个打包后的js文件,其中会包含各种库文件,会导致最后打包完成后的文件过大,同时减慢打包速度,这时可以把库文件代码使用cdn访问,加快打包和网页打开速度。

首先需要确认的是,vite在开发时使用的是自己的一套机制,也就是将代码转换成浏览器原生可以使用的type="module",然后让浏览器的js引擎去直接运行js,不再需要使用构建工具打包,所以可以做到秒开。而vite的编译使用的是另外一个开源的包rollup,这也是这篇文章的主角,而且需要注意的是,开发时dev server是不会去使用cdn的代码的,只会引用我们安装的node_modules内部的代码。

但是这个时候又出现问题了,搞定这些问题前前后后花了我大半天的时间,终于功夫不负有心人,搞定了。

使用rollup自带的ouput.globals

首先参考的是vite用户在github的一条issues,其中尤大提到了rollupOptions.external这个东西,然后我参考了vite的文档,文档中指向了rollup的配置。
浏览器渲染帧 流程图

跳转之后就可以看到官方的文档的第一条就是说明的external,经过百度查找,我打开了第一篇csdn的文章。

https://blog.csdn.net/qq_29722281/article/details/95596372

内部提到了使用globals来指定全局模块名称,这个地方给的样例代码其实已经有问题了:
浏览器渲染帧 流程图

新版的rollup更新后,globals选项已经被移动到了output.globals,如果不修改则会发生以下错误:

Unknown input options: globals. Allowed options: acorn, acornInjectPlugins, cache, context, experimentalCacheExpiry,
external, inlineDynamicImports, input, manualChunks, moduleContext, onwarn, perf, plugins, preserveEntrySignatures,
preserveModules, preserveSymlinks, shimMissingExports, strictDeprecations, treeshake, watch

提示我们globals选项不存在,我们需要将globals放入output中才可以使用。
于是我就按照文档,添加了一个globals选项,同时使用external选项来排除打包。

当时代码是这样的:

1
2
3
4
5
6
external: ["vue"],
output: {
globals: {
vue: "Vue",
},
},

添加完成之后,问题就开始出现了:

rollup Failed to resolve module specifier “vue”

查看打包后的代码可以发现,我们的import代码还是原样import了vue,但是这种语法浏览器其实是解析不了了,因为网页没有node_modules,浏览器也就没有办法去别处找这个包。

就是因为这个问题,前前后后搞了半天,终于在谷歌搜到了一个rollup用户的issue,其中的用户和我具有一样的情况,也就是globals选项无效。
参考下方地址:
https://github.com/rollup/rollup/issues/3188

然后经过一番研究,我才找到了最终的问题(当然我之前也有思考过是不是不支持,但是用其他的插件也是无效):

output.globals only sets the global variable for external imports in IIFE bundles (and UMD bundles when they are used
in a script tag), in other formats it has no effect. https://www.npmjs.com/package/rollup-plugin-external-globals is
probably what you want.
Ah sorry, I see you found that plugin, is it working for you? There are technical reasons this is not supported in
core, mostly because it would need to work very differently for non-IIFE formats, potentially provide larger output
after minification, and it is totally unclear how to handle this with UMD.

翻译过来,就是说自带的global只支持iife或者umd打包的库文件,这时候我才恍然大悟,我使用的包都是commonjs打包的(关于各种打包的方式使用大家可以谷歌学习),所以才出现这种问题,其实就是本身不支持(不过经过测试其他方式的好像也不可以,可能就是vite本身的问题吧)。

反正经过这个提示,问题已经大概解决了,我们只需要使用插件就能解决这个问题。

使用rollup-plugin-external-globals插件来解决问题

  1. 参考:https://github.com/rollup/rollup/issues/2374
  2. 插件地址:https://www.npmjs.com/package/rollup-plugin-external-globals
安装插件

yarn add -D rollup-plugin-external-globals

添加配置

使用方法与上方定义方法几乎相同,传入参数给插件初始化方法就行。

1
2
3
4
5
6
7
8
plugins: [
commonjs(),
externalGlobals({
vue: "Vue",
"ant-design-vue": "antd",
moment: "moment",
}),
],

参数对解释:
● vue - 这里需要和external对应,这个字符串就是(import xxx from aaa)中的aaa,也就是包的名字
● Vue - 这个是js文件导出的全局变量的名字,比如说vue就是Vue,查看源码或者参考作者文档可以获得

下面是全部vite.config.js,供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import vue from "@vitejs/plugin-vue";
import commonjs from "rollup-plugin-commonjs";
import externalGlobals from "rollup-plugin-external-globals";

/**
* https://vitejs.dev/config/
* @type {import('vite').UserConfig}
*/
export default {
plugins: [vue()],
build: {
rollupOptions: {
external: ["vue", "ant-design-vue", "moment"],
plugins: [
commonjs(),
externalGlobals({
vue: "Vue",
"ant-design-vue": "antd",
moment: "moment",
}),
],
},
},
};
在index.html中导入静态文件

修改根目录下的index.html,添加cdn文件:

1
2
3
4
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ant-design-vue@2.0.0-rc.9/dist/antd.min.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.0.5/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ant-design-vue@2.0.0-rc.9/dist/antd.js"></script>

整个文件参考下方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ant-design-vue@2.0.0-rc.9/dist/antd.min.css">
<script src="https://cdn.jsdelivr.net/npm/vue@3.0.5/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/ant-design-vue@2.0.0-rc.9/dist/antd.js"></script>
<title>前端</title>
</head>

<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>

</html>
编译测试

vite中提供了preview,可以供我们预览编译后的结果
浏览器渲染帧 流程图

1
2
yarn run build
yarn run serve

打开网页发现原先报错已经不存在,问题解决。
浏览器渲染帧 流程图

包体积,打包速度优化

压缩项目打包后的体积大小、提升打包速度,是前端性能优化中非常重要的环节,笔者结合工作中的实践总结,梳理出一些 常规且有效 的性能优化建议

● 初始体积 2.25M
浏览器渲染帧 流程图

在 vue.config.js 中 引入添加 配置 webpack-bundle-analyzer

1
2
3
4
5
6
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
configureWebpack: {
plugins: [new BundleAnalyzerPlugin()]
}
};

externals 提取项目依赖

从上面的打包分析页面中可以看到,chunk-vendors.js 体积为 2.21M,其中最大的几个文件都是一些公共依赖包,那么只要把这些依赖提取出来,就可以解决 chunk-vendors.js 过大的问题
可以使用 externals 来提取这些依赖包,告诉 webpack 这些依赖是外部环境提供的,在打包时可以忽略它们,就不会再打到 chunk-vendors.js 中

  1. vue.config.js 中配置:
1
2
3
4
5
6
7
8
9
module.exports = {
configureWebpack: {
externals: {
vue: 'Vue',
'vue-router': 'VueRouter',
axios: 'axios',
echarts: 'echarts'
}
}}

浏览器渲染帧 流程图

  1. 在 index.html 中使用 CDN 引入依赖
1
2
3
4
5
6
<body>
<script src="http://lib.baomitu.com/vue/2.6.14/vue.min.js"></script>
<script src="http://lib.baomitu.com/vue-router/3.5.1/vue-router.min.js"></script>
<script src="http://lib.baomitu.com/axios/1.2.1/axios.min.js"></script>
<script src="http://lib.baomitu.com/echarts/5.3.2/echarts.min.js"></script>
</body>

验证 externals 的有效性:

重新打包,最新数据如下:
打包体积:1.12M
浏览器渲染帧 流程图

打包速度:18879ms
浏览器渲染帧 流程图

使用 externals 后,包体积压缩 50%、打包速度提升 26%

组件库的按需引入

elementUI 需要借助 babel-plugin-component 插件实现,插件的作用如下:
如按需引入 Button 组件:

1
2
import { Button } from 'element-ui';
Vue.component(Button.name, Button);

编译后的文件(自动引入 button.css):

1
2
3
4
5
import _Button from 'element-ui/lib/button';
import _Button2 from 'element-ui/lib/theme-chalk/button.css';
// base.css是公共的样式
import 'element-ui/lib/theme-chalk/base.css';
Vue.component(_Button.name, _Button);

通过该插件,最终只引入指定组件和样式,来实现减少组件库体积大小

  1. 安装 babel-plugin-component

npm install babel-plugin-component -D

  1. babel.config.js 中引入
1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
presets: ['@vue/app'],
plugins: [
[
'component',
{
libraryName: 'element-ui',
styleLibraryName: 'theme-chalk'
}
]
]
}

验证组件库按需引入的有效性:

重新打包,最新数据如下:
打包体积:648KB
浏览器渲染帧 流程图

打包速度:15135ms
浏览器渲染帧 流程图

组件库按需引入后,包体积压缩 72%、打包速度提升 40%
同时 chunk-vendors.css 的体积也有了明显的减少,从206KB降到了82KB

减小三方依赖的体积

继续分析打包文件,项目中使用了 moment.js,发现打包后有很多没有用到的语言包
浏览器渲染帧 流程图

使用 moment-locales-webpack-plugin 插件,剔除掉无用的语言包

  1. 安装
    npm install moment-locales-webpack-plugin -D

  2. vue.config.js 中引入

1
2
3
4
5
6
7
const MomentLocalesPlugin = require('moment-locales-webpack-plugin');

module.exports = {
configureWebpack: {
plugins: [new MomentLocalesPlugin({ localesToKeep: ['zh-cn'] })]
}
};

验证插件的有效性:

重新打包,最新数据如下:
打包体积:407KB
浏览器渲染帧 流程图

打包速度:10505ms
浏览器渲染帧 流程图

减小三方依赖体积后,包体积压缩 82%、打包速度提升 59%

Gzip 压缩

线上的项目,一般都会结合构建工具 webpack 插件或服务端配置 nginx,来实现 http 传输的 gzip 压缩,目的就是把服务端响应文件的体积尽量减小,优化返回速度
html、js、css 资源,使用 gzip 后通常可以将体积压缩 70%以上
这里介绍下使用 webpack 进行 gzip 压缩的方式,使用 compression-webpack-plugin 插件

  1. 安装
    npm install compression-webpack-plugin -D

  2. vue.config.js 中引入

1
2
3
4
5
6
7
8
9
10
11
12
13
const CompressionPlugin = require('compression-webpack-plugin');

module.exports = {
configureWebpack: {
plugins: [
new CompressionPlugin({
test: /\.(js|css)(\?.*)?$/i, //需要压缩的文件正则
threshold: 1024, //文件大小大于这个值时启用压缩
deleteOriginalAssets: false //压缩后保留原文件
})
]
}
};

验证插件的有效性:

重新打包,原来 407KB 的体积压缩为 108KB
浏览器渲染帧 流程图

经过上面的一系列优化,可以看到:
● 包体积由原来的 2.25M 减少到 407KB,压缩了 82%
● 打包速度由原来的 25386ms减少到 8949ms,提升了 65%

这些方式虽然很常规,但确实可以有效地提升项目的性能

首页资源, 白屏优化具体操作

1. 路由懒加载

SPA 项目,一个路由对应一个页面,如果不做处理,项目打包后,会把所有页面打包成一个文件,当用户打开首页时,会一次性加载所有的资源,造成首页加载很慢,降低用户体验

列一个实际项目的打包详情:
app.js 初始体积: 1175 KB
浏览器渲染帧 流程图

app.css 初始体积: 274 KB
浏览器渲染帧 流程图

将路由全部改成懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 通过webpackChunkName设置分割后代码块的名字
const Home = () => import(/* webpackChunkName: "home" */ "@/views/home/index.vue");
const MetricGroup = () => import(/* webpackChunkName: "metricGroup" */ "@/views/metricGroup/index.vue");
const routes = [
{
path: "/",
name: "home",
component: Home
},
{
path: "/metricGroup",
name: "metricGroup",
component: MetricGroup
},

]

重新打包后,首页资源拆分为 app.js 和 home.js,以及对应的 css 文件

● app.js:244 KB、 home.js: 35KB
浏览器渲染帧 流程图

● app.css:67 KB、home.css: 15KB
浏览器渲染帧 流程图

通过路由懒加载,该项目的首页资源压缩约 52%
懒加载前提的实现:ES6的动态地加载模块——import()
webpackChunkName 作用是 webpack 在打包的时候,对异步引入的库代码(lodash)进行代码分割时,设置代码块的名字。webpack 会将任何一个异步模块与相同的块名称组合到相同的异步块中
vite也有类似的功能

2. 组件懒加载

home 页面 和 about 页面,都引入了 dialogInfo 弹框组件,该弹框不是一进入页面就加载,而是需要用户手动触发后才展示出来

home 页面示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div class="homeView">
<p>home 页面</p>
<el-button @click="dialogVisible = !dialogVisible">打开弹框</el-button>
<dialogInfo v-if="dialogVisible" />
</div>
</template>
<script>
import dialogInfo from '@/components/dialogInfo';
export default {
name: 'homeView',
components: {
dialogInfo
}
}
</script>

项目打包后,发现 home.js 和 about.js 均包括了该弹框组件的代码(在 dist 文件中搜索dialogInfo弹框组件)
浏览器渲染帧 流程图

当用户打开 home 页时,会一次性加载该页面所有的资源,我们期望的是用户触发按钮后,再加载该弹框组件的资源

这种场景下,就很适合用懒加载的方式引入
弹框组件懒加载:

1
2
3
4
5
6
7
8
9
<script>
const dialogInfo = () => import(/* webpackChunkName: "dialogInfo" */ '@/components/dialogInfo');
export default {
name: 'homeView',
components: {
dialogInfo
}
}
</script>

重新打包后,home.js 和 about.js 中没有了弹框组件的代码,该组件被独立打包成 dialogInfo.js,当用户点击按钮时,才会去加载 dialogInfo.js 和 dialogInfo.css
浏览器渲染帧 流程图

最终,使用组件路由懒后,该项目的首页资源进一步减少约 11%

组件懒加载的使用场景
有时资源拆分的过细也不好,可能会造成浏览器 http 请求的增多
总结出三种适合组件懒加载的场景:

  1. 该页面的 JS 文件体积大,导致页面打开慢,可以通过组件懒加载进行资源拆分,利用浏览器并行下载资源,提升下载速度(比如首页)
  2. 该组件不是一进入页面就展示,需要一定条件下才触发(比如弹框组件)
  3. 该组件复用性高,很多页面都有引入,利用组件懒加载抽离出该组件,一方面可以很好利用缓存,同时也可以减少页面的 JS 文件大小(比如表格组件、图形组件等)

骨架屏优化白屏时长

用骨架屏,可以缩短白屏时间,提升用户体验。国内大多数的主流网站都使用了骨架屏,特别是手机端的项目

SPA 单页应用,无论 vue 还是 react,最初的 html 都是空白的,需要通过加载 JS 将内容挂载到根节点上,这套机制的副作用:会造成长时间的白屏

常见的骨架屏插件就是基于这种原理,在项目打包时将骨架屏的内容直接放到 html 文件的根节点中
使用骨架屏插件,打包后的 html 文件(根节点内部为骨架屏):
浏览器渲染帧 流程图

同一项目,对比使用骨架屏前后的 FP 白屏时间: 1063ms
浏览器渲染帧 流程图

有骨架屏:白屏时间 144ms
浏览器渲染帧 流程图

骨架屏插件

vue-skeleton-webpack-plugin

JS 的6种加载方式

1. 正常模式
1
<script src="index.js"></script>

这种情况下 JS 会阻塞 dom 渲染,浏览器必须等待 index.js 加载和执行完成后才能去做其它事情

2. async 模式
1
<script async src="index.js"></script>

async 模式下,它的加载是异步的,JS 不会阻塞 DOM 的渲染,async 加载是无顺序的,当它加载结束,JS 会立即执行

使用场景:若该 JS 资源与 DOM 元素没有依赖关系,也不会产生其他资源所需要的数据时,可以使用async 模式,比如埋点统计

3. defer 模式
1
<script defer src="index.js"></script>

defer 模式下,JS 的加载也是异步的,defer 资源会在 DOMContentLoaded 执行之前,并且 defer 是有顺序的加载

如果有多个设置了 defer 的 script 标签存在,则会按照引入的前后顺序执行,即便是后面的 script 资源先返回

所以 defer 可以用来控制 JS 文件的执行顺序,比如 element-ui.js 和 vue.js,因为 element-ui.js 依赖于 vue,所以必须先引入 vue.js,再引入 element-ui.js

1
2
<script defer src="vue.js"></script>
<script defer src="element-ui.js"></script>

defer 使用场景:一般情况下都可以使用 defer,特别是需要控制资源加载顺序时

5. module 模式
1
<script type="module">import { a } from './a.js'</script>

在主流的现代浏览器中,script 标签的属性可以加上 type="module",浏览器会对其内部的 import 引用发起 HTTP 请求,获取模块内容。这时 script 的行为会像是 defer 一样,在后台下载,并且等待 DOM 解析
Vite 就是利用浏览器支持原生的 es module 模块,开发时跳过打包的过程,提升编译效率

5.preload
1
<link rel="preload" as="script" href="index.js">

link 标签的 preload 属性:用于提前加载一些需要的依赖,这些资源会优先加载(如下图红框)

async、defer 是 script 标签的专属属性,对于网页中的其他资源,可以通过 link 的 preload、prefetch 属性来预加载
如今现代框架已经将 preload、prefetch 添加到打包流程中了,通过灵活的配置,去使用这些预加载功能,同时我们也可以审时度势地向 script 标签添加 async、defer 属性去处理资源,这样可以显著提升性能

图片的懒加载
1
2
<img src="" alt="" data-src="./images/1.jpg">
<img src="" alt="" data-src="./images/2.jpg">
图片转 base64 格式

将小图片转换为 base64 编码字符串,并写入 HTML 或者 CSS 中,减少 http 请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 安装
`npm install url-loader --save-dev
`
// 配置
module.exports = {
  module: {
    rules: [{
        test/.(png|jpg|gif)$/i,
        use: [{
            loader'url-loader',
            options: {
// 小于 20kb 的图片转化为 base64
              limit1024 * 20
            }
        }]
     }]
  }
};