目录
合理利用缓存是Web性能优化的必要手段,前端工程师所接触的主要是针对客户端浏览器的缓存策略,客户端的缓存可以分为以下两种。
【1】利用本地存储,比如LocalStorage、SessionStorage等。
【2】利用HTTP缓存策略,其中又分为强制缓存与协商缓存。
其中对于本地存储的利用属于代码架构层面的优化措施,不属于前端工程体系的服务范畴。HTTP缓存需要服务器配合,比如Apache、Ngnix等服务器软件可以为资源设置不同的HTTP缓存策略。增量更新是目前大部分团队采用的缓存更新方案,结合HTTP强制缓存策略,既能够保证用户在第一时间获取最新资源,又可以减少网络资源消耗,提高Web应用程序的执行速度。前端工程体系在此中的作用如下。
【1】构建产出文件hash指纹,这是实现增量更新的必要条件。
【2】构建更新html文件对其他静态资源的引用URL。
浏览器对静态资源的缓存本质上是HTTP协议的缓存策略,其中又可以分为强制缓存和协商缓存。两种缓存策略都会将资源缓存到本地,强制缓存策略根据过期时间决定使用本地缓存还是请求新资源;而协商缓存每次都会发出请求,经过服务器进行对比后决定采用本地缓存还是新资源。具体采用哪种缓存策略,由HTTP协议的首部(Headers)信息决定。
Expires和max-age
Expires和max-age是强制缓存策略的关键信息,两者均是响应首部信息的。Expires是HTTP 1.0加入的特性,通过指定一个明确的时间点作为缓存资源的过期时间,在此时间点之前客户端将使用本地缓存的文件应答请求,而不会向服务器发出实体请求(在浏览器调试面板中可以看到此请求并且状态码为200)。Expires的优点是可以在缓存过期时间内减少客户端的HTTP请求,不仅节省了客户端处理时间和提高了Web应用的执行速度,而且也减少了服务器负载以及客户端网络资源的消耗。一个典型的Expires首部信息如下:
Expires有一个致命的缺陷是:它所指定的时间点是以服务器为准的时间,但是客户端进行过期判断时是将本地的时间与此时间点对比。也就是说,如果客户端的时间与服务器存在误差,那么通过上述Expires控制的缓存资源将会失效,客户端将会发送实体请求获取对应资源。这显然是不合理的。
Cache-control
针对这个问题,HTTP 1.1新增了Cache-control首部信息以便更精准地控制缓存。常用的Cache-control信息有以下几种。
no-cache和no-store:“no-cache”并非禁止缓存,而是需要先与服务器确认返回的响应是否发生了变化,如果资源未发生变化,则可使用缓存副本从而避免下载。“no-store”是真正意义上的禁止缓存,禁止浏览器以及所有中间缓存存储任何版本的返回响应。每次用户都会向服务器发送请求,并下载完整的响应。
public和private:“public”表示此响应可以被浏览器以及中间缓存器无限期缓存,此信息并不常用,常规方案是使用max-age指定精确的缓存时间。“private”表示此响应可以被用户浏览器缓存,但是不允许任何中间缓存器对其进行缓存。例如,用户的浏览器可以缓存包含用户私人信息的HTML网页,但CDN却不能缓存。
max-age:指定从请求的时刻开始计算,此响应的缓存副本有效的最长时间(单位:秒)。例如,“max-age=3600”表示浏览器在接下来的1小时内使用此响应的本地缓存,不会发送实体请求到服务器。
max-age指定的是缓存的时间跨度,而非缓存失效的时间点,不会受到客户端与服务器时间误差的影响。所以,与Expires相比,max-age可以更精确地控制缓存,并且比Expires有更高的优先级。强制缓存策略下(Cache-control未指定no-cache和no-store)的缓存判断流程如图所示。
Etag和If-none-match
Etag是服务器为资源分配的字符串形式唯一性标识,作为响应首部信息返回给浏览器。浏览器在Cache-control指定no-cache或者max-age和Expires均过期之后,将Etag值通过If-none-match作为请求首部信息发送给服务器。服务器接收到请求之后,对比所请求资源的Etag值是否改变,如果未改变将返回304 Not Modified,并且根据既定的缓存策略分配新的Cache-control信息;如果资源发生了改变,则会返回最新的资源以及重新分配的Etag值。整体流程如图所示。
如果强制浏览器使用协商缓存策略,需要将Cache-control首部信息设置为no-cache,这样便不会判断max-age和Expires过期时间,从而每次资源请求都会经过服务器对比。协商缓存并非是一种比强制缓存“低级”的策略,对于一些特殊的应用场景或资源,协商缓存是优于强制缓存的。
前面我们讨论的非服务器端渲染项目中的HTML文档,由于它是所有其他静态资源的引用者,所以必须保证每次请求到的资源都是最新的。同时,为了便于服务器解析和保证网站地址的唯一性,html文件不能应用hash指纹。在这种场景下只能使用协商缓存。
覆盖更新与增量更新都是建立在启用浏览器强制缓存策略的前提下的。增量更新是目前被业界广泛使用的前端静态资源更新策略,普遍的实现方案是通过为文件名添加hash指纹。覆盖更新的缺陷较多且没有较好的解决方案,目前已逐渐被淘汰。接下来我们通过一个具体的应用场景来说明两者的区别以及增量更新方案的优势。
假设项目中存在一个css文件和一个js文件,由index.html引入:
- <head>
- <link rel=“stylesheet” href=“main.home.css”>
- head>
- <body>
- <script type="text/javascript" src=“main.home.js”>
- body>
为了提高页面的加载性能,我们启用强制缓存策略,main.a.css和main.a.js均被缓存到本地并且设置了max-age为30天。如果在缓存有效期内项目需要进行迭代,为了保证让用户第一时间获取到最新资源,就必须让浏览器放弃使用之前的缓存文件,发送实体请求下载最新的资源。覆盖更新策略的实现方案是在引用资源的URL后添加请求参数,比如添加时间戳参数:
- <head>
- <link rel=“stylesheet” href=“main.home.css? v=1.0.0”>
- head>
- <body>
- <script type="text/javascript" src=“main.home.js? v=1.0.0”>
- body>
浏览器会将参数不同的URL视为全新的URL,所以上述改动可以保证浏览器向服务器请求并下载最新的资源。但是问题来了,为了更好地利用缓存,我们应该只更新有改动的资源,未改动的资源继续使用缓存。假设我们只改动了main.home.js, main.home.css没有任何改动,则应该只更新main.home.js的URL,如下:
- <head>
- <link rel=“stylesheet” href=“main.home.css? v=1.0.0”>
- head>
- <body>
- <script type="text/javascript" src=“main.home.js? v=1.0.1”>
- body>
有针对性的参数修改工作对于开发人员来说并不困难,因为参与开发的人员知道哪些文件有改动和哪些文件未改动。但是这种人为操作是非常烦琐的体力活,并且不能避免人为失误。所以更好的方式是使用工具取代人工。但是工具没有记忆,如果想让工具识别改动文件并且有针对性地修改参数,要么通过人告诉工具改动的文件列表,要么让工具自动获取文件改动之前的内容后逐一对比。不论哪种方案都非常耗时、耗力。
要解决这个问题,我们首先思考一下静态资源URL后的v参数的意义。它唯一的作用就是让浏览器更新资源,如果这个参数的值能够跟文件内容一一对应,是不是就可以实现针对性修改?这就是hash指纹:通过既定的数据摘要算法(目前使用较广泛的是md5)计算出文件的hash值。将hash指纹作为url参数的用法如下:
- <head>
- <link rel="stylesheet" href="main.home.css? v=858d5483">
- head>
- <body>
- <script type="text/javascript" src="main.home.js? v=bbcdaf73">
- body>
然而将hash指纹作为url参数值实现覆盖更新的方案有如下两个致命缺陷。
第一:必须保证html文件与改动的静态文件同步更新,否则会出现资源不同步的情况。如果是无服务器端渲染的项目,html文件会被视为静态资源,并且与其他静态资源(JS/CSS/图片等)部署到同一台服务器,在这种场景下我们可以保证所有资源更新的同步,不会受到覆盖更新缺陷的影响。但这种部署方式并不适用所有项目,对于依赖服务器端渲染的项目,目前大多数团队的部署方式是将网站的入口HTML和静态资源分开部署。比如,将HTML与服务器代码一同部署到域名为www.app.com对应的服务器内,把JS/CSS/图片等静态资源部署到static.app.com对应的服务器上。两种资源分开部署必然有先后顺序,这也就意味着两种资源的上线存在一定的时间差。不论是先部署哪种资源都无法保证这个时间差内所有用户访问页面的正确性,即使这个时间差很小,对于淘宝这种访问量庞大的网站来说也会影响相当可观的用户群。这就是为何很多团队总是选择在半夜或凌晨这种网站访问量较小的时间段发布新版本的原因之一。
第二:不利于版本回滚。由于覆盖更新每次迭代之后的资源都会覆盖服务器上原有的旧版本文件,这对于版本回滚操作很不友好。运维人员要么借助于服务器本身的缓存机制,要么拿到旧版本文件再次覆盖部署。
增量更新策略完美地解决了上述缺陷,实现的方案很简单,将原本作为参数值的hash指纹作为资源文件名的一部分并且删除用于更新的url参数。比如上文提到的代码改为增量更新策略之后的形式如下:
- <head>
- <link rel=“stylesheet” href=“main.home.858d5483.css”>
- head>
- <body>
- <script type="text/javascript" src=“main.home.bbcdaf73.js”>
- body>
在静态资源使用增量更新策略的前提下,可以将静态资源先于动态HTML部署,此时静态资源没有引用入口,不会对线上环境产生影响;动态HTML部署后即可在第一时间访问已存的最新静态资源。这样便解决了覆盖更新部署同步性的问题。另外,增量更新修改了资源文件名,不会覆盖已存的旧版本文件,运维人员进行回滚操作时只需回滚HTML即可。这样不仅优化了版本控制,而且还可以支持多版本共存的需求。
按需加载与多模块架构场景下的增量更新
多模块架构指的是存在多个互不干扰的模块体系,这些模块体系可能存在于同一页面中,也可能存在于两个独立页面。对于按需加载需求和在多模块架构场景下实现增量更新,需要考虑以下几个问题。
【1】同步模块的修改对异步文件和主文件hash指纹产生的影响。
【2】异步模块的修改对主文件hash指纹产生的影响。
同步模块的修改对异步文件和主文件hash指纹的影响
假设一个单页面项目的模块结构如图所示
【1】主模块main.app.js。
【2】同步模块module.sync.js,构建后与主模块合并为主文件main.app. [hash].js,同步加载。
【3】异步模块module.async.js,单独构建为异步文件app.async.[hash].js,按需加载。
构建输出的文件[hash]值是经过md5计算所得的,参与计算的模块内容改动后必然影响计算后的结果。同步模块module.async.js的内容作为计算因子参与主文件的hash指纹计算,并未参与异步文件hash指纹的计算。所以可以确定的是,同步模块的修改影响主模块的hash指纹,对异步文件无影响。
异步模块的修改对主模块的hash指纹产生的影响
异步模块的内容只影响异步文件的hash指纹,是这样吗?在回答这个问题之前,我们先弄清楚异步文件是如何加载的。下述代码是一种比较普遍的加载异步文件的逻辑:
- window.onload = function(){
- var script = document.createElement('script');
- sciprt.src = 'https://static.app.com/async.js'; //异步文件URL
- document.head.append(script);
- };
异步文件的URL被固定写死在负责加载它的主文件中,如果应用了hash指纹,上述代码经过构建之后的内容如下:
- window.onload = function(){
- var script = document.createElement('script');
- sciprt.src = 'https://static.app.com/async.2483fae1.js'; //异步文件URL
- document.head.append(script);
- };
假设此时主文件为main.home.bbcdaf73.js,当前版本的所有资源被缓存在用户本地。经过新版迭代之后只改动了异步模块的内容,经过构建后异步文件的hash指纹更新为async.6203b33c.js,那么主文件的hash指纹是否变化呢?
我们首先假设主文件的hash指纹没有变化,新版发布之后,HTML文档中对于主文件的引用URL没有改动,浏览器仍旧使用缓存副本main.home. bbcdaf73.js。主文件中异步文件的URL仍旧是“https://static.app.com/async.2483fae1.js”,也就是说,即使我们更新了异步文件的hash指纹,也并没有令浏览器请求到最新的资源,这显然是不合理的。所以,异步模块的修改不仅仅影响其对应异步文件的hash指纹,主文件的hash指纹也必须同步修改,这样才能保证用户得到最新的异步文件。