摘自
前端性能优化原理与实践
网络层面的性能优化
我们从输入 URL
到显示页面这个过程中,涉及到网络层面的,有三个主要过程:
-
DNS
解析 -
TCP
连接 -
HTTP
请求/响应
对于DNS
解析和 TCP
连接两个步骤,我们前端可以做的努力非常有限。相比之下,HTTP
连接这一层面的优化才是我们网络优化的核心。
HTTP
优化有两个大的方向:
- 减少请求次数
- 减少单次请求所花费的时间
webpack 优化方案
不要让 loader 做太多事情——以 babel-loader 为例
最常见的优化方式是,用 include
或exclude
来帮我们避免不必要的转译,比如webpack
官方在介绍 babel-loader
时给出的示例:
module: {
rules: [
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
这段代码帮我们规避了对庞大的node_modules
文件夹或者 bower_components
文件夹的处理。但通过限定文件范围带来的性能提升是有限的。除此之外,如果我们选择开启缓存将转译结果缓存至文件系统,则至少可以将babel-loader
的工作效率提升两倍。要做到这点,我们只需要为loader
增加相应的参数设定:
loader: 'babel-loader?cacheDirectory=true'
Gzip 压缩原理
我们日常开发中,其实还有一个便宜又好用的压缩操作:开启 Gzip
。
具体的做法非常简单,只需要你在你的request headers
中加上这么一句:
accept-encoding:gzip
webpack 的 Gzip 和服务端的 Gzip
一般来说,Gzip
压缩是服务器的活儿:服务器了解到我们这边有一个Gzip
压缩的需求,它会启动自己的CPU
去为我们完成这个任务。
而压缩文件这个过程本身是需要耗费时间的,大家可以理解为我们以服务器压缩的时间开销和
CPU
开销(以及浏览器解析压缩文件的开销)为代价,省下了一些传输过程中的时间开销。
既然存在着这样的交换,那么就要求我们学会权衡。服务器的 CPU
性能不是无限的,如果存在大量的压缩需求,服务器也扛不住的。服务器一旦因此慢下来了,用户还是要等。Webpack
中Gzip
压缩操作的存在,事实上就是为了在构建过程中去做一部分服务器的工作,为服务器分压。
因此,这两个地方的 Gzip
压缩,谁也不能替代谁。它们必须和平共处,好好合作。作为开发者,我们也应该结合业务压力的实际强度情况,去做好这其中的权衡。
图片优化
就图片这块来说,与其说我们是在做“优化”,不如说我们是在做“权衡”。因为我们要做的事情,就是去压缩图片的体积(或者一开始就选取体积较小的图片格式)。但这个优化操作,是以牺牲一部分成像质量为代价的。因此我们的主要任务,是尽可能地去寻求一个质量与性能之间的平衡点。
最经典的小图标解决方案——雪碧图(CSS Sprites)
图像精灵(sprite
,意为精灵),被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP
请求更少,对内存和带宽更加友好。
和雪碧图一样,Base64
图片的出现,也是为了减少加载网页图片时对服务器的请求次数,从而提升网页性能。Base64
是作为雪碧图的补充而存在的。
Base64
是一种用于传输8Bit
字节码的编码方式,通过对图片进行Base64
编码,我们可以直接将编码结果写入HTML
或者写入CSS
,从而减少HTTP
请求的次数。
我们加载图片需要把图片链接写入img
标签:
浏览器就会针对我们的图片链接去发起一个资源请求。 但是如果我们对这个图片进行 Base64
编码,我们会得到一个这样的字符串:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABYAAAAWCAYAAADEtGw7AAAMJGlDQ1BJQ0MgUHJvZmlsZQAASImVlwdUU8kagOeWJCQktEAEpITeBCnSpdfQpQo2QhJIKDEkBBU7uqjgWlARwYquitjWAshiw14Wwd4fiKgo62LBhsqbFNDV89477z9n7v3yzz9/mcydMwOAehxbJMpFNQDIExaI48MCmeNT05ikR4AECIAKRgEamyMRBcTFRQEoQ+9/yrubAJG9r9nLfP3c/19Fk8uTcABA4iBncCWcPMiHAMDdOCJxAQCEXqg3m1YggkyEWQJtMUwQsrmMsxTsIeMMBUfJbRLjgyCnA6BCZbPFWQCoyfJiFnKyoB+1pZAdhVyBEHIzZF8On82F/BnyqLy8qZDVrSFbZ3znJ+sfPjOGfbLZWcOsqEUuKsECiSiXPeP/nI7/LXm50qEYZrBR+eLweFnNsnnLmRopYyrk88KMmFjIWpCvC7hyexk/4UvDk5T2HziSIDhngAEASuWygyMhG0A2FebGRCn1vpmCUBZkOPdooqCAlagYi3LFU+OV/tHpPElIwhCzxfJYMptSaU5SgNLnRj6PNeSzqYifmKLIE20rFCTHQFaDfF+SkxCptHlexA+KGbIRS+NlOcP/HAOZ4tB4hQ1mnicZqgvz4gtYMUqO4rDl+ehCnlzATwxX+MEKeZLxUUN5cnnBIYq6sGKeMEmZP1YuKgiMV47dJsqNU9pjzbzcMJneFHKrpDBhaGxfAVxsinpxICqIS1TkhmtnsyPiFHFxWxAFgkAwYAIpbBlgKsgGgtbehl74S9E
浏览器缓存机制介绍与缓存策略剖析
大家倾向于将浏览器缓存简单地理解为“HTTP 缓存”
。但事实上,浏览器缓存机制有四个方面,它们按照获取资源时请求的优先级依次排列如下:
Memory Cache
Service Worker Cache
HTTP Cache
Push Cache
HTTP 缓存机制探秘
HTTP 缓存
是我们日常开发中最为熟悉的一种缓存机制。它又分为强缓存和协商缓存。优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。
强缓存的特征
强缓存是利用http
头中的Expires
和Cache-Control
两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires
和 cache-control
判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
强缓存的实现:从 expires 到 cache-control
实现强缓存,过去我们一直用expires
。
当服务器返回响应时,在Response Headers
中将过期时间写入expires
字段。像这样:
expires: Wed, 11 Sep 2019 16:12:18 GMT
可以看到,expires
是一个时间戳,接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和expires
的时间戳,如果本地时间小于 expires
设定的过期时间,那么就直接去缓存中取这个资源。
从这样的描述中大家也不难猜测,expires
是有问题的,它最大的问题在于对“本地时间”
的依赖。如果服务端和客户端的时间设置可能不同,或者我直接手动去把客户端的时间改掉,那么 expires
将无法达到我们的预期。
考虑到expires
的局限性,HTTP1.1
新增了 Cache-Control
字段来完成expires
的任务。
cache-control: max-age=31536000
如大家所见,在 Cache-Control
中,我们通过 max-age
来控制资源的有效期。max-age
不是一个时间戳,而是一个时间长度。在本例中,max-age
是 31536000
秒,它意味着该资源在 31536000
秒以内都是有效的,完美地规避了时间戳带来的潜在问题。
Cache-Control
相对于expires
更加准确,它的优先级也更高。当Cache-Control
与 expires
同时出现时,我们以 Cache-Control
为准。
no-store与no-cache
no-cache
绕开了浏览器:我们为资源设置了no-cache
后,每一次发起请求都不会再去询问浏览器的缓存情况,而是直接向服务端去确认该资源是否过期(即走我们下文即将讲解的协商缓存的路线)。
no-store
比较绝情,顾名思义就是不使用任何缓存策略。在no-cache
的基础上,它连服务端的缓存确认也绕开了,只允许你直接向服务端发送请求、并下载完整的响应。
协商缓存:浏览器与服务器合作之下的缓存策略
协商缓存依赖于服务端与浏览器之间的通信。
协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。
如果服务端提示缓存资源未改动(Not Modified
),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是304
。
协商缓存的实现:从 Last-Modified 到 Etag
Last-Modified
是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers
返回:
Last-Modified: Fri, 27 Oct 2017 06:35:57 GMT
随后我们每次请求时,会带上一个叫If-Modified-Since
的时间戳字段,它的值正是上一次response
返回给它的last-modified
值:
If-Modified-Since: Fri, 27 Oct 2017 06:35:57 GMT
服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers
中添加新的 Last-Modified
值;否则,返回如上图的 304
响应,Response Headers
不会再添加Last-Modified
字段。
使用Last-Modified
存在一些弊端,这其中最常见的就是这样两个场景:
我们编辑了文件,但文件的内容没有改变。服务端并不清楚我们是否真正改变了文件,它仍然通过最后编辑时间进行判断。因此这个资源在再次被请求时,会被当做新资源,进而引发一次完整的响应——不该重新请求的时候,也会重新请求。
当我们修改文件的速度过快时(比如花了100ms
完成了改动),由于 If-Modified-Since
只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。
这两个场景其实指向了同一个bug
——服务器并没有正确感知文件的变化。为了解决这样的问题,Etag
作为Last-Modified
的补充出现了。
Etag
是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的Etag
就是不同的,反之亦然。因此Etag
能够精准地感知文件的变化。
Etag
和 Last-Modified
类似,当首次请求时,我们会在响应头里获取到一个最初的标识符字符串,它可以是这样的:
ETag: W/"2a3b-1602480f459"
那么下一次请求时,请求头里就会带上一个值相同的、名为if-None-Match
的字符串供服务端比对了:
If-None-Match: W/"2