前端优化是一个永恒的话题,每个前端开发者都希望自己的页面能够快速加载,给用户良好的体验。但往往事与愿违。因此,本文从编码优化、构建优化、部署优化三方面入手进行web页面性能优化。
1. 编码优化
1.1. Css优化
1.1.1. 合理使用css选择器
CSS 查找样式表时是从右往左查询的,当遇到一个标签选择器如 span 时,会先遍历页面里所有的 span元素,然后先过滤掉祖先元素不是.name-and-status的元素,再过滤掉.name-and-status的祖先不是.readonly-instance-info的,依次像左查询,这个过程遍历了很多不会用到的标签,并且嵌套层级越多,匹配所要花费的时间代价也会更高。
不过现代浏览器在这一方面做了很多优化,不同选择器的性能差别并不明显,所以在使用选择器的时候注意以下几点:
-
保持简单,不要使用嵌套过多过于复杂的选择器,最好嵌套不超过三层以上,可以考虑使用类似BEM规范的方式进行css className的命名,有效避免更多的嵌套。
-
通配符和属性选择器效率最低,需要匹配的元素最多,尽量避免使用。
-
不要使用类选择器和ID选择器修饰元素标签,如h3#markdown-content,这样多此一举,还会降低效率。
-
不要为了追求速度而放弃可读性与可维护性。
1.1.2. 减少昂贵属性的使用
在浏览器绘制屏幕时,所有需要浏览器进行操作或计算的属性相对而言都需要花费更大的代价。当页面发生重绘时,它们会降低浏览器的渲染性能。所以在编写CSS时,我们应该尽量减少使用昂贵属性,如box-shadow/border-radius/filter/透明度/:nth-child等。
并不是说不要使用这些属性,而是当有两种方案选择时,可以优先选择性能消耗少的属性。
1.1.3. 使用BEM规范
BEM(块、元素、修饰符)是一种前端开发中的命名规范,旨在提高代码的可读性、可维护性和可协作性。BEM是由Yandex团队提出的一种前端CSS命名方法论,它使用简洁的命名规则来描述组件及其状态,使代码易于理解、易于维护、易于扩展和重构。
BEM命名规则:
- 块(Block):块是一个独立的实体,代表一个可重用的组件或模块。
块的类名应该使用单词或短语,并使用连字符(-)作为分隔符。例如:.header、.menu。
- 元素(Element):元素是块的组成部分,不能独立存在。
元素的类名应该使用双下划线(__)作为分隔符,连接到块的类名后面。例如:.menu__item、.header__logo。
- 修饰符(Modifier):修饰符用于描述块或元素的不同状态或变体,用来更改外观或行为。
修饰符的类名应该使用双连字符(–)作为分隔符,连接到块或元素的类名后面。例如:.menu__item–active、.header__logo–small。
BEM 命名法的好处:
BEM的关键是,可以获得更多的描述和更加清晰的结构,从其名字可以知道某个标记的含义。于是,通过查看 HTML 代码中的 class 属性,就能知道元素之间的关联。
常规的命名法示例:
<div class="article"><div class="body"><button class="button-primary"></button><button class="button-success"></button></div></div>
这种写法从 DOM 结构和类命名上可以了解每个元素的意义,但无法明确其真实的层级关系。在 css 定义时,也必须依靠层级选择器来限定约束作用域,以避免跨组件的样式污染。
使用了 BEM 命名方法的示例:
<div class="article"><div class="article__body"><div class="tag"></div><button class="article__button--primary"></button><button class="article__button--success"></button></div></div>
通过 BEM 命名方式,模块层级关系简单清晰,而且 css 书写上也不必作过多的层级选择。
1.1.4. tailwindcss第三方插件
Tailwind CSS是一个功能类优先的CSS框架,它集成了flex、text-center这样的类,可以实现开发者无需离开HTML页面,同时也无需编写复杂的 CSS 选择器或嵌套规则,很大程度减少了css代码体积,也免于起名的烦恼。不过不得不承认,原子化的CSS方案,都会或多或少的增大HTML的文件体积,因为我们总归是将样式代码从CSS文件里挪用到了HTML的标签里,不过搭配gzip压缩的话,会起到事半功倍的效果,因为gzip的压缩效率与文档的字符重复程度呈正相关,换句话来说,也就是当文档里的重复字符越多时,文档压缩后的体积就会越小,而tailwindcss书写的class,绝大多数都是重复的类名,这样大大减少了类名所造成的体积影响。
1.1.5. 确保文本在网页字体加载期间保持可见状态
利用 font-display 这项 CSS 功能,确保文本在网页字体加载期间始终对用户可见。
1.2. Js书写优化
1.2.1. 条件判断语句优化
- 离散值多分支逻辑优化
if(value === 0) {// to do} else if(value === 1) {// to do} else if(value === 2) {// to do} else {// to do}
匹配的条件仅为一两个离散值时,if-else的处理时间一般会很快,而当匹配的条件为多个枚举值时,可优先选择switch语句,switch语句会比if-else有更高性能表现。
优化后:
switch(value) {case 0:// to dobreak;case 1:case 2:// to dobreak;default:// to do}
switch可以清晰的表明判断条件和返回值之间的对应关系,同时switch还能使不同的条件指向相同的出来逻辑,具有更好的代码可读性。
虽然switch语句在逻辑上确实比else if语句简单,但是代码本身也有点多,还可以利用对象属性查询的方式达到条件判断的目的,代码优化如下:
let enums = {'A': handleA,'B': handleB,'C': handleC,'D': handleD,'E': handleE}function action(val){let handleType = enums\[val]handleType()}
实际上还可以通过Map来进一步优化枚举值的条件判断的代码。
对比基于对象属性的映射方式,Map具有许多优点:
-
对象的键只能是字符串或符号,而Map的键可以是任何类型的值。
-
使用Map size属性可以轻松获取Map的键/值对的数量,而对象的键/值对的数量只能手动确定。
-
具有极快的查找速度。 代码优化如下:
let enums = new Map(\[\['A', handleA],\['B', handleB],\['C', handleC],\['D', handleD],\['E', handleE]])function action(val){let handleType = enums.get(val)handleType()}
条件判断的书写建议:
-
当匹配的条件仅有一两个离散值判断时,使用if-else语句。
-
当匹配的条件超过一两个但少于十个离散值时,使用switch语句。
-
当匹配条件超过十个离散值时,使用对象索引或者Map数据结构的查找方式。
- 排非策略
比如用户登录场景,如果用户名和密码输入框为空,那么我们就提示用户”用户名和密码不能为空”;如果有值,就执行登录的操作。 优化前
if (user && password) {// 逻辑处理} else {throw('用户名和密码不能为空!')}
优化后
if (!user || !password) return throw('用户名和密码不能为空!')// 逻辑处理
表单提交时,需要提前排除那些提交不规范的内容,通常情况下,表单提交遇到不符合我们要求大于我们提交成功的情形,排非策略是个很不错的选择。
- if-else优化
在if-else语句的使用过程中,如果可以预估条件被匹配到的频率,按照频率的高低顺序来排列if-else语句,可以让匹配频率高的条件更快执行。如果匹配频率高的条件放在了最后的else中,那么之前的所有条件都需要经历一遍,从而会增加程序花费在条件判断上的时间。
1.2.2. 循环语句优化
相比于条件判断语句,循环语句对程序的执行性能影响更大,一个循环语句的循环执行次数直接影响程序的时间复杂度。本小节对不同循环语句的执行时间做了对比。
如下是对于数组循环的简单实验对比:
let arr = [];arr.length = 1000000;arr.fill(1);let sum1 = 0,sum2 = 0,sum3 = 0,sum4 = 0,sum5 = 0,sum6 = 0;(function () {console.time("for循环");for (var i = 0; i < arr.length; i++) {sum1 = sum1 + arr[i];}console.timeEnd("for循环");})();
(function () {console.time("while循环");var i = 0;while (i < arr.length) {sum2 = sum2 + arr[i];i++;}console.timeEnd("while循环");})();
(function () {console.time("do-while");var i = 0;do {sum3 = sum3 + arr[i];i++;} while (i < arr.length);console.timeEnd("do-while");})();
(function () {console.time("for-in");for (var i in arr) {sum4 = sum4 + arr[i];}console.timeEnd("for-in");})();
(function () {console.time("for-of");for (var i of arr) {sum5 = sum5 + i;}console.timeEnd("for-of");})();
(function () {console.time("forEach");arr.forEach((i) => {sum6 = sum6 + i;});console.timeEnd("forEach");})();
最终执行时间结果如下:
从结果中可以明显看到,for、while、do-while三种基本的循环方法耗时最少,而for-in耗时最多,这是因为for-in不仅会遍历自身对象的可枚举属性,而且也会遍历整个原型链上的属性,所以其循环速度会比其他方式慢一些。如果对性能有要求则尽量不使用for-in循环。
不同循环方法的执行性能有所不同,在实际开发中,我们也不能过分追求性能而忽略代码的可读性和可维护性。
1.2.3. 正确使用filter、map函数
1、正确使用循环。应该先执行 filter,再执行 map。 因为 filter 之后,数量会变少,如下示例。
2、正确使用循环。如果不使用结果,应该使用 forEach 遍历。
-
优化嵌套循环。上面的例子充其量就是 N * 2,嵌套循环就会变成 N²。一般出现在树状结构,比如说权限。 例子采用去重来说明差距
-
用 lodash 中的方法来简化数据处理逻辑。 可以实现代码少、效率高、语义明确
1.2.4. promise优化
通过chrome自带的性能数据分析进行页面性能测量,可以发现TTI这项值异常高,那么问题主要出在哪呢?
从NetWork瀑布图中可看出,在页面最后调用了大量的同步接口,通过分析这部分同步接口在代码中的作用可知:这是为了渲染页面上所有实例的内存空间,需要调用页面上所有实例的内存数据。
既然知道了原因,如何优化呢?。在这里,可通过使用promise.all对接口进行异步处理。并且可以使用bluebird提升promise的执行速度
bluebird是一个流行的Promise库,用于处理异步操作。它提供了强大的异步编程工具,使得编写和管理异步代码变得更加简单和可靠。
-
Promise功能增强: bluebird提供了许多额外的功能和操作,超出了原生Promise的范围。它支持超时控制、并发控制、错误处理、重试、进度报告和取消等功能。这些功能使得处理复杂的异步控制流变得更加容易。
-
性能优化:bluebird在性能方面进行了优化,比原生Promise更快。 它实现了高效的异步调度和内存管理,以提供更快的执行速度和更低的资源消耗。这使得在大规模异步操作的情况下,bluebird可以提供更高效的性能。
-
错误追踪和调试:bluebird提供了更好的错误追踪和调试支持。 当使用bluebird进行异步操作时,它会生成详细的错误堆栈跟踪信息,包括异步操作链的每个步骤。这使得在调试和排查错误时更容易定位问题所在。
-
可互操作性:bluebird的api与原生Promise相似,因此可以与其他使用Promise的库和代码进行互操作。 这使得在现有的代码基础上,迁移到bluebird更加容易,并且可以充分利用bluebird提供的额外功能。
最后,通过上述处理,TTI这个值有了很大改善。
1.3.图片优化
1.3.1. 使用合适类型的图片
下图总结了各类图片类型的优势、缺点和使用场景,在开发中,选取合适的图片类型是图片优化的第一步。
图片类型选取的优先级可参考下图:
1.3.2. 使用合适的图片尺寸
在使用图片之前,尽量确认图片需要显示的大小和分辨率(设备像素比)。如果需要显示一个较小的图像,尽量不要使用很大的图片并使用限制宽高的方式来缩小它,当然如果是移动端,因为分辨率导致设备像素比的不同,因此需要显示图片往往是实际尺寸的2倍或者3倍。
因此我们可以通过使用合适大小的图片来减少图片文件的体积,优化加载时间。
1.3.3. 图片压缩
1.3.3.1. 图片压缩工具
没经过优化的图像不仅增加了加载时间,而且占用了用户和网络的带宽。对于拥有大量图片和流量的较大型网站影响更为严重。
该文档介绍了一些图片体积优化的工具,这些小工具在压缩图片体积的同时,不影响图片质量。
1.3.3.2. SVG体积压缩
可以进行SVG体积压缩的插件有很多,本文以svgo插件为例进行svg压缩,实际可减少约50%的体积。
1)npm install -g svgo svgo-loader
2)svgo默认配置项可见下图,详细配置见SVGO配置文档
// svg压缩config.module.rule('svg').test(/\.svg$/).use('svgo-loader').loader('svgo-loader').tap((options) => ({...options})).end();
1.3.4. WebP格式图片
WebP格式是一个新推出的图片文件格式,同时支持有损压缩与无损压缩,支持透明图层和多图片动图。WebP可对网页图片有效压缩而不影响图片清晰度,从而节省带宽,提高图片下载速度。
WebP格式特点:
-
体积:在质量相同的情况下,WebP格式图像的体积要比JPEG格式图像小40%,比PNG文件小26%。
-
兼容性:WebP目前支持Chrome内核、Edgel8+、Safari 14+、Android的WebView等 浏览器,不支持ie。
-
WebP转换工具:https://cloudconvert.com/png-to-webp
位图WebP优化:需要注意浏览器兼容性
<picture class="pic"><source type="image/webp" srcset="a.webp"><img class="img" src="a.jpg"></picture>
1.3.5. 图片懒加载
懒加载也叫做延迟加载、按需加载,指的是在长网页中延迟加载图片数据,是一种较好的网页性能优化的方式。在比较长的网页或应用中,如果图片很多,所有的图片都被加载出来,而用户只能看到可视窗口的那一部分图片数据,这样就浪费了性能。
1.3.5.1. 懒加载的实现原理
-
将图片的src属性置空,阻止图片加载:
一般采用 data-* 自定义数据属性代替 src 来存储图片资源路径。 -
判断图片是否在可视区域内
我们可以借助一些 API 来实现这一功能: HTMLElement.offsetTop、Element.scrollTop、Element.clientHeight、Window.innerHeight、IntersectionObserve
本文以IntersectionObserve为例,讲解图片是如何进行懒加载,提升性能的。
点击查看文档
1.3.5.2. 懒加载实践
场景:监控告警页面大致有30+个图表,每个图表都会调用相应的接口获取数据,在页面初始化的时候,一股脑调用了所有的接口,而可视窗口仅展示几个图表,这样导致性能浪费,lighthouse分数又很低。
使用IntersectionObserve API实现图表懒加载,当图表进入可视窗口时再调用对应的数据接口。
lazyLoadChart() {const intervalId = setInterval(() => {const chartContainers = document.querySelectorAll('.lazy');if (chartContainers.length > 0) {clearInterval(intervalId);const observer = new IntersectionObserver((entries) => {entries.forEach((entry) => {if (entry.isIntersecting) {// 进入视野后,移除lazy标记,加载图表数据entry.target.classList.remove('lazy');const index = entry.target.elementIndex;const chartItem = this.chartList\[index];this.getChartData(chartItem);observer.unobserve(entry.target);}});});chartContainers.forEach((container, index) => {container.elementIndex = index;observer.observe(container);});}}, 500);
1.4. 第三方插件优化
1.4.1. 正确使用依赖包
安装依赖包时,应该正确区分dependencies和devDependencies的区别,避免将只在开发环境下依赖的模块打包到生产环境中。
同时,在不影响使用的情况下,优先选择体积小的依赖包,比如Dayjs和Moment.js是两个时间组件库,Dayjs的体积和性能都优于Moment,Dayjs适用于简单的时间处理,而Moment适用于复杂的时间操作和国际化项目,所以针对不同的应用场景和特点,选择合适的依赖包。
1.4.2. 组件库按需引入
项目中引入的组件库、依赖包是否可以按需引入,比如在我们项目中仅使用了echarts的折线图图表,这时就可以根据echarts的官方文档按需引入折线图表组件,按需引入后的包体积会减小。
1.4.3. 使用jscpd插件检测项目代码重复率
针对多人协作的老项目,我们可以使用jscpd插件检测项目源代码中是否有完全重复的代码。
- jscpd安装
yarn global add jscpd
- 在项目的package.json中配置jscpd
{..."jscpd": {"threshold": 0.1, // 重复率阈值"reporters": ["html","console","badge"], // report输出类型"ignore": ["node_modules","miniprogram_npm","pages/test","config/mock.js "], // 忽略文件/夹"absolute": true, // report路径采用绝对路径"gitignore": true // gitignore文件也忽略}...}
- 输出报告
jscpd ./src -o 'report'
通过生成本地网页直接展示所有的检测报告,并且还能查看到重复的代码具体位置。知道了项目中的重复代码,给我们优化代码结构,提炼代码逻辑,增强代码的可维护性、可扩展性和可复用性方面,都能带来比较多的好处,研发效率的提高也是随之而来的。
在我们的项目中,检测出两个重复率高达95%的组件,这种情况就可以看是否可以复用,达到优化的效果。
1.5. 资源加载优化
1.5.1. async
当浏览器遇到带有 async 属性的 script 时,请求该脚本的网络请求是异步的,不会阻塞浏览器解析 HTML,一旦网络请求回来之后,如果此时 HTML 还没有解析完,浏览器会暂停解析,先让 JS 引擎执行代码,执行完毕后再进行解析,图示如下:
当然,如果在 JS 脚本请求回来之前,HTML 已经解析完毕了,那就啥事没有,立即执行 JS 代码,如下图所示:
所以 async 是不可控的,因为执行时间不确定,你如果在异步 JS 脚本中获取某个 DOM 元素,有可能获取到也有可能获取不到。而且如果存在多个 async 的时候,它们之间的执行顺序也不确定,完全依赖于网络传输结果,谁先到执行谁。这意味着如果某个脚本依赖于另一个脚本,那么它可能会在依赖脚本执行之前执行,导致错误。
应用场景:埋点
注意:async 特性仅适用于外部脚本,如果 script 脚本没有 src,则会忽略 async 特性。
1.5.2. defer
当浏览器遇到带有 defer 属性的 script 时,获取该脚本的网络请求也是异步的,不会阻塞浏览器解析 HTML,一旦网络请求回来之后,如果此时 HTML 还没有解析完,浏览器不会暂停解析并执行 JS 代码,而是等待 HTML 解析完毕再执行 JS 代码,图示如下:
如果存在多个 defer script 标签,浏览器(IE9及以下除外)会保证它们按照在 HTML 中出现的顺序执行,不会破坏 JS 脚本之间的依赖关系。
注意:
-
defer 属性仅适用于外部脚本,如果 script 脚本没有 src,则会忽略 defer 特性。
-
defer 属性对模块脚本(script type=‘module’)无效,因为模块脚本就是以 defer 的形式加载的。
1.5.3. 优化css和js加载执行
-
css放在里,尽早地进行样式解析,构建cssdom树
-
js放置位置不对,会使js的加载执行阻塞到页面的加载,引起页面空白带来不佳的用户体验 结合async和defer进行处理,方式如下:
-
js放在body底部,让js不阻塞html和css的解析
-
在script标签中添加defer
-
在script标签中添加async
-
2. 构建优化
2.1. 使用webpack合并小文件
合并js代码的意义:
-
减少网络请求: 每个文件都需要通过网络进行单独的请求和响应。通过将多个文件合并为一个文件,可以减少页面需要发起的网络请求次数,从而降低延迟和提高加载速度。
-
缓存优化: 合并代码可以提高浏览器缓存的效率。当多个页面共享同一个文件时,浏览器只需要下载并缓存一次该文件,而不是针对每个页面都下载一次。这样可以减少整体的重复下载和提高缓存命中率。
-
减少页面渲染阻塞: 当浏览器下载和执行js代码时,它会阻塞页面的渲染过程。通过合并js代码,可以减少因为多个js文件的下载和执行而造成的页面渲染阻塞时间,提高页面的响应速度和用户体验。
-
代码优化和压缩: 在合并js代码之前,可以对代码进行优化和压缩,去除空格、注释和不必要的代码,从而减少文件大小,并提高代码的执行效率。
2.2. gzip压缩
使用compression-webpack-plugin插件在webpack中启用gzip压缩。步骤如下:
-
安装compression-webpack-plugin:npm install --save-dev compression-webpack-plugin
-
在webpack配置文件中,引入并使用CompressionPlugin
这段配置会在你的构建过程中,为javascript文件生成.js.gz的gzip压缩文件,如下图所示
2.3. 图片压缩
在webpack中启用图片压缩,可以使用image-webpack-loader插件。步骤如下
-
安装image-webpack-loader:npm install --save-dev image-webpack-loader
-
在webpack配置文件中进行使用:
2.4. 资源优先级
2.4.1. 预链接preconnect dns-prefetch
当浏览器向服务器请求一个资源的时候,需要建立连接,而建立一个安全的连接需要经历以下 3 个步骤:
-
查询域名并将其解析成 IP 地址(DNS Lookup);
-
建立和服务器的连接(Initial connection);
-
加密连接以确保安全(SSL);
以上 3 个步骤浏览器都需要和服务器进行通信,而这一来一往的请求和响应势必会耗费不少时间。
所以若能提前完成上述这些步骤,则会给用户带来更为流畅的用户体验。解决方案就是所谓的预连接:proconnect、dns-prefetch。
2.4.1.1. proconnect
当我们的站点需要对别的域下的资源进行请求的时候,就需要和那个域建立连接,然后才能开始下载资源,如果我都已经知道了是和哪个域进行通信,那不就可以先建立连接,然后等需要进行资源请求的时候就可以直接进行下载了。
假设当前站点是 https://a.com,这个站点的主页需要请求 https://b.com/b.js 这个资源。对比正常请求和配置了 preconnect 时候的请求,它们在请求时间轴上看到的表现是不一样的:
通过如下配置可以提前建立和 https://b.com 这个域的连接:
<link rel="preconnect" href="https://b.com">
通过 preconnect 提早建立和第三方源的连接,可以将资源的加载时间缩短 100ms ~ 500ms,这个时间虽然看起来微不足道,但是它是实实在在的优化了页面的性能,提升了用户的体验。
2.4.1.2. dns-prefetch
通常我们记住一个网站都是通过它的域名,但是对于服务器来说,它是通过 IP 来记住它们的。浏览器使用 DNS 来将站点转成 IP 地址,这个是建立连接的第一步,而这一步骤通常需要花费的时间大概是 20ms ~ 120ms。因此,可以通过 dns-prefetch 来节省这一步骤的时间。
居然能通过 preconnect 来减少整个建立连接的时间,那为什么还需要 dns-prefetch 来减少建立连接中第一步 DNS 查找解析的时间呢?
假如页面引入了许多第三方域下的资源,而如果它们都通过 preconnect 来预建立连接,其实这样的优化效果反而不好,甚至可能变差,所以这个时候就有另外一个方案,那就是对于最关键的连接使用 preconnect,而其他的则可以用 dns-prefetch。
可以按照如下方式配置 dns-prefetch:
<link rel="dns-prefetch" href="https://cdn.bootcss.com">
注意:
-
如果页面需要建立与许多第三方域的连接,则将它们预先连接会适得其反。 preconnect 提示最好仅用于最关键的连接。对于其他的,只需使用link rel=“dns-prefetch” 即可节省第一步的时间DNS查找。
-
dns-prefetch 仅对跨域域上的 DNS查找有效,因此请避免使用它来指向相同域。这是因为,到浏览器看到提示时,您站点域背后的IP已经被解析。
2.4.2. 预加载preload
preload属性用于指示浏览器在页面加载过程中提前加载指定的资源(如脚本、样式表、字体等)。它可以在head标签中的link元素或者script元素上使用。
<link rel="preload" href="font.woff2" as="font">
适合的场景:
-
有字体的 CSS 先加载,防止字体突然的变化。
-
按需加载语言包的语言包 js 文件最先加载。
实践:在实际开发中,我们并不知道编译后的资源命名,在link中直接添加preload并不现实,一般情况是在webpack中进行配置的,如下以vue框架为例:
注意:若该页面在3s内未应用preload的资源,控制台会有如下提示,需要注意并解决。
2.4.3. 预提prefetch
prefetch(链接预取)是一种浏览器机制,其利用浏览器空闲时间来下载或预取用户在不久的将来可能访问的文档。网页向浏览器提供一组预取提示,并在浏览器完成当前页面的加载后开始静默地拉取指定的文档并将其存储在缓存中。当用户访问其中一个预取文档时,便可以快速的从浏览器缓存中得到。
使用方法同上节的preload。
适用场景:
-
异步加载的模块(典型的如单页应用中的非首页)
-
大概率即将被访问到的资源
2.4.4. prefetch、proload默认配置
默认情况下,Vue CLI 会为所有初始化渲染需要的文件自动生成 preload 提示,为所有作为 async chunk 生成的 JavaScript 文件(通过动态 import() 按需 code splitting 的产物)自动生成 prefetch 提示。可以通过以下方式删除默认配置:
大家可以根据实际项目具体分析,进行合理的配置。
2.5. 包体积分析
打包后的文件大小直接影响我们访问的加载速度,所以我们要知道哪些打包文件存在性能问题,Network面板中的大小一列展示了每个资源文件的大小,我们可以结合webpack-bundle-analyzer文件大小分析工具来分析在打包或者包引入的过程中是否有优化的点。vue-cli有自带的webpack包体积优化工具,只需在package.json中的“scripts"下配置如下命令即可:
-
“report”: “vue-cli-service build --modern --report”
-
执行npm run report,会在dist目录下生成一个report.html文件。通过浏览器打开report.html,如下图所示,通过图我们可以分析项目bundle大小
一般又如下几个通用优化点:
-
按需引入:项目中引入的组件库、依赖包是否可以按需引入,比如在我们项目中仅使用了echarts的折线图图表,这时就可以根据echarts的官方文档按需引入折线图表组件,按需引入后的包体积会减小
-
cdn引入:有一些外部引入的资源可以通过cdn的方式导入,比如体积比较大的iview、element-ui等组件库。
-
正确使用依赖包:安装依赖包时,应该正确区分dependencies和devDependencies的区别,避免将只在开发环境下依赖的模块打包到生产环境中。同时,在不影响使用的情况下,优先选择体积小的依赖包
通过上述优化,优化后包体积减小了30%左右。如下图所示
3. 部署优化
3.1. 缓存
这里我们只针对浏览器缓存做优化,浏览器缓存是指浏览器在第一次访问一个网页时,将页面的各种资源文件(如样式表、JavaScript文件、图片等)缓存到本地磁盘上。当用户再次访问该网页时,浏览器会先检查本地缓存是否存在对应的资源文件,如果存在且未过期,就直接从本地加载资源文件,而不是重新下载。
浏览器缓存优化是一种可以显著提高网站性能的技术,因为它减少了网络请求次数,降低了服务器的负载,同时也缩短了用户等待页面加载的时间。
3.1.1. 强缓存、协商缓存
良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度 通常浏览器缓存策略分为两种:强缓存和协商缓存
基本原理
1)浏览器在加载资源时,先根据这个资源的一些http header判断它是否命中强缓存,强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。
2)当强缓存没有命中的时候,浏览器一定会发送一个请求到服务器,通过服务器端依据资源的另外一些http header验证这个资源是否命中协商缓存,如果协商缓存命中,服务器会将这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载这个资源;
3)强缓存与协商缓存的共同点是:如果命中,都是从客户端缓存中加载资源,而不是从服务器加载资源数据;区别是:强缓存不发请求到服务器,协商缓存会发请求到服务器。
4)当协商缓存也没有命中的时候,浏览器直接从服务器加载资源数据。
3.1.1.1. 强缓存
强缓存通过Expires和Cache-Control两种响应头实现。一般服务端设置。
- Expires
Expires字段用于设置资源的过期时间。如果当前时间在Expires字段的值之前,那么缓存资源就是有效的。
不过,Expires 受限于本地时间,如果修改了本地时间,可能会造成缓存失效。
示例1:设置缓存的过期时间为 2024年1月1日
Expires: Mon, 1 Jan 2024 00:00:00 GMT
示例2:设置缓存有效时间为24小时,下面的示例表示在24小时内请求资源会命中强缓存,24小时后重新请求新的资源。
Expires: 24h
比如:2024.1.16 09:00请求了该资源,并且该资源设置了强缓存expires: 24h,则它的过期时间为2024.1.17 09:00。
- Cache-Control
Cache-Control 出现于 HTTP / 1.1,优先级高于 Expires ,表示的是相对时间。
Cache-Control的取值如下:
指令 | 作用 |
---|---|
Public | 允许所有用户缓存,包括终端和CDN等中间代理服务器 |
private | 只允许私有缓存,比如浏览器缓存,不允许中继缓存服务器进行缓存 |
no-cache | 不直接使用缓存,需要先向服务器验证资源是否过期 |
no-store | 不缓存资源 |
max-age | 服务端告知客户端浏览器响应资源的过期时长 |
S-maxage | 表示缓存在代理服务器中的过期时长,仅当设置了public属性值时才有效 |
例如,设置强缓存的有效期为3600秒
Cache-Control: max-age=3600
3.1.1.2. 协商缓存
当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中,如果协商缓存命中,请求响应返回的http状态为304并且会显示一个Not Modified的字符串。
协商缓存是利用的是【Last-Modified,If-Modified-Since】和【ETag、If-None-Match】这两对Header来管理的。对于静态资源文件如 JS、CSS、图片等,nginx 默认情况下会自动生成Last-Modified和 ETag 响应头,这是由 ngx_http_static_module 模块提供的功能。
L1. ast-Modified,If-Modified-Since
Last-Modified 表示本地文件最后修改日期,浏览器会在request header加上If-Modified-Since(上次返回的Last-Modified的值),询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来。
2、ETag、If-None-Match
Etag就像一个指纹,资源变化都会导致ETag变化,跟最后修改时间没有关系,ETag可以保证每一个资源是唯一的
If-None-Match的header会将上次返回的Etag发送给服务器,询问该资源的Etag是否有更新,有变动就会发送新的资源回来。
ETag的优先级比Last-Modified更高
整体流程图
3.1.2. 缓存
标识强缓存状态码返回200,同时会有【来自磁盘缓存】的标志
协商缓存状态码返回304,同时会有【Not Modified】的标志
这里的停用缓存,是强缓存和协商缓存都停用了。
3.1.3. 优化实践
浏览器请求资源时,nginx服务器对文件进行gzip压缩后返回给浏览器。前端不用做任何修改正常打包,但这样会消耗服务器性能。nginx开启gzip压缩,代码如下:
在Nginx中缓存静态资源是提高网站性能和减少服务器负载的重要策略之一。通过缓存静态资源,您可以减少对后端服务器的请求次数,加快页面加载速度,并提供更好的用户体验。以下是一个详细的步骤,演示了如何在Nginx中缓存静态资源:
打开Nginx的配置文件,配置如下:
location ~* \.(ico|gif|jpg|jpeg|png)$ {expires 30d;}location ~* \.(css|js|txt|xml|swf|wav)$ {expires 24h;}location ~ .*\.(json)$ {# 每次请求都检查文件是否更新add_header Cache-Control "no-cache,must-revalidate";expires 24h;}location ~* \.(eot|ttf|otf|woff2?|svg)$ {expires max;}
注意:
-
实际上nginx配置的expires指令,会同时转换成http的Cache-Control和Expires头。
-
默认情况下,Nginx不会对HTML文件进行缓存。这是因为HTML文件通常被认为是动态生成的内容,可能包含用户特定的数据或者频繁更改的内容,因此缓存这类文件可能会导致用户获取过期或不正确的数据。
-
在这个配置中,我们使用了
location
指令来定义如何缓存静态资源。具体地说:-
location ~* \.(jpg|jpeg|png|gif|css|js)$
:这个正则表达式匹配图片、CSS和JavaScript文件。 -
expires 7d
:设置缓存过期时间,这里是7天。 -
add_header Cache-Control "public, max-age=604800"
:设置Cache-Control头,允许客户端缓存资源并设置最大缓存时间为7天。
-
3.2. 开启gzip压缩
gzip压缩可以减小资源大小,减少传输数据量,提高页面加载速度。对于文本文件来说,gzip压缩可以将其压缩至原始大小的至少40%。不过需要注意的是,图片、视频文件不应该使用gzip压缩。
在nginx配置文件中可以开启gzip压缩,如下为简单示例:
# 开启gzip压缩gzip on;# 小于1k的文件不压缩gzip_min_length 1k;# 使用 deflate 压缩算法gzip_http_version 1.1;# 压缩级别,1(最快)到 9(最小压缩)gzip_comp_level 9;# 使用 gzip 压缩的文件类型gzip_types text/plain application/x-javascript text/css application/xml text/javascript application/javascript application/json;# 压缩后文件的缓存时间gzip_disable "MSIE [1-6]\.";# 是否在响应头中删除 Vary HTTP 响应头gzip_vary on;
可以在Network中查看是否开启了gzip压缩。
3.3. Cdn
前端CDN,即内容分发网络(Content Delivery Network),是一种网络架构,旨在加速静态资源的传输,如html、图片、Javascript和CSS文件等。通过在网络中增加一层新的架构,CDN可以将网站内容分发到不同的节点上。这样,用户可以更快、更稳定地获取所需内容,减轻源服务器的负担,缓解网络拥挤,并提高用户访问网站的响应速度和体验。
3.3.1. 外部文件cdn引入
有一些外部引入的资源可以通过cdn的方式导入,比如体积比较大的iview、element-ui等组件库,从而减小打包体积,提高资源加载速度。在项目中的vue.config.js文件中进行如下配置:
chainWebpack: config => {/*** 添加CDN参数到htmlWebpackPlugin配置中*/config.plugin("html").tap(args => {args[0].cdn = {css: ["xxx", "xxx"],js: ["xxx", "xxx"]};return args;});}configureWebpack: {externals: {vue: 'Vue',xxx}}
这样webpack将不会打包cdn引入的第三方资源,减小打包后的包体积,缓解网站服务器的压力,加快首屏加载的速度。
3.3.2. 前端静态资源cdn部署
上一节介绍了外部文件通过cdn引入的方式,除此之外,前端静态资源也可以通过cdn的方式引入,加速网站的加载速度,提高稳定性和可用性。
上图为前端cdn的整体架构图,前端静态资源包通过部署的upload模块服务上传到对应的对象存储桶中,用户登录移动云官网访问产品页面发送静态资源请求,静态资源请求通过op网关等转发,从对应的对象存储桶中获取资源返回给用户。
下面是Upload模块的具体实践步骤:
-
打包制作基础镜像my-cdn-upload:1.0.0。
-
将基础镜像运行为容器服务
docker run --name my-cdn-upload --privileged -itd my-cdn-upload:1.0.0。
- 进入容器服务中
docker exec -it ${id} bash。
- 创建上传路径
mkdir mysql-order。
- 退出容器,并将dist包上传到该路径下
docker cp ./dist ${id} :/mysql-order/.
- 将容器封装成镜像
docker commit ${id} ${imageName}。
上图为静态资源请求流程:
-
客户端访问产品页面发送html、Javascript和CSS等静态资源请求。
-
静态资源请求通过op网关及apisixRoute共同转发。
-
请求到达cdn服务,判断该请求的静态资源文件类型。
-
请求Html/json类型的静态资源文件,从own对象存储桶中获取资源,并返回给客户端。
-
请求非Html/json类型的静态资源文件,将请求进行301重定向,从op对象存储桶中获取资源,并返回给客户端。
ApisixRouter详细配置如下:
apiVersion: apisix.apache.org/v2alpha1kind: ApisixRoutemetadata:name: dpd-mysqldb-order-webnamespace: paas-ddsspec:http:- name: releasepriority: 10match:hosts:- order.mysqldb.test.internalpaths:- /*plugins:- name: proxy-rewriteenable: trueconfig:regex_uri: ["/*", "/dpd-mysqldb-order-web/v5.3.0/"] #修改为存储桶内文件路径,每次版本迭代时需要修改该文件路径headers:dest-bucket: upload-web #dest-bucket为存储桶名pack-mode: history #采用history路由需要添加该headerbackends:- serviceName: dpd-esd-cdn-releaseservicePort: 3001
CDN技术对于前端开发来说是一个非常有用和值得应用的技术,可以极大地提升网站的性能和用户体验。
4. 利用chrome面板进行性能优化
4.1. 如何使用performance Insight性能测量工具
4.1.1. 如何查看影响LCP的最大元素
在【性能数据分析】工具中,在右侧栏展示了LCP的元素,如下图,该页面的最大元素是一个图片, 其优化点是将最大元素尽可能早的获取和渲染,比如在我们项目中,这个元素是根据后端接口的相应字段判断是否展示,导致LCP的值很大。通过分析代码逻辑发现,该元素可以在初始化的时候先获取并渲染,然后再根据后端接口判断是否展示,该页面有个loading遮罩层,也不会影响用户体验。
优化后的LCP值如下:
4.2. 使用Network进行排查接口阻塞等
查看网络瀑布,观察是否存在阻碍性的资源或接口请求,下图的示例就是一个阻碍性的接口请求,查看代码逻辑发现可以将这个接口换成响应较快的接口;并发现该接口并不是每次都要请求的,因此增加限制条件,在需要调用该接口的场景再去调用。