前言
自己从未做过性能优化的相关实践,闲暇之余看了《前端性能优化原理与实践》一书,本文是对该书的的一个简要总结。
要想了解性能优化,首先我们得知道网页是如何加载的,然后再针对每一个过程进行优化
网页加载过程
- DNS 解析(通过DNS将 URL 解析为对应的 IP 地址)
- TCP 连接(与 IP 地址确定的那台服务器建立起 TCP 网络连接)
- 发起HTTP 请求(向服务端发起我们的 HTTP 请求)
- 服务端处理请求,HTTP 响应返回数据(服务端处理完请求之后,把数据放在 HTTP 响应里返回客户端)
- 浏览器对拿到数据进行渲染(浏览器渲染返回的数据,将页面呈现给用户)
DNS解析
DNS 解析花时间,能不能尽量减少解析次数或者把解析前置?能——浏览器 DNS 缓存和 DNS prefetch
TCP连接
TCP 每次的三次握手都急死人,有没有解决方案?有——长连接、预连接、接入 SPDY 协议
前面这两点需要服务端的同事来完成
HTTP请求/响应
HTTP请求次数太多?HTTP响应返回的资源太大?那就减少请求次数和减小请求体积,这两个优化点直直地指向了我们日常开发中非常常见的操作——资源的压缩与合并。这就是我们每天用构建工具在做的事情。
webpack优化
让webpack打包更快,让包更小
- 不要让 loader 做太多事情——以 babel-loader 为例,用
include
或exclude
来避免不必要的转译 - 不要放过第三方库——以node_modules为例,推荐使用 DllPlugin 避免对第三方库重复打包的问题
- 用
Happypack
将 loader 由单进程转为多进程,提升打包效率 - 压缩包的体积,使用
Tree-Shaking
删除冗余代码,使用UglifyJsPlugin
对碎片化的冗余代码(如 console 语句、注释等)进行自动化删除 - 按需加载——以单页应用为例,只加载当前页面,其他页面等用到了在加载
- 使用Gzip再次进行压缩
图片的选择
JPEG/JPG
以前一直以为这是两种不同的格式😂
优点
- 有损压缩,在极高压缩率的同时还能保证较好的图像品质
缺点
- 不支持透明度处理
- 会造成图像数据损失(当它处理矢量图形和 Logo 等线条感较强、颜色对比强烈的图像时,人为压缩导致的图片模糊会相当明显。)
PNG
PNG-8与PNG-24
PNG-8最多支持 256 种颜色,而PNG-24 位的可以呈现约 1600 万种颜色
优点
- 无损压缩、高保真
- 支持透明与半透明
缺点
- 体积太大
应用场景
适合处理复杂的、色彩层次鲜明、对比强烈的图片,比如logo或者一些小的
PNG-8 与 PNG-24 的选择
- 如果追求最佳的显示效果、并且不在意文件体积大小时,是推荐使用 PNG-24 的。
- 如果为了规避体积的问题,我们一般不用PNG去处理较复杂的图像。当我们遇到适合 PNG 的场景时,应优先选择更为小巧的 PNG-8。
- 另外一种做法是把图片先按照这两种格式分别输出,看 PNG-8 输出的结果是否会带来肉眼可见的质量损耗,并且确认这种损耗是否在我们(尤其是你的 UI 设计师)可接受的范围内,基于对比的结果去做判断。
SVG
优点
- 文件体积更小,可压缩性更强
- 图片可无限放大而不失真
- 具有较强的灵活性,可以像写代码一样定义 SVG
缺点
- 有一定的学习成本
- 渲染成本比较高
Base64
雪碧图,一种将小图标和背景图像合并到一张图片上,然后利用 CSS 的背景定位来显示其中的每一部分的技术,从而减少对服务器的请求次数,Base64实际是雪碧图的一个补充
Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,可以直接将编码结果写入 HTML 或者 CSS,从而减少 HTTP 请求的次数。
优点
- 不再请求服务器调用图片资源,减少了服务器访问次数
- 能够适用于不同平台、不同语言的传输
缺点
- 文本内容较多,增大了数据库服务器的压力
- 图片体积会更大
应用场景
Base64 并非万全之策,我们往往在一张图片满足以下条件时会对它应用 Base64 编码
WebP
Google 专为 Web 开发的一种旨在加快图片加载速度的图片格式,它支持有损压缩和无损压缩。
优点
- 同时支持有损和无损压缩
- 无损压缩体积更小
- 同等质量下,有损压缩的体积更小
- 支持透明
缺点
- 兼容性较差
应用场景
使用 WebP 的最大问题不是“这个图片是否适合用 WebP 呈现”的问题,而是“浏览器是否允许 WebP”的问题。
处理兼容的方式:
- 在浏览器环境支持 WebP 的情况下,优先使用 WebP 格式,否则就把图片降级为 JPG 格式(本质是对图片的链接地址作简单的字符串切割)
- 由服务器根据 HTTP 请求头部的 Accept 字段来决定返回什么格式的图片,当 Accept 字段包含 image/webp 时,就返回 WebP 格式的图片,否则返回原图。
浏览器缓存
浏览器缓存机制有四个方面,按照获取资源时请求的优先级依次排列如下
Memory Cache
内存缓存是优先级最高的缓存,也是响应速度最快的一种缓存,但也是短命的,当进程结束后,内存里的数据也不复存在。
因为内存有限,所以只有体积很小的文件才能使用内存缓存,比如base64图片,体积较小的css或js文件
Service Worker Cache
Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,因此无法直接访问 DOM。这样独立的个性使得 Service Worker 的“个人行为”无法干扰页面的性能,这个“幕后工作者”可以帮我们实现离线缓存、消息推送和网络代理等功能。我们借助 Service worker 实现的离线缓存就称为 Service Worker Cache。
HTTP Cache
HTTP Cache又分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。
强缓存
强缓存是利用 http 头中的 expires
和cache-control
两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires
和 cache-control
判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
expires:值为一个时间点,保存的是过期时间。expires: Wed, 11 Sep 2022 16:12:18 GMT
如果我们再次向服务器请求资源,浏览器就会先对比本地时间和 expires
的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。由于时间戳是服务器来定义的,而本地时间的取值却来自客户端,因此 expires
的工作机制对客户端时间与服务器时间之间的一致性提出了极高的要求
cache-control:cache-control
中的max-age
字段中保存的是一个时间长度(单位:秒)cache-control: max-age=31536000
当我们向服务器请求资源时,客户端会记录请求到资源的时间点,以此作为相对时间的起点,当我们再次向服务器请求时,客户端会使用 当前时间 - 起始时间 与max-age
比较,若小于max-age
,那么就直接去缓存中取这个资源。max-age
可以视作是对 expires
能力的补位/替换。在当下实践里,我们普遍会倾向于使用max-age。但如果你的应用对向下兼容有强诉求,那么 expires
仍然是不可缺少的。
当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。
协商缓存
协商缓存向服务器去询问缓存的相关信息,如果服务端提示缓存资源未改动,资源会被重定向到浏览器缓存。如果发生了变化,就会返回一个完整的响应内容。
如果我们启用了协商缓存,它会在首次请求时随着 响应中返回一个Last-Modified
时间戳字段Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,会带上一个叫 If-Modified-Since
的时间戳字段,它的值正是上一次 响应返回的 last-modified 值If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在响应头中添加新的 Last-Modified
值。Last-Modified
的弊端
- 我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。
- 当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于
If-Modified-Since
只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。
上述两个问题归根结底是因为服务器并没有正确感知文件的变化。为了解决这样的问题,Etag 作为 Last-Modified
的补充出现了。
Etag 是由服务器为每个资源生成的唯一标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag
就是不同的,反之亦然。因此 Etag
能够精准地感知文件的变化。Etag
也会在首次请求时被添加在响应头中ETag: W/"2a3b-1602480f459"
下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match 的字符串供服务端比对If-None-Match: W/"2a3b-1602480f459"
Etag
的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。因此启用 Etag 需要我们审时度势。正如我们刚刚所提到的——Etag
并不能替代 Last-Modified
,它只能作为 Last-Modified
的补充和强化存在。
Etag
在感知文件变化上比 Last-Modified
更加准确,优先级也更高。当 Etag
和 Last-Modified
同时存在时,以 Etag
为准。
缓存抉择
- 当资源内容不可复用时,则
Cache-Control: no-store
,拒绝一切形式的缓存 - 是否每次都需要向服务器进行缓存有效确认,如果需要,则
Cache-Control: no-cache
; - 资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;
- 考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;
- 配置协商缓存需要用到的 Etag、Last-Modified 等参数。
Push Cache
Push Cache 是指 HTTP2 在 server push 阶段存在的缓存。这块的知识比较新,应用也还处于萌芽阶段。但应用范围有限不代表不重要——HTTP2 是趋势、是未来
Push Cache 关键特性
- Push Cache 是缓存的最后一道防线。浏览器只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。
- Push Cache 是一种存在于会话阶段的缓存,当 session 终止时,缓存也随之释放。
- 不同的页面只要共享了同一个 HTTP2 连接,那么它们就可以共享同一个 Push Cache。
本地存储
通过将信息存储到本地来达到减少请求次数的目的
CDN
浏览器缓存、本地存储带来的性能提升,只能在“获取到资源并把它们存起来”之后,这对我们的第一次请求并没有帮助。将静态资源(像 JS、CSS、图片等不需要业务服务器进行计算即得的资源)存放到CDN上,可以提升首次请求的速度
浏览器渲染
服务端渲染
通常情况下,我们使用的都是客户端渲染,即服务端把渲染需要的静态文件发送给客户端,客户端加载完之后,在浏览器里跑一遍 JS,生成相应的 DOM。
在服务端渲染模式下,服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。
比如知乎就是一个服务端渲染案例
服务端渲染的优缺点
- 利于搜索引擎查找我们的页面,因为搜索引擎只会查找现成的内容,不会跑 JS 代码,就不能得到像Vue、React生存的完整的页面
- 提升首屏加载速度,因为服务端直接返回的是THTML字符串,客户端拿到之后可以直接进行渲染
- 增加来服务器的负担,服务端渲染是把本该浏览器做的事情交给服务器来做。
Css与JS加载
浏览器渲染流程
- 解析DOM,生成DOM树
- 解析CSS生成CSS树(与DOM得解析是并行的,当HTML解析到link标签或者style标签时,才开始解析Css)
- 将DOM树和CSS树结合生成渲染树
- 计算渲染树中每一个元素得位置、大小等信息生成布局渲染树
- 浏览器根据布局渲染树进行绘制
- 之后每当一个新元素加入到这个 DOM 树当中,浏览器查找 CSS 样式表,找到符合该元素的样式规则应用到这个元素上,然后重新绘制
Css优化建议
首先我们得知道,CSS 引擎查找样式表,对每条规则都按从右到左的顺序去匹配,例如
#name li {}
浏览器在遍历时,是先遍历每一个li
元素,然后再去确认li
元素得父元素是否是#name
通过上面的分析,我们可以得出一下优化方案
告别阻塞
HTML、CSS 和 JS,都具有阻塞渲染的特性。
Css阻塞
默认情况下,CSS 是阻塞的资源。浏览器在构建 CSS树的过程中,不会渲染任何已处理的内容。即便 DOM 已经解析完毕了,只要 CSS树未加载完,那么渲染这个事情就无法进行(这主要是为了避免没有 CSS 的 HTML 页面丑陋地“裸奔”在用户眼前)。所以我们应当将css放在head标签里
JS阻塞
JS 的作用在于修改,它帮助我们修改网页的内容、样式以及如何响应用户交互。这些修改,本质上都是对 DOM 和 CSS进行修改。因此在我们不作显式声明的情况下,JS也会阻塞渲染
JS的三种加载方式
- 正常模式 —— JS的加载会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。
<scriptsrc="index.js"></script>
- async模式 —— JS的加载不会阻塞浏览器,它的加载是异步的,当它加载结束才会立即执行
- defer模式 —— JS的加载不会阻塞浏览器,它的加载是异步的,执行会被推迟。等整个文档解析完成、DOMContentLoaded 事件即将被触发时,被标记了 defer 的 JS 文件才会开始依次执行。
当我们的脚本与 DOM 元素和其它脚本之间的依赖关系不强时,选用 async;当脚本依赖于 DOM 元素和其它脚本的执行结果时,选用 defer。通过审时度势地向 script 标签添加 async/defer,我们就可以告诉浏览器在等待脚本可用期间不阻止其它的工作,这样可以显著提升性能
DOM操作优化
JS操作DOM其实是很快的,但是在JS的世界里,DOM操作并非JS一个人完成的,还需要渲染引擎与其配合来完成工作,所以我们可以通过减少对DOM操作的次数来一定程度上提升性能。比如
- 使用 DOM Fragment
- 在微任务里进行DOM更新
- 通过增减类名来进行DOM的样式更改
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
<script>
const container = document.getElementById('container')
container.classList.add('basic_style')
</script>
- 先通过
display: none
隐藏元素,然后在更改样式,最后再display: block
显示元素
let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
...
container.style.display = 'block'
其他优化手段
如何查看自己网站的性能
- Chrome浏览器的Performance面板
- Chrome浏览器的Lighthouse面板
- PerformanceAPI
最后
本文只能让大家对前端性能优化有个大体认识,并不能带大家深入其中方方面面。
若本文存在错误还请斧正