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

前端性能优化之首屏、白屏与卡顿优化手段
左杰优化手段
首屏秒开
首屏秒开主要可以分为 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 | // webpack.conf.js |
并行化
懒加载、缓存和离线化都是在请求本身上下功夫,想尽办法减少请求或者推迟请求,并行化则是在请求通道上功夫,解决请求阻塞问题,进而减少首屏时间。
代码逻辑上,能并行的逻辑尽量并行处理,如使用Promise.all()。根本上可以使用HTTP 2.0 的多路复用方案来解决。突破同域名的连接数限制(6个),解决HTTP阻塞问题。
白屏优化
所谓白屏时间,一般是当用户打开一个页面,从开始等待到页面第一个字符出现的时间。我们可以基于影响白屏时间长短的两个主要因素来解决——DNS 查询和首字符展示。
DNS 查询优化
前端侧,可以通过在页面中加入 dns-prefetch,在静态资源请求之前对域名进行解析,从而减少用户进入页面的等待时间。如下所示:
1 | <meta http-equiv="x-dns-prefetch-control" content="on" /> |
其中第一行中的 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 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; |
1 | // import { visualizer } from 'rollup-plugin-visualizer'; // |
gzip压缩
通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右
1 | import viteCompression from 'vite-plugin-compression'; |
1 | //pnpm i -D compression-webpack-plugin |
Brotli 的性能比 Gzip 提高了 17-25%
Brotli 压缩只在 https 下生效,因为 在 http 请求中 request header 里的 Accept-Encoding是没有 br 的,只有gzip, deflate 。并且 Brotli 和 gzip 是可以并存的,因此无需关闭 gzip,客户端可以根据其能力选择最适合的压缩算法
1 | import brotli from 'rollup-plugin-brotli'; |
nginx需要配置
1 | http { |
CDN减少打包体积
- 使用 cdn 文件来减少工程到打包体积,也可以按需加载。
- 在 /public/index.html 中引入需要的js和css文件
1 | import App/ from 'App.vue' |
1 | import App/ from 'App.vue' |
1 | module.exports = { |
1 | // .env 文件变量 |
Vite项目
https://github.com/MMF-FE/vite-plugin-cdn-import/tree/master
1 | import { defineConfig } from 'vite' |
1 | import { createApp } from 'vue' |
使用 rollup自带的方式
1 | import { defineConfig } from 'vite' |
https://github.com/eight04/rollup-plugin-external-globals
1 | <!DOCTYPE html> |
强缓存、协商缓存
ServiceWorker Http缓存(强缓存–本地缓存 ) 协商缓存(304)
强缓存 200
协商缓存 304
资源预加载
提前加载资源,当用户需要查看时可直接从本地缓存中渲染
1 | <!-- 对sty1e.cs和 index.js进行pre1oad预加载 --> |
1 | <!--对资源进行 prefetch预加载--> |
1 | <link rel="dns-prefetch" href="//example.com"> |
1 | <script src="d.js" defer></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 | external: ["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插件来解决问题
- 参考:https://github.com/rollup/rollup/issues/2374
- 插件地址:https://www.npmjs.com/package/rollup-plugin-external-globals
安装插件
yarn add -D rollup-plugin-external-globals
添加配置
使用方法与上方定义方法几乎相同,传入参数给插件初始化方法就行。
1 | plugins: [ |
参数对解释:
● vue - 这里需要和external对应,这个字符串就是(import xxx from aaa)中的aaa,也就是包的名字
● Vue - 这个是js文件导出的全局变量的名字,比如说vue就是Vue,查看源码或者参考作者文档可以获得
下面是全部vite.config.js,供参考:
1 | import vue from "@vitejs/plugin-vue"; |
在index.html中导入静态文件
修改根目录下的index.html,添加cdn文件:
1 | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ant-design-vue@2.0.0-rc.9/dist/antd.min.css"> |
整个文件参考下方:
1 |
|
编译测试
vite中提供了preview,可以供我们预览编译后的结果
1 | yarn run build |
打开网页发现原先报错已经不存在,问题解决。
包体积,打包速度优化
压缩项目打包后的体积大小、提升打包速度,是前端性能优化中非常重要的环节,笔者结合工作中的实践总结,梳理出一些 常规且有效 的性能优化建议
● 初始体积 2.25M
在 vue.config.js 中 引入添加 配置 webpack-bundle-analyzer
1 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; |
externals 提取项目依赖
从上面的打包分析页面中可以看到,chunk-vendors.js 体积为 2.21M,其中最大的几个文件都是一些公共依赖包,那么只要把这些依赖提取出来,就可以解决 chunk-vendors.js 过大的问题
可以使用 externals 来提取这些依赖包,告诉 webpack 这些依赖是外部环境提供的,在打包时可以忽略它们,就不会再打到 chunk-vendors.js 中
- vue.config.js 中配置:
1 | module.exports = { |
- 在 index.html 中使用 CDN 引入依赖
1 | <body> |
验证 externals 的有效性:
重新打包,最新数据如下:
打包体积:1.12M
打包速度:18879ms
使用 externals 后,包体积压缩 50%、打包速度提升 26%
组件库的按需引入
elementUI 需要借助 babel-plugin-component 插件实现,插件的作用如下:
如按需引入 Button 组件:
1 | import { Button } from 'element-ui'; |
编译后的文件(自动引入 button.css):
1 | import _Button from 'element-ui/lib/button'; |
通过该插件,最终只引入指定组件和样式,来实现减少组件库体积大小
- 安装 babel-plugin-component
npm install babel-plugin-component -D
- babel.config.js 中引入
1 | module.exports = { |
验证组件库按需引入的有效性:
重新打包,最新数据如下:
打包体积:648KB
打包速度:15135ms
组件库按需引入后,包体积压缩 72%、打包速度提升 40%
同时 chunk-vendors.css 的体积也有了明显的减少,从206KB降到了82KB
减小三方依赖的体积
继续分析打包文件,项目中使用了 moment.js,发现打包后有很多没有用到的语言包
使用 moment-locales-webpack-plugin 插件,剔除掉无用的语言包
安装
npm install moment-locales-webpack-plugin -Dvue.config.js 中引入
1 | const MomentLocalesPlugin = require('moment-locales-webpack-plugin'); |
验证插件的有效性:
重新打包,最新数据如下:
打包体积:407KB
打包速度:10505ms
减小三方依赖体积后,包体积压缩 82%、打包速度提升 59%
Gzip 压缩
线上的项目,一般都会结合构建工具 webpack 插件或服务端配置 nginx,来实现 http 传输的 gzip 压缩,目的就是把服务端响应文件的体积尽量减小,优化返回速度
html、js、css 资源,使用 gzip 后通常可以将体积压缩 70%以上
这里介绍下使用 webpack 进行 gzip 压缩的方式,使用 compression-webpack-plugin 插件
安装
npm install compression-webpack-plugin -Dvue.config.js 中引入
1 | const CompressionPlugin = require('compression-webpack-plugin'); |
验证插件的有效性:
重新打包,原来 407KB 的体积压缩为 108KB
经过上面的一系列优化,可以看到:
● 包体积由原来的 2.25M 减少到 407KB,压缩了 82%
● 打包速度由原来的 25386ms减少到 8949ms,提升了 65%
这些方式虽然很常规,但确实可以有效地提升项目的性能
首页资源, 白屏优化具体操作
1. 路由懒加载
SPA 项目,一个路由对应一个页面,如果不做处理,项目打包后,会把所有页面打包成一个文件,当用户打开首页时,会一次性加载所有的资源,造成首页加载很慢,降低用户体验
列一个实际项目的打包详情:
app.js 初始体积: 1175 KB
app.css 初始体积: 274 KB
将路由全部改成懒加载
1 | // 通过webpackChunkName设置分割后代码块的名字 |
重新打包后,首页资源拆分为 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 | <template> |
项目打包后,发现 home.js 和 about.js 均包括了该弹框组件的代码(在 dist 文件中搜索dialogInfo弹框组件)
当用户打开 home 页时,会一次性加载该页面所有的资源,我们期望的是用户触发按钮后,再加载该弹框组件的资源
这种场景下,就很适合用懒加载的方式引入
弹框组件懒加载:
1 | <script> |
重新打包后,home.js 和 about.js 中没有了弹框组件的代码,该组件被独立打包成 dialogInfo.js,当用户点击按钮时,才会去加载 dialogInfo.js 和 dialogInfo.css
最终,使用组件路由懒后,该项目的首页资源进一步减少约 11%
组件懒加载的使用场景
有时资源拆分的过细也不好,可能会造成浏览器 http 请求的增多
总结出三种适合组件懒加载的场景:
- 该页面的 JS 文件体积大,导致页面打开慢,可以通过组件懒加载进行资源拆分,利用浏览器并行下载资源,提升下载速度(比如首页)
- 该组件不是一进入页面就展示,需要一定条件下才触发(比如弹框组件)
- 该组件复用性高,很多页面都有引入,利用组件懒加载抽离出该组件,一方面可以很好利用缓存,同时也可以减少页面的 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 | <script defer src="vue.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 | <img src="" alt="" data-src="./images/1.jpg"> |
图片转 base64 格式
将小图片转换为 base64 编码字符串,并写入 HTML 或者 CSS 中,减少 http 请求
1 | // 安装 |












































