细说Web前端的静态资源缓存

发布:elantion 日期:2020-05-28 阅读:290 评论:0

用户,特别是移动端的用户,对于页面的打开速度非常敏感,一般来说,一秒内响应的页面用户体验最好,两秒内的,用户明显感知页面不流畅,三秒开外的,百分之七八十的用户将会关闭页面。因此,前端(本文均指Web前端)业界有个共识,将一秒内响应页面作为努力优化的目标。

加载静态资源的问题

Web前端与APP最大的不同在于,APP的执行文件是用户预先下载好的,程序可以直接执行本地文件,用户即点即有,而Web前端则需要在用户打开页面时,才去下载资源,网络的宽带和波动对打开速度影响极大,很难做到即开即有。所以,打开页面时如何避免网络的影响,是个很重要的问题。而静态资源缓存就是个最好的解决办法,但怎么处理静态资源缓存又是一个老大难的问题(与变量命名,都是程序员最大的世界难题)。

Web页面打开流程

在开始深入之前,我们先来了解一下Web页面的打开程流程,在整体上有个感知,这样更容易理解下面的内容。
我们先看看大概的流程:当用户点击链接或者使用地址栏打开地址,浏览器会发送一个请求给服务器(GET请求),服务器会根据地址返回相应的html文件,浏览器收到html文件之后,开始渲染页面,展示给用户。
简单图例就像下面这样:

5ed061e8c4cee40224ecd435

实际上要比上图复杂得多得多,还要处理tcp握手、路由寻址等,这里先不管。浏览器接收到index.html的内容之后,页面应该有响应了,但也仅限于html文件,也就只是只有个空壳,显然没什么实际用处,要展示丰富多彩的内容,提供复杂的交互,还要js、css和图片等静态资源,而这些资源是标记在html文件里,看下面这个html:

<!DOCTYPE html>
<html lang=cn>
    <head>
        <meta charset=utf-8>
        <link rel=icon href="favicon.ico">
        <title>标题</title>
        <link href="index.css" rel=stylesheet>
    </head>
    <body>
        <div id=app></div>
        <script src=index.js></script>
    </body>
</html>

当浏览器打开这个html时,它会读取里面的内容,遇到类似<script src="xxx.js"></script>的标记时,就会向服务器请求这些静态资源,上面的html代码需要三个文件,favicon.ico、index.css、index.js,请求的过程跟获取index.html如出一致,看下图:

5ed061f7c4cee40224ecd436

当获取到这些文件之后,浏览器就可以执行代码,然后展示一个完整的页面了,页面的加载流程也就结束。

协商缓存和强制缓存

看完上面的流程,我们不禁会想,如果我们不用去服务服器取index.html, index.css, index.js等文件,直接保存在手机上,浏览器在加载页面时直接读取本地文件不就变得飞快了吗?是的,这样确实比从服务器下载要快得多。而这种把服务器下载过来的静态文件保存在手机上,下次打开页面时直接这些本地文件的的行为,就是本文的重点:静态文件缓存。
缓存快是快了,但如果某些文件要更新怎么办?我们怎样保证手机上的文件跟服务器的文件是一样的?这就引入这章的主题:协商缓存和强制缓存。

协商缓存

既然下载文件的过程很耗时,但又要保证文件是最新的,那么我们直接问一下服务器,我手机本地保存的缓存是新的还是旧的,不就好了吗?如果是新的,就直接读取本地缓存,不用再下载,旧的话,就重新下载好了。对,这就是协商缓存的由来,顾名思义,它的缓存是要跟服务器协商,它的原理是:每次打开页面,浏览器都会向服务器发送请求,如果服务器的文件与手机本地的文件是一样的,服务器就会返回空内容和304状态码,意思是不需要更新,浏览器直接读取本地缓存即可。如果不一样,就会返回服务器上最新的文件给浏览器,还有状态码200。
要实现这种缓存机制,我们需要在服务器返回的http头部信息里添加一个关键的指令:cache-control: no-cache,这就告诉浏览器,这个文件可以进行协商缓存。而浏览器则会根据其它头部信息综合考虑是否需要更新,例如:eTag, last-modifined, exprired等等。
5ed06208c4cee40224ecd437
上图中,我们请求index.js文件,每次请求,浏览器都会带上之前浏览器返回的头部信息,例如:ETag,服务器收到该请求之后,会校对ETag的值,如果不一致,服务器就会返回状态码200,并且发送index.js的全部内容和更新后的ETag值,浏览器需要接收完整的index.js。如果一致,就会返回状态码304,告诉浏览器不需要更新,浏览器就会直接读取本地缓存。

