背景
随着业务的不断迭代,项目日渐壮大,为了给用户提供更优的体验,性能优化是前端开发避不开的话题。一个优秀的网站必然是拥有丰富功能的同时具有比较块的响应速度,想必我们浏览网页时都更喜欢丝般顺滑的感受。
最近在学习整理前端性能优化方面的知识,看了很多的文章,感觉文章多了比较零散,学习效率不高,所以在阅读和学习其他优秀博客文章的同时自己做了整理和归纳,与大家一起学习和共勉。
本文相关图文内容多来自于稀土掘金社区,引用参考文献出处均在文章末尾显著标注。
如有侵权,联系删除
一、性能优化的本质
性能优化的目的,就是为了提供给用户更好的体验,这些体验包含这几个方面:展示更快、交互响应快、页面无卡顿情况。
更详细的说,就是指,在用户输入url到站点完整把整个页面展示出来的过程中,通过各种优化策略和方法,让页面加载更快;在用户使用过程中,让用户的操作响应更及时,有更好的用户体验。
对于前端工程师来说,要做好性能优化,需要理解浏览器加载和渲染的本质。理解了本质原理,才能更好的去做优化。
二、雅虎性能优化军规
雅虎军规是雅虎的开发人员在总结了网站的不合理部分后,提出的优化网站性能提高的一套方法规则,非常适合初学者绕过这些坎。非常简要,供大家参考使用,希望对你们以后的开发过程中有所帮助。
三、性能优化指标
3.1 以用户为中心的性能指标
First Paint 首次绘制(FP)
这个指标用于记录页面第一次绘制像素的时间,如显示页面背景色。FP不包含默认背景绘制,但包含非默认的背景绘制。
First contentful paint 首次内容绘制 (FCP)
LCP是指页面开始加载到最大文本块内容或图片显示在页面中的时间。如果 FP 及 FCP 两指标在 2 秒内完成的话我们的页面就算体验优秀。Largest contentful paint 最大内容绘制 (LCP)
用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。官方推荐的时间区间,在 2.5 秒内表示体验优秀First input delay 首次输入延迟 (FID)
首次输入延迟,FID(First Input Delay),记录在 FCP 和 TTI 之间用户首次与页面交互时响应的延迟。Time to Interactive 可交互时间 (TTI)
首次可交互时间,TTI(Time to Interactive)。这个指标计算过程略微复杂,它需要满足以下几个条件:- 从 FCP 指标后开始计算
- 持续 5 秒内无长任务(执行时间超过 50 ms)且无两个以上正在进行中的 GET 请求
- 往前回溯至 5 秒前的最后一个长任务结束的时间
对于用户交互(比如点击事件),推荐的响应时间是 100ms 以内。那么为了达成这个目标,推荐在空闲时间里执行任务不超过 50ms( W3C 也有这样的标准规定),这样能在用户无感知的情况下响应用户的交互,否则就会造成延迟感。
Total blocking time 总阻塞时间 (TBT)
阻塞总时间,TBT(Total Blocking Time),记录在 FCP 到 TTI 之间所有长任务的阻塞时间总和。Cumulative layout shift 累积布局偏移 (CLS)
累计位移偏移,CLS(Cumulative Layout Shift),记录了页面上非预期的位移波动。页面渲染过程中突然插入一张巨大的图片或者说点击了某个按钮突然动态插入了一块内容等等相当影响用户体验的网站。这个指标就是为这种情况而生的,计算方式为:位移影响的面积 * 位移距离。
3.2 三大核心指标(Core Web Vitals)
3.2.1 Largest Contentful Paint (LCP)
LCP
代表了页面的速度指标,虽然还存在其他的一些体现速度的指标,但是上文也说过 LCP
能体现的东西更多一些。一是指标实时更新,数据更精确,二是代表着页面最大元素的渲染时间,通常来说页面中最大元素的快速载入能让用户感觉性能还挺好。
那么哪些元素可以被定义为最大元素呢?
标签
在svg中的image标签video标签
- CSS background url()加载的图片
- 包含内联或文本的块级元素
线上测量工具
- Chrome User Experience Report
- PageSpeed Insights
- Search Console (Core Web Vitals report)
- web-vitals JavaScript library
实验室工具
原生的JS API测量
LCP还可以用JS API进行测量,主要使用PerformanceObserver
接口,目前除了IE不支持,其他浏览器基本都支持了。
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
}).observe({type: 'largest-contentful-paint', buffered: true});
如何优化LCP
LCP可能被这四个因素影响:
- 服务端响应时间
- Javascript和CSS引起的渲染卡顿
- 资源加载时间
- 客户端渲染
3.2.2 First Input Delay (FID)
FID
代表了页面的交互体验指标,毕竟没有一个用户希望触发交互以后页面的反馈很迟缓,交互响应的快会让用户觉得网页挺流畅。
这个指标其实挺好理解,就是看用户交互事件触发到页面响应中间耗时多少,如果其中有长任务发生的话那么势必会造成响应时间变长。推荐响应用户交互在 100ms 以内.
线上测量工具
- Chrome User Experience Report
- PageSpeed Insights
- Search Console (Core Web Vitals report)
- web-vitals JavaScript library
原生的JS API测量
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const delay = entry.processingStart - entry.startTime;
console.log('FID candidate:', delay, entry);
}
}).observe({type: 'first-input', buffered: true});
如何优化FID
FID可能被这四个因素影响:
- 减少第三方代码的影响
- 减少Javascript的执行时间
- 最小化主线程工作
- 减小请求数量和请求文件大小
3.2.3 Cumulative Layout Shift (CLS)
CLS
代表了页面的稳定指标,它能衡量页面是否排版稳定。尤其在手机上这个指标更为重要,因为手机屏幕挺小,CLS
值一大的话会让用户觉得页面体验做的很差。CLS的分数在0.1或以下,则为Good。
浏览器会监控两桢之间发生移动的不稳定元素。布局移动分数由2个元素决定:impact fraction
和distance fraction
。
layout shift score = impact fraction * distance fraction
下面例子中,竖向距离更大,该元素相对适口移动了25%的距离,所以distance fraction是0.25。所以布局移动分数是 0.75 * 0.25 = 0.1875
。
但是要注意的是,并不是所有的布局移动都是不好的,很多web网站都会改变元素的开始位置。只有当布局移动是非用户预期的,才是不好的。
换句话说,当用户点击了按钮,布局进行了改动,这是ok的,CLS的JS API中有一个字段hadRecentInput,用来标识500ms内是否有用户数据,视情况而定,可以忽略这个计算。
线上测量工具
- Chrome User Experience Report
- PageSpeed Insights
- Search Console (Core Web Vitals report)
- web-vitals JavaScript library
实验室工具
原生的JS API测量
let cls = 0;
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
console.log(‘Current CLS value:’, cls, entry);
}
}
}).observe({type: ‘layout-shift’, buffered: true});
如何优化CLS
我们可以根据这些原则来避免非预期布局移动:
- 图片或视屏元素有大小属性,或者给他们保留一个空间大小,设置width、height,或者使用 unsized-media feature policy 。
- 不要在一个已存在的元素上面插入内容,除了相应用户输入。
- 使用animation或transition而不是直接触发布局改变。
3.3 性能工具:工欲善其事,必先利其器
Google开发的 所有工具 都支持Core Web Vitals的测量。工具如下:
- Lighthouse
- PageSpeed Insights
- Chrome DevTools
- Search Console
- web.dev’s提供的测量工具
- Web Vitals扩展
- Chrome UX Report API
工具:思考与总结
我们该如何选择?如何使用好这些工具进行分析?
- 首先我们可以使用Lighthouse,在本地进行测量,根据报告给出的一些建议进行优化;
- 发布之后,我们可以使用PageSpeed Insights去看下线上的性能情况;
- 接着,我们可以使用Chrome User Experience Report API去捞取线上过去28天的数据;
- 发现数据有异常,我们可以使用DevTools工具进行具体代码定位分析;
- 使用Search Console’s Core Web Vitals report查看网站功能整体情况;
- 使用Web Vitals扩展方便的看页面核心指标情况;
四、HTTP中的性能优化
4.1 HTTP 1.1
HTTP/1.1中大多数的网站性能优化技术都是减少向服务器发起的HTTP请求数。浏览器可以同时建立有限个TCP连接,而通过这些连接下载资源是一个线性的流程:一个资源的请求响应返回后,下一个请求才能发送。这被称为线头阻塞。
在HTTP/1.1中,Web开发者往往将整个网站的所有CSS都合并到一个文件。类似的,JavaScript也被压缩到了一个文件,图片被合并到了一张雪碧图上。合并CSS、JavaScript和图片极大地减少了HTTP的请求数,在HTTP/1.1中能获得显著的性能提升。
存在的问题:
为了尽可能减少请求数,需要做合并文件、雪碧图、资源内联等优化工作,但是这无疑造成了单个请求内容变大延迟变高的问题,且内嵌的资源不能有效地使用缓存机制。
4.2 HTTP/2.0的优势
4.2.1 二进制分帧传输
帧是数据传输的最小单位,以二进制传输代替原本的明文传输,原本的报文消息被划分为更小的数据帧。
原来Headers + Body的报文格式如今被拆分成了一个个二进制的帧,用Headers帧存放头部字段,Data帧存放请求体数据。分帧之后,服务器看到的不再是一个个完整的 HTTP 请求报文,而是一堆乱序的二进制帧。这些二进制帧不存在先后关系,因此也就不会排队等待,也就没有了 HTTP 的队头阻塞问题。
4.2.2 多路复用(MultiPlexing)
通信双方都可以给对方发送二进制帧,这种二进制帧的双向传输的序列,也叫做流(Stream)。HTTP/2 用流来在一个 TCP 连接上来进行多个数据帧的通信,这就是多路复用的概念。
在一个 TCP 连接上,我们可以向对方不断发送帧,每帧的 Stream Identifier 标明这一帧属于哪个流,然后在对方接收时,根据 Stream Identifier 拼接每个流的所有帧组成一整块数据。 把 HTTP/1.1 每个请求都当作一个流,那么多个请求变成多个流,请求响应数据分成多个帧,不同流中的帧交错地发送给对方,这就是 HTTP/2 中的多路复用。
流的概念实现了单连接上多请求 - 响应并行,解决了线头阻塞的问题,减少了 TCP 连接数量和 TCP 连接慢启动造成的问题。所以 http2 对于同一域名只需要创建一个连接,而不是像 http/1.1 那样创建 6~8 个连接
4.2.3 服务端推送(Server Push)
在 HTTP/2 当中,服务器已经不再是完全被动地接收请求,响应请求,它也能新建 stream 来给客户端发送消息,当 TCP 连接建立之后,比如浏览器请求一个 HTML 文件,服务器就可以在返回 HTML 的基础上,将 HTML 中引用到的其他资源文件一起返回给客户端,减少客户端的等待。
Server-Push 主要是针对资源内联做出的优化,相较于 http/1.1 资源内联的优势:
- 客户端可以缓存推送的资源
- 客户端可以拒收推送过来的资源
- 推送资源可以由不同页面共享
- 服务器可以按照优先级推送资源
4.2.4 Header 压缩(HPACK)
使用 HPACK 算法来压缩首部内容
4.3 HTTP/2 Web优化最佳实践
HTTP/2的优化需要不同的思维方式。Web开发者应该专注于网站的缓存调优,而不是担心如何减少HTTP请求数。通用的法则是,传输轻量、细粒度的资源,以便独立缓存和并行传输。
4.3.1 停止合并文件
在HTTP/2中合并文件不再是一项最佳实践。虽然合并依然可以提高压缩率,但它带来了代价高昂的缓存失效。即使有一行CSS改变了,浏览器也会强制重新加载你 所有的 CSS声明。
另外,你的网站不是所有页面都使用了合并后的CSS或JavaScript文件中的全部声明或函数。被缓存之后倒没什么关系,但这意味着在用户第一次访问时这些不必要的字节被传输、处理、执行了。HTTP/1.1中请求的开销使得这种权衡是值得的,而在HTTP/2中这实际上减慢了页面的首次绘制。
Web开发者应该更加专注于缓存策略优化,而不是压缩文件。将经常改动和不怎么改动的文件分离开来,就可以尽可能利用CDN或者用户浏览器缓存中已有的内容。
4.3.2 停止内联资源
内联资源是文件合并的一个特例。它指的是将CSS样式表、外部的JavaScript文件和图片直接嵌入HTML页面中。
4.3.3 停止细分域名
细分域名是让浏览器建立更多TCP连接的通常手段。浏览器限制了单个服务器的连接数量,但是通过将网站上的资源切分到几个域上,你可以获得额外的TCP连接。它避免了线头阻塞,但也带来了显著的代价。
细分域名在HTTP/2中应该避免。每个细分的域名都会带来额外的DNS查询、TCP连接和TLS握手(假设服务器使用不同的TLS证书)。在HTTP/1.1中,这个开销通过资源的并行下载得到了补偿。但在HTTP/2中就不是这样了:多路复用使得多个资源可以在一个连接中并行下载。同时,类似于资源内联,域名细分破坏了HTTP/2的流优先级,因为浏览器不能跨域比较优先级。
4.4 一些最佳实践依然有效
幸运的是,HTTP/2没有改变所有的Web优化方式。一些HTTP/1.1中的最佳实践在HTTP/2中依然有效。剩下的文章讨论了这些技巧,无论你在HTTP/1.1还是HTTP/2优化都能用上。
4.4.1 减少DNS查询时间
在浏览器可以请求网站资源之前,它需要通过域名系统(DNS)获得你的服务端IP地址。直到DNS响应前,用户看到的都是白屏。HTTP/2优化了Web浏览器和服务器之间的通信方式,但它不会影响域名系统的性能。
因为DNS查询的开销可能会很昂贵,尤其是当你从根名字服务器开始查询时,最小化网站使用的DNS查询数仍然是一个明智之举。使用HTML头部的可以帮助你提前获取DNS记录,但这不是万能的解决方案。
4.4.2 静态资源使用CDN
内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。
4.4.3利用浏览器缓存
你可以进一步利用内容分发网络,将资源存储在用户的本地浏览器缓存中,除了产生一个304 Not Modified响应之外,这避免了任何形式的数据在网络上传输。
4.4.4 最小化HTTP请求大小
尽管HTTP/2的请求使用了多路复用技术,在线缆上传输数据仍然需要时间。同时,减少需要传输的数据规模同样会带来好处。在请求端,这意味着尽可能多地最小化cookie、URL和查询字符串的大小。
4.4.5 最小化HTTP响应大小
当然了,另一端也是这样。作为Web开发者,你会希望服务端的响应尽可能的小。你可以最小化HTML、CSS和JavaScript文件,优化图像,并通过gzip压缩资源。
4.4.6 减少不必要的重定向
HTTP 301和302重定向在迁移到新平台或者重新设计网站时难以避免,但如有可能应该被去除。重定向会导致一圈额外的浏览器到服务端往返,这会增加不必要的延迟。 你应该特别留意重定向链,上面需要多个重定向才能到达目的地址。
像301和302这样的服务端重定向虽不理想,但也不是世界上最糟的事情。它们可以在本地被缓存,所以浏览器可以识别重定向URL,并且避免不必要的往返。元标签中的刷新(如
五、代码压缩
5.1 开启 gzip 压缩
gzip
是 GNUzip
的缩写,最早用于 UNIX 系统的文件压缩。HTTP 协议上的 gzip
编码是一种用来改进 web 应用程序性能的技术,Web 服务器和客户端(浏览器)必须共同支持 gzip
。目前主流的浏览器,Chrome,firefox,IE等都支持该协议。常见的服务器如 Apache,Nginx,IIS 同样支持,gzip
压缩效率非常高,通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右
5.1.1 Nginx 配置
gzip on;
gzip_min_length 1k;
gzip_buffers 4 16k;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/javascript application/x-javascript application/xml application/json;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
配置好重新启动Nginx,当看到请求响应头中有 Content-Encoding: gzip,说明传输压缩配置已经生效,此时可以看到我们请求文件的大小已经压缩很多。
5.1.2 Node 服务端
以下我们以服务端使用我们熟悉的 express 为例,开启 gzip 非常简单,相关步骤如下:
- 安装:
npm install compression —save
- 添加代码逻辑:
var compression = require('compression');
var app = express();
app.use(compression())
- 重启服务,观察网络面板里面的 response header,如果看到如下红圈里的字段则表明 gzip 开启成功:
5.2 Webpack 压缩
在 webpack 可以使用如下插件进行压缩:
- JavaScript:
UglifyPlugin
- CSS :
MiniCssExtractPlugin
- HTML:
HtmlWebpackPlugin
其实,我们还可以做得更好。那就是使用 gzip 压缩。可以通过向 HTTP 请求头中的 Accept-Encoding 头添加 gzip 标识来开启这一功能。当然,服务器也得支持这一功能。
gzip 是目前最流行和最有效的压缩方法。举个例子,我用 Vue 开发的项目构建后生成的 app.js 文件大小为 1.4MB,使用 gzip 压缩后只有 573KB,体积减少了将近 60%。
附上 webpack 和 node 配置 gzip 的使用方法。
下载插件
npm install compression-webpack-plugin —-save-dev
npm install compression
webpack 配置
const CompressionPlugin = require(‘compression-webpack-plugin’);
module.exports = {
plugins: [new CompressionPlugin()],
}
node 配置
const compression = require(‘compression’)
// 在其他中间件前使用
app.use(compression())
六、JavaScript中的性能优化
6.1 不要覆盖原生方法
无论你的 JavaScript 代码如何优化,都比不上原生方法。因为原生方法是用低级语言写的(C/C++),并且被编译成机器码,成为浏览器的一部分。当原生方法可用时,尽量使用它们,特别是数学运算和 DOM 操作。
6.2 使用事件委托(简化DOM操作)
- 苹果
- 香蕉
- 凤梨
6.3 JS动画
尽量避免添加大量的JS动画,CSS3动画和 Canvas 动画都比 JS 动画性能好。
使用requestAnimationFrame
来代替setTimeout
和setInterval
,因为requestAnimationFrame
可以在正确的时间进行渲染,setTimeout
和setInterval
无法保证渲染时机。不要在定时器里面绑定事件。
6.4 节流和防抖
6.4.1 防抖(debounce)
// 在事件被触发n秒后再执行回调,如果在这n秒内又被触发,则重新计时。
function debounce(func, delay) {
let time = null;
return function (...args) {
const context = this;
if (time) {
clearTimeout(time);
}
time = setTimeout(() => {
func.call(context, ...args);
}, delay);
};
}
6.4.2 节流(throttle)
// 规定在一个单位时间内,只能触发一次函数。如果这个单位时间内触发多次函数,只有一次生效。
function throttle(func, delay) {
let prevTime = Date.now();
return function (...args) {
const context = this;
let curTime = Date.now();
if (curTime - prevTime > delay) {
prevTime = curTime;
func.call(context, ...args);
}
};
}
七、页面渲染优化
Webkit 渲染引擎流程:
- 处理 HTML 并构建 DOM 树
- 处理 CSS 构建 CSS 规则树(CSSOM)
- 接着JS 会通过 DOM Api 和 CSSOM Api 来操作 DOM Tree 和 CSS Rule Tree 将 DOM Tree 和 CSSOM Tree 合成一颗渲染树 Render Tree。
- 根据渲染树来布局,计算每个节点的位置
- 调用 GPU 绘制,合成图层,显示在屏幕上
7.1 避免CSS、JS阻塞
7.1.1 CSS 的阻塞
我们提到 DOM 和 CSSOM 合力才能构建渲染树。这一点会给性能造成严重影响:默认情况下,CSS 是阻塞的资源。浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容,即便 DOM 已经解析完毕了。
只有当我们开始解析 HTML 后、解析到 link 标签或者 style 标签时,CSS 才登场,CSSOM 的构建才开始。 很多时候,DOM 不得不等待 CSSOM。因此我们可以这样总结:
CSS 是阻塞渲染的资源。需要将它尽早、尽快地下载到客户端,以便缩短首次渲染的时间。尽早(将 CSS 放在 head 标签里)和尽快(启用 CDN 实现静态资源加载速度的优化)
7.1.2 JS 的阻塞
JS 的作用在于修改,它帮助我们修改网页的方方面面:内容、样式以及它如何响应用户交互。这“方方面面”的修改,本质上都是对 DOM 和 CSSDOM 进行修改。因此 JS 的执行会阻止 CSSOM,在我们不作显式声明的情况下,它也会阻塞 DOM。
JS 不仅可以读取和修改DOM 属性,还可以读取和修改CSSOM 属性,存在阻塞的 CSS 资源时, 浏览器会延迟 JS 的执行和 Render Tree 构建。
JS 引擎是独立于渲染引擎存在的。我们的 JS 代码在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标签时,它会暂停渲染过程,将控制权交给 JS 引擎。JS 引擎对内联的 JS 代码会直接执行,对外部 JS 文件还要先获取到脚本、再进行执行。等 JS 引擎运行完毕,浏览器又会把控制权还给渲染引擎,继续 CSSOM 和 DOM 的构建。 因此与其说是 JS 把 CSS 和 HTML 阻塞了,不如说是 JS 引擎抢走了渲染引擎的控制权。
- 现代浏览器会并行加载 JS 文件。
- 加载或者执行JS时会阻塞对标签的解析,也就是阻塞了DOM 树的形成,只有等到JS执行完毕,浏览器才会继续解析标签。没有DOM树,浏览器就无法渲染,所以当加载很大的JS文件时,可以看到页面很长时间是一片空白
之所以会阻塞对标签的解析是因为加载的 JS 中可能会创建,删除节点等,这些操作会对 DOM 树产生影响,如果不阻塞,等浏览器解析完标签生成 DOM树后,JS 修改了某些节点,那么浏览器又得重新解析,然后生成 DOM 树,性能比较差。
实际使用时,可以遵循下面3个原则:
- CSS 资源优于 JavaScript 资源引入
- JS 应尽量少影响 DOM 的构建
7.1.3 改变 JS 阻塞的方式
defer(延缓)模式
defer
方式加载 script, 不会阻塞 HTML 解析,等到 DOM 生成完毕且 script 加载完毕再执行 JS。
async(异步)模式
async
属性表示异步执行引入的 JS,加载时不会阻塞 HTML解析,但是加载完成后立马执行,此时仍然会阻塞 load 事件。
从应用的角度来说,一般当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,我们会选用 async
;当脚本依赖于 DOM 元素和其它脚本的执行结果时,我们会选用defer
。
7.2 使用字体图标 iconfont 代替图片图标
字体图标就是将图标制作成一个字体,使用时就跟字体一样,可以设置属性,例如 font-size、color 等等,非常方便。并且字体图标是矢量图,不会失真。还有一个优点是生成的文件特别小。
7.3 降低 CSS 选择器的复杂性
浏览器读取选择器,遵循的原则是从选择器的右边到左边读取。看个示例:
#block .text p {
color: red;
}
- 查找所有 P 元素。
- 查找结果 1 中的元素是否有类名为 text 的父元素
- 查找结果 2 中的元素是否有 id 为 block 的父元素
CSS 选择器优先级
内联 > ID选择器 > 类选择器 > 标签选择器
根据以上两个信息可以得出结论:
- 减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素
- 关注可以通过继承实现的属性,避免重复匹配重复定义
- 尽量使用高优先级的选择器,例如 ID 和类选择器。
- 避免使用通配符,只对需要用到的元素进行选择
7.4 减少重绘和回流
7.4.1 重绘 (Repaint)
当页面中元素样式的改变并不影响它在文档流中的位置时(例如:color、background-color、visibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘
7.4.2 回流 (Reflow)
当Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。
回流必将引起重绘,重绘不一定会引起回流,回流比重绘的代价要更高。
7.4.3 如何避免
CSS
- 避免使用table布局。
- 尽可能在DOM树的最末端改变class。
- 避免设置多层内联样式。
- 将动画效果应用到position属性为absolute或fixed的元素上。
- 避免使用CSS表达式(例如:calc())。
JavaScript
- 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。
- 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
- 也可以先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
- 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
- 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。
7.5 使用 flexbox 而不是较早的布局模型
在早期的 CSS 布局方式中我们能对元素实行绝对定位、相对定位或浮动定位。而现在,我们有了新的布局方式 flexbox ,它比起早期的布局方式来说有个优势,那就是性能比较好。
7.6 图片资源优化
7.6.1 使用雪碧图
雪碧图的作用就是减少请求数,而且多张图片合在一起后的体积会少于多张图片的体积总和,这也是比较通用的图片压缩方案
7.6.2 降低图片质量
压缩方法有两种,一是通过在线网站进行压缩,二是通过 webpack 插件 image-webpack-loader。它是基于 imagemin 这个 Node 库来实现图片压缩的。
使用很简单,我们只要在file-loader
之后加入 image-webpack-loader
即可:
npm i -D image-webpack-loader
webpack 配置如下
// config/webpack.base.js
// ...
module: {
rules: [
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name]_[hash].[ext]',
outputPath: 'images/'
}
},
{
loader: 'image-webpack-loader',
options: {
// 压缩 jpeg 的配置
mozjpeg: {
progressive: true,
quality: 65
},
// 使用 imagemin**-optipng 压缩 png,enable: false 为关闭
optipng: {
enabled: false
},
// 使用 imagemin-pngquant 压缩 png
pngquant: {
quality: '65-90',
speed: 4
},
// 压缩 gif 的配置
gifsicle: {
interlaced: false
},
// 开启 webp,会把 jpg 和 png 图片压缩为 webp 格式
webp: {
quality: 75
}
}
}
]
}
];
}
// ...
7.6.3 图片懒加载
在页面中,先不给图片设置路径,只有当图片出现在浏览器的可视区域时,才去加载真正的图片,这就是延迟加载。对于图片很多的网站来说,一次性加载全部图片,会对用户体验造成很大的影响,所以需要使用图片延迟加载。
7.6.4 使用CSS3 代替图片
有很多图片使用 CSS 效果(渐变、阴影等)就能画出来,这种情况选择 CSS3 效果更好。因为代码大小通常是图片大小的几分之一甚至几十分之一。
7.6.5 使用 webp 格式的图片
WebP
是 Google 团队开发的加快图片加载速度的图片格式,其优势体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性,在 JPEG 和 PNG 上的转化效果都相当优秀、稳定和统一。
八、Webpack 优化
8.1 减少 ES6 转为 ES5 的冗余代码
Babel 插件会在将 ES6 代码转换成 ES5 代码时会注入一些辅助函数,例如下面的 ES6 代码:
class HelloWebpack extends Component{...}
这段代码再被转换成能正常运行的 ES5 代码时需要以下两个辅助函数:
babel-runtime/helpers/createClass // 用于实现 class 语法
babel-runtime/helpers/inherits // 用于实现 extends 语法
在默认情况下, Babel 会在每个输出文件中内嵌这些依赖的辅助函数代码,如果多个源代码文件都依赖这些辅助函数,那么这些辅助函数的代码将会出现很多次,造成代码冗余。为了不让这些辅助函数的代码重复出现,可以在依赖它们时通过 require('babel-runtime/helpers/createClass')
的方式导入,这样就能做到只让它们出现一次。babel-plugin-transform-runtime
插件就是用来实现这个作用的,将相关辅助函数进行替换成导入语句,从而减小 babel 编译出来的代码的文件大小。
首先,安装 babel-plugin-transform-runtime
:
npm install babel-plugin-transform-runtime —save-dev
然后,修改 .babelrc
配置文件为:
"plugins": [
"transform-runtime"
]
如果要看插件的更多详细内容,可以查看babel-plugin-transform-runtime
的 详细介绍 。
8.2 提取公共代码
如果项目中没有去将每个页面的第三方库和公共模块提取出来,则项目会存在以下问题:
- 相同的资源被重复加载,浪费用户的流量和服务器的成本。
- 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验。
所以我们需要将多个页面的公共代码抽离成单独的文件,来优化以上问题 。Webpack 内置了专门用于提取多个Chunk 中的公共部分的插件 CommonsChunkPlugin,我们在项目中 CommonsChunkPlugin 的配置如下:
// 所有在 package.json 里面依赖的包,都会被打包进 vendor.js 这个文件中。
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function(module, count) {
return (
module.resource &&
/\.js$/.test(module.resource) &&
module.resource.indexOf(
path.join(__dirname, '../node_modules')
) === 0
);
}
}),
// 抽取出代码模块的映射关系
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
})
如果要看插件的更多详细内容,可以查看 CommonsChunkPlugin 的 详细介绍 。
8.3 模板预编译
当使用 DOM 内模板或 JavaScript 内的字符串模板时,模板会在运行时被编译为渲染函数。通常情况下这个过程已经足够快了,但对性能敏感的应用还是最好避免这种用法。
预编译模板最简单的方式就是使用 单文件组件 ——相关的构建设置会自动把预编译处理好,所以构建好的代码已经包含了编译出来的渲染函数而不是原始的模板字符串。
如果你使用 webpack,并且喜欢分离 JavaScript 和模板文件,你可以使用 vue-template-loader ,它也可以在构建过程中把模板文件转换成为 JavaScript 渲染函数。
8.4 提取组件的 CSS
当使用单文件组件时,组件内的 CSS 会以 style 标签的方式通过 JavaScript 动态注入。这有一些小小的运行时开销,如果你使用服务端渲染,这会导致一段 “无样式内容闪烁 (fouc) ” 。将所有组件的 CSS 提取到同一个文件可以避免这个问题,也会让 CSS 更好地进行压缩和缓存。
查阅这个构建工具各自的文档来了解更多:
- webpack + vue-loader ( vue-cli 的 webpack 模板已经预先配置好)
- Browserify + vueify
- Rollup + rollup-plugin-vue
8.5 按需加载代码
通过 Vue 写的单页应用时,可能会有很多的路由引入。当打包构建的时候,JS 包会变得非常大,影响加载。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来。
项目中路由按需加载(懒加载)的配置:
const Foo = () => import('./Foo.vue')
const router = new VueRouter({
routes: [
{ path: '/foo', component: Foo }
]
})
九、Vue项目性能优化
9.1 合理使用v-if
和v-show
v-if 是 真正 的条件渲染,因为它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建;也是惰性的:如果在初始渲染时条件为假,则什么也不做——直到条件第一次变为真时,才会开始渲染条件块。
v-show 就简单得多, 不管初始条件是什么,元素总是会被渲染,并且只是简单地基于 CSS 的 display 属性进行切换。
所以,v-if 适用于在运行时很少改变条件,不需要频繁切换条件的场景;v-show 则适用于需要非常频繁切换条件的场景。
9.2 合理使用watch
和computed
computed: 是计算属性,依赖其它属性值,并且 computed 的值有缓存,只有它依赖的属性值发生改变,下一次获取 computed 的值时才会重新计算 computed 的值;
watch: 更多的是「观察」的作用,类似于某些数据的监听回调 ,每当监听的数据变化时都会执行回调进行后续操作;
运用场景:
- 当我们需要进行数值计算,并且依赖于其它数据时,应该使用 computed,因为可以利用 computed 的缓存特性,避免每次获取值时,都要重新计算;
- 当我们需要在数据变化时执行异步或开销较大的操作时,应该使用 watch,使用 watch 选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。
9.3 v-for 遍历必须为 item 添加 key,且避免同时使用 v-if
9.3.1 v-for 遍历必须为 item 添加 key
在列表数据进行遍历渲染时,需要为每一项 item 设置唯一 key 值,方便 Vue.js 内部机制精准找到该条列表数据。当 state 更新时,新的状态值和旧的状态值对比,较快地定位到 diff 。
9.3.2 v-for 遍历避免同时使用 v-if
v-for
比 v-if
优先级高,如果每一次都需要遍历整个数组,将会影响速度,尤其是当之需要渲染很小一部分的时候,必要情况下应该替换成 computed 属性。
推荐:
-
{{ user.name }}
computed: {
activeUsers: function () {
return this.users.filter(function (user) {
return user.isActive
})
}
}
不推荐:
-
{{ user.name }}
9.4 长列表性能优化
Vue 会通过 Object.defineProperty
对数据进行劫持,来实现视图响应数据的变化,然而有些时候我们的组件就是纯粹的数据展示,不会有任何改变,我们就不需要 Vue 来劫持我们的数据,在大量数据展示的情况下,这能够很明显的减少组件初始化的时间,那如何禁止 Vue 劫持我们的数据呢?可以通过 Object.freeze
方法来冻结一个对象,一旦被冻结的对象就再也不能被修改了。
export default {
data: () => ({
users: {}
}),
async created() {
const users = await axios.get("/api/users");
this.users = Object.freeze(users);
}
};
9.5 事件的销毁
Vue 组件销毁时,会自动清理它与其它实例的连接,解绑它的全部指令及事件监听器,但是仅限于组件本身的事件。 如果在 JS 内使用 addEventListener
等方式是不会自动销毁的,我们需要在组件销毁时手动移除这些事件的监听,以免造成内存泄露,如:
created() {
addEventListener('click', this.click, false)
},
beforeDestroy() {
removeEventListener('click', this.click, false)
}
9.6 图片资源懒加载
对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载,等到滚动到可视区域后再去加载。这样对于页面加载性能上会有很大的提升,也提高了用户体验。
9.7 路由懒加载
Vue 是单页面应用,可能会有很多的路由引入 ,这样使用 webpcak 打包后的文件很大,当进入首页时,加载的资源过多,页面会出现白屏的情况,不利于用户体验。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来。
路由懒加载:
const Foo = () => import(‘./Foo.vue’)
const router = new VueRouter({
routes: [
{ path: ‘/foo’, component: Foo }
]
})
9.8 第三方插件的按需引入
我们在项目中经常会需要引入第三方插件,如果我们直接引入整个插件,会导致项目的体积太大,我们可以借助 babel-plugin-component ,然后可以只引入需要的组件,以达到减小项目体积的目的。以下为项目中引入 element-ui 组件库为例:
- 首先,安装
babel-plugin-component
:
npm install babel-plugin-component -D
- 然后,将
.babelrc
修改为:
{
"presets": [["es2015", { "modules": false }]],
"plugins": [
[
"component",
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]
]
}
- 在 main.js 中引入部分组件:
import Vue from ‘vue’;
import { Button, Select } from ‘element-ui’;
Vue.use(Button)
Vue.use(Select)
9.9 优化无限列表性能
如果你的应用存在非常长或者无限滚动的列表,那么需要采用 窗口化 的技术来优化性能,只需要渲染少部分区域的内容,减少重新渲染组件和创建 dom 节点的时间。 你可以参考以下开源项目 vue-virtual-scroll-list 和 vue-virtual-scroller 来优化这种无限列表的场景的。
9.10 服务端渲染 SSR or 预渲染
如果你的项目的 SEO 和 首屏渲染是评价项目的关键指标,那么你的项目就需要服务端渲染来帮助你实现最佳的初始加载性能和 SEO,具体的 Vue SSR 如何实现,可以参考作者的另一篇文章《 Vue SSR 踩坑之旅 》。如果你的 Vue 项目只需改善少数营销页面(例如 /, /about, /contact 等)的 SEO,那么你可能需要预渲染,在构建时 (build time) 简单地生成针对特定路由的静态 HTML 文件。优点是设置预渲染更简单,并可以将你的前端作为一个完全静态的站点,具体你可以使用 prerender-spa-plugin 就可以轻松地添加预渲染 。
十、服务端渲染
10.1 适用场景
以下两种情况 SSR 可以提供很好的场景支持
- 需更好的支持 SEO
优势在于同步。搜索引擎爬虫是不会等待异步请求数据结束后再抓取信息的,如果 SEO 对应用程序至关重要,但你的页面又是异步请求数据,那 SSR 可以帮助你很好的解决这个问题。 - 需更快的到达时间
优势在于慢网络和运行缓慢的设备场景。传统 SPA 需完整的 JS 下载完成才可执行,而SSR 服务器渲染标记在服务端渲染 html 后即可显示,用户会更快的看到首屏渲染页面。如果首屏渲染时间转化率对应用程序至关重要,那可以使用 SSR 来优化。
10.2 不适用场景
以下三种场景 SSR 使用需要慎重
- 同构资源的处理
劣势在于程序需要具有通用性。结合 Vue 的钩子来说,能在 SSR 中调用的生命周期只有 beforeCreate 和 created,这就导致在使用三方 API 时必须保证运行不报错。在三方库的引用时需要特殊处理使其支持服务端和客户端都可运行。 - 部署构建配置资源的支持
劣势在于运行环境单一。程序需处于 node.js server 运行环境。 - 服务器更多的缓存准备
劣势在于高流量场景需采用缓存策略。应用代码需在双端运行解析,cpu 性能消耗更大,负载均衡和多场景缓存处理比 SPA 做更多准备。
十一、缓存优化
11.1 浏览器缓存策略
缓存的意义就在于减少请求,更多地使用本地的资源,给用户更好的体验的同时,也减轻服务器压力。所以,最佳实践,就应该是尽可能命中强缓存,同时,能在更新版本的时候让客户端的缓存失效。
在更新版本之后,如何让用户第一时间使用最新的资源文件呢?机智的前端们想出了一个方法,在更新版本的时候,顺便把静态资源的路径改了,这样,就相当于第一次访问这些资源,就不会存在缓存的问题了
entry:{
main: path.join(__dirname,'./main.js'),
vendor: ['react', 'antd']
},
output:{
path:path.join(__dirname,'./dist'),
publicPath: '/dist/',
filname: 'bundle.[chunkhash].js'
}
综上所述,我们可以得出一个较为合理的缓存方案:
- HTML:使用协商缓存。
- CSS、JS和图片:使用强缓存,文件命名带上hash值。
11.2 文件名哈希
Webpack 给我们提供了三种哈希值计算方式,分别是hash
、chunkhash
和contenthash
。那么这三者有什么区别呢?
hash
:跟整个项目的构建相关,构建生成的文件hash值都是一样的,只要项目里有文件更改,整个项目构建的hash值都会更改。chunkhash
:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的hash值。contenthash
:由文件内容产生的hash值,内容不同产生的contenthash
值也不一样。
显然,我们是不会使用第一种的。改了一个文件,打包之后,其他文件的hash
都变了,缓存自然都失效了。这不是我们想要的。
那chunkhash
和contenthash
的主要应用场景是什么呢?在实际在项目中,我们一般会把项目中的 CSS 都抽离出对应的 CSS 文件来加以引用。如果我们使用chunkhash
,当我们改了CSS 代码之后,会发现 CSS 文件hash
值改变的同时,JS 文件的hash
值也会改变。这时候,contenthash
就派上用场了。
十二、关于性能监控的思考
我们在做性能优化的时候,常常会通过各种线上打点,来收集用户数据,进行性能分析。没错,这是一种监控手段,更精确的说,这是一种”事后”监控手段。
”事后”监控固然重要,但我们也应该考虑”事前”监控,否则,每次发布一个需求后,去线上看数据。咦,发现数据下降了,然后我们去查代码,去查数据,去查原因。这样性能优化的同学永远处于”追赶者”的角色,永远跟在屁股后面查问题。
举个例子,我们可以这样去做”事前”监控。
建立流水线机制。流水线上如何做呢?
- Lighthouse CI 或 PageSpeed Insights API :把Lighthouse或PageSpeed Insights API集成到CI流水线中,输出报告分析。
- Puppeteer 或 Playwright :使用E2E自动化测试工具集成到流水线模拟用户操作,得到Chrome Trace Files,也就是我们平常录制Performance后,点击左上角下载的文件。Puppeteer和Playwright底层都是基于 Chrome DevTools Protocol 。
Chrome Trace Files:根据 规则 分析Trace文件,可以得到每个函数执行的时间。如果函数执行时间超过了一个临界值,可以抛出异常。如果一个函数每次的执行时间都超过了临界值,那么就值得注意了。但是还有一点需要思考的是:函数执行的时间是否超过临界值固然重要,但更重要的是这是不是用户的输入响应函数,与用户体验是否有关。
- 输出报告。定义异常临界值。如果异常过多,考虑是否卡发布流程。
十三、参考资料
Web Vitals
Aerotwist - The Anatomy of a Frame
Performance Timeline - Web API 接口参考 | MDN
Getting Around the Chromium Source Code Directory Structure
前端性能优化 24 条建议(2020) - 掘金
【前端优化】首屏加载 9.2s 压缩至 3.6 s - 掘金
async vs defer attributes - Growing with the Web
我的前端性能优化知识体系 - 掘金
前端性能优化三部曲(加载篇)
全链路前端性能优化(欢迎收藏) - 掘金
性能优化到底应该怎么做 - 掘金
『Webpack系列』—— 路由懒加载的原理 - 掘金
前端缓存最佳实践 - 掘金
『前端优化』—— Vue项目性能优化 - 掘金
服务端渲染SSR及实现原理 - 掘金
Vue 项目性能优化 — 实践指南(网上最全 / 详细) - 掘金
Vue项目Webpack优化实践,构建效率提高50% - 掘金
我是佩奇烹饪家,年轻的前端攻城狮,爱专研,爱技术,爱分享。
个人笔记,整理不易,感谢阅读
、点赞
、关注
和收藏
。
文章有任何问题欢迎大家指出,也欢迎大家一起交流学习!