协商缓存的不足

看完这个流程,大家心里可能会觉得,这个方法妙啊,应该可以做到即点即有吧?因为页面不更新的话,浏览器要下载的内容可能就几kb,顶多一百kb,对于现在随随便便几十兆的带宽来说,都是几百毫秒的事,页面加载应该瞬间就能完成。可是现实往往不那么简单,大家忽略了一个重要的问题:延迟。
文件从服务器发送到手机上,并不是简单的从A递给B,实际上,中间要经历极其复杂的过程。涉及到:dns、tcp/ip、https、cdn、hdd等等部分,即使优化到极致,也难免偶尔出现高延迟的情况,特别是对于使用不太可靠的移动网络的手机用户来说,情况可能更糟糕。所以,理想的情况下,我们连这个「询问文件是否最新」的流程也省掉,直接读取本地缓存,就能实现即点即响应,这也是接下来我们要讨论的内容:强制缓存。

强制缓存

强制缓存比较好理解,就是静态文件保存到本地手机上,以后不再向服务器请求,直接读到本地缓存。这样的好处显然易见,省去了向服务器询问的过程,有效减少延迟的问题,但问题也明显,就是服务器更新文件后,用户没法及时更新。
开启方法很简单,在服务器返回的头部信息中插入指令:Cache-control:max-age=2592000,private,immutable 即可,这样可以让静态文件在一个月内都不必向服务器请求。

协商缓存与强制缓存结合

单独使用那种缓存机制都不能完美解决问题,所以,最佳的解决办法就是把两个缓存机制相互结合使用。对于index.html这种入口文件,我们使用协商缓存,这样可以保证文件是最新的。然后,对于像index.js、index.css这种动态文件,我们可以给它加上hash值,例如index.123.js, index.123.css,并且设为强缓存。当文件修改过之后,hash值都会改变,例如index.321.js,浏览器就会认为全新的文件,就会重新向服务器发起请求,无视所有的缓存机制。相反,如果文件没修改,文件名没发生改变,并且因为强制缓存的机制,浏览器就会直接读取本地缓存文件,不会向服务器询问文件的新旧,加载既快速又稳定。
5ed061d1c4cee40224ecd434
理想情况下的加载情况,整个页面可能只需要1~3kb的流量,是不是很完美?

两者结合仍有不足

虽然,上面的方法可以说优化到绝大部分用户都满意的程度,但仍有优化的空间,最后的关卡就是:index.html,这个入口文件仍无法避免需要询问服务器,在极限情况下,例如用户断网了,服务器压力极大,网络非常拥挤等等,用户仍有可能出现高延迟,甚至无法访问,最后,我们要请出终极大杀器:ServiceWorker离线缓存。

ServiceWorker离线缓存

离线缓存并不是什么新鲜玩意,前面的强制缓存就也可以做到离线访问,只是不能更新,但这里利用ServiceWorker(下面简称SW),就可以做到离线访问,也可以更新文件。
SW的原理并不复杂,从整体上来讲,可以简单地把SW理解成一个代理。它会拦截所有的请求,结合自带的缓存对象进行文件的读取和更新,当浏览器访问旧文件时,SW会直接把缓存里的文件发回给浏览器(这里方便理解,实际上是主线程,而不是浏览器,SW也属于浏览器一部分),如果访问的是新文件,它会在后台向服务器请求,然后保存到缓存里,下次浏览器需要时再从缓存里返回给浏览器。但实际上,情况可能会更加复杂,这里不再细说,建议有兴趣的可以读一读这篇文章:《PWA 之 Service Worker 离线缓存》
5ed061c5c4cee40224ecd433
SW可以缓存所有类型的文件,甚至index.html也可以做到离线缓存。但如果服务器更新了新文件,那怎么处理?由于浏览器已经从缓存中加载了旧的index.html,所以目前最好的解决办法就是SW向浏览器发送一个「更新事件」,浏览器接收到之后就主动刷新一下页面,SW就会把最新的文件给回浏览器,保证页面文件保持最新。如果要体验更好,可以参考webpack的HotModuleUpdate功能,做一个部分更新,但成本会高很多。

总结

好了,到这里大家可能会想,那只要用serviceWorker就可以了吧?不用管之前的协商缓存和强制缓存。这说法不对,协商和强制缓存还是要做的,因为ServiceWorker只是帮忙处理缓存,它还是会向服务器请求文件,这个过程仍会依赖协商和强制缓存,所以最完美的办法就是把三者结合,这样既保证更新及时,也能保证读取速度。