离线化/长缓存方案探究与实践

259次阅读  |  发布于3年以前

背景

最近在做资源改造,所有静态资源的url由服务端下发,而且带上了认证和过期时间等params,导致静态资源优化利器之一的HTTP缓存失效了:只要下发资源的url过期了资源就会重新请求下载,但实际上该资源并没有变更。为了解缓存相关的问题,本文就从三个方面来探究离线化/长缓存:

HTTP缓存

引用我的另一篇文章:一文读懂HTTP缓存机制[1]一句话概况:本地缓存请求到的资源,后续请求尽可能直接复用这些资源,减少HTTP请求,从而显著提高网站和应用程序的性能。那么什么时候缓存资源到本地?缓存资源什么时候过期?什么情况下使用这些缓存的资源呢?

缓存机制流程

从流程中可以看到,浏览器发起资源请求后,大致有三部分:强缓存校验、协商缓存校验、资源请求。本文主要讲解强缓存和协商缓存模块,资源请求部分就是正常的一次HTTP交互过程,但值得注意的是:因为一般只有GET请求才会被缓存,所以这里泛指一般的GET资源请求。

强缓存

不需要额外向服务端发送请求,直接使用本地缓存。在Chrome浏览器中本地强缓存分为两类,一类是disk cache,一类是memory cache,查看devtools中的Networks会看到请求状态为200,并且后面跟着from disk cache和from memory cache的请求就是使用了强缓存,如下面两个图。 本人也尚未了解Chrome浏览器如何控制两种强缓存,故不展开了,以免误导读者,希望能有高手指出!!!!这里放上找到的Chrome官方文档[2]中的描述,其大体意思是两种强缓存策略与渲染进程的生命周期有关,渲染进程的周期又大致与tab选项卡相对应:

Chrome employs two caches — an on-disk cache and a very fast in-memory cache. The lifetime of an in-memory cache is attached to the lifetime of a render process, which roughly corresponds to a tab. Requests that are answered from the in-memory cache are invisible to the web request API.

是否使用强缓存由HTTP的三个头部字段来控制:Expires、Pragma、Cache-Control。

Expires

Exipres字段是http/1.0中的字段,其优先级在三个缓存控制字段中最低。 如图所示,响应头中Expires的值是一个时间戳,发起请求时,如果本地系统时间在这个时间戳之前,则缓存有效,否则缓存失效,进入协商缓存。若该响应头中Expires设置为无效的日期,比如 0, 则代表着过去的日期,即该资源已经过期。

Cache-Control

Cache-Control是 HTTP/1.1 中规定的通用头部字段,常用属性如下:

Pragma

Pragma是 HTTP/1.0 中规定的通用头部字段,用于向后兼容只支持 HTTP/1.0 协议的缓存服务端。这个字段只有一个值:no-cache,其表现行为与Cache-Control: no-cache一致,但是HTTP的响应头没有明确定义这个属性,所以它不能拿来完全替代HTTP/1.1中定义的Cache-control头。

如果Pragma 和 Cache-Control 两个字段同时存在,Pragma的优先级大于Cache-Control。

协商缓存

当强缓存过期或者请求头字段设置不走强缓存,比如Cache-Control:no-cache和Pragma:no-cache,则进入协商缓存部分。协商缓存涉及两对头部字段,分别是Last-Modified/If-Modified-Since、和ETag/If-None-Match。若请求头中携带If-Modified-Since或If-None-Match字段,则会发起去服务端校验资源是否有变化,如果有变化,则未命中缓存,服务端返回200,浏览器计算响应体资源是否缓存并使用资源;如果未变换,则命中缓存,返回304,浏览器根据响应头更新缓存头部信息,延长有效期,并直接使用缓存。

Last-Modified/If-Modified-Since

Last-Modified/If-Modified-Since的值是资源修改时间。第一次请求资源时,服务端将资源的最后修改时间放到响应头的 Last-Modified 字段中,第二次请求该资源时,浏览器会自动将该资源上一次响应头中的Last-Modified的值放到第二次请求头的If-Modified-Since字段中,服务端比较服务端资源的最后一次修改时间和请求头中的If-Modified-Since 的值,如果相等,则命中缓存返回 304,否则,返回200。

ETag/If-None-Match

ETag/If-None-Match 的值是一串hash值(hash算法不统一),是资源的标识符,当资源内容发生变化,其hash值也会改变。其过程与上面的相似,不过服务端是比较服务端资源的hash值和请求头中的If-None-Match的值,但比较方式有所区别,因为ETag有两种类型:

例如下面这样:

ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4" ETag: W/"0815"

两者区别

  1. ETag/If-None-Match优先级比Last-Modified/If-Modified-Since高;
  2. Last-Modified/If-Modified-Since有个1S问题,即服务端在1S内修改文件,且再次受到请求时,会错误的返回304。

代理服务缓存

Vary是HTTP/1.1中的一个头字字段,其值为请求头中的字段,如上图中的Accept-Encoding,可以是多个,以逗号分割,其记录了代理服务器返回资源参考了哪些请求头字段。代理服务器拿到源服务器的响应报文,会根据 Vary 里的字段列表,缓存不同版本的资源。当有资源请求再次访问时,代理服务器会分析请求头字段,返回正确的版本。

Application Cache(已废弃)

虽然部分浏览器依然支持,但是W3C已经废弃该方案,推荐开发者使用Service Worker。

简介

HTML5的离线存储(Application Cache)是基于一个manifest文件(缓存清单文件,一般后缀为.appcache)的缓存机制(不是存储技术)。在该文件中定义需要缓存的文件,支持manifest的浏览器,会将按照manifest文件的规则,像文件保存在本地,之后当网络在处于离线状态时,浏览器会通过被离线存储的数据进行页面展示。主要应用在内容变动少、相对固定的场景下。其流程大致如下: 它具备以下优势:

文件配置

一个比较典型的manifest文件结构如下:

CACHE MANIFEST 
#version 1.0 

CACHE: 
/static/img/dalizhineng.c66247e.png 
http://localhost:8080/static/img/setting-icon-hover.413c0d7.png 

NETWORK: 
* 

FALLBACK: 
/html5/ /404.html 

第一行的CACHE MANIFEST是固定行,必须写在前面。一般第二行是以 # 号开头的注释,当有缓存文件需要更新时,更改注释内容即可。可以是版本号,时间戳或者md5码等。剩下内容分为三个部分(可按任意顺序排列,且每个部分均可在同一清单中重复出现):

标识出哪些文件需要缓存,可以是相对路径,也可以是绝对路径。

标识出哪些文件必须经过网络请求。可以是相对路径或绝对路径,表示指定资源必须经过网络请求;也可以直接使用通配符*,表示除CACHE外的所有资源都需要网络请求。比如下面的例子就是‘index.css’永远不会被缓存,必须走网络请求。

NETWORK: 
index.css 

标识出指定资源无法访问时,浏览器会使用fallback资源。其中每条记录都列出两个URI:第一个表示资源,第二个表示fallback资源。两个 URI 都必须使用相对路径并且与manifest文件同源。可以使用通配符,比如下面的例子就是页面无法访问时,使用404.html替代。

FALLBACK: 
*.html /404.html 

使用方法

在文档的html标签中设置manifest 属性,引用manifest文件 ,可指向绝对网址或相对路径,但绝对网址必须与相应的网络应用同源,且必须要在服务器端正确的配置MIME-type,即“text/cache-manifest”。

<html lang="en" manifest="manifest.appcache">    

访问及操作缓存

部分浏览器提供了 window.applicationCache[3] 对象来访问和操作离线缓存。

window.applicationCache.status属性表示当前缓存状态。

状态 状态值 描述
UNCACHED 0 无缓存, 即没有与页面相关的应用缓存
IDLE 1 闲置,即应用缓存未得到更新
CHECKING 2 检查中,即正在下载描述文件并检查更新
DOWNLOADING 3 下载中,即应用缓存正在下载描述文件
UPDATEREADY 4 更新完成,所有资源都已下载完毕
OBSOLETE 5 废弃,即应用缓存的描述文件已经不存在了,因此页面无法再访问应用缓存
事件名 描述
cached 下载完成并且首次将应用程序下载到缓存中时,浏览器会触发“cached“事件
checking 每当应用程序载入的时候,都会检查该清单文件,也总会首先触发“checking”事件
downloading 如果还未缓存应用程序,或者清单文件有改动,那么浏览器会下载并缓存清单中的所有资源 ,触发"downloading"事件,同时意味着下载过程开始
error 如果浏览器处于离线状态,检查清单列表失败,则会触发“error“事件,当一个未缓存的应用程序引用一个不存在的清单文件,也会触发此事件
noupdate 如果没有改动,同时应用程序也已经缓存了,“noupdate”事件被触发,整个过程结束
obsolete 如果一个缓存的应用程序引用一个不存在的清单文件,会触发“obsolete“,同时将应用从缓存中移除之后不会从缓存而是通过网络加载资源
progress 在下载过程中会间断性触发“progress”事件,通常是在每个文件下载完毕的时候
updateready 当下载完成并将缓存中的应用程序更新后,浏览器会触发”updaterady”事件
方法名 描述
abort 取消资源加载
swapCache 使用新缓存替换旧缓存,不过使用location.reload()更方便
update 更新缓存

注意事项

Service Worker

简介

service worker也是一种web worker[4],额外拥有持久离线缓存的能力。宿主环境会提供单独的线程来执行其脚本,解决js中耗时间、耗资源的运算过程带来的性能问题。从下图可以看到除IE以外,支持度挺高的。

特点

if ('serviceWorker' in navigator) { 
  navigator.serviceWorker.getRegistrations() 
    .then(function (registrations) { 
      for (let registration of registrations) { 
        // 找到需要移除的SW 
        if (registration && registration.scope === 'https://xxx.com') { 
          registration.unregister(); 
        } 
      } 
    }); 
} 

作用域

SW的作用域是一个 URL path 地址,表示SW能够控制的页面的范围。比如下面就能控制http://localhost:8080/ehx-room/ 目录下的所有页面。默认的作用域就是注册时候的 path,下面的例子就是./ehx-room/sw.js。 也可以在 navigator.serviceWorker.register() 方法中传入 {scope: '/xxx/yyyy/'} 参数指定作用域,但是指定scope必须在SW注册的path的目录下,比如上面的sw注册时加上,{scope: '/'}就会报错。

生命周期

当我们注册了Service Worker后,它会经历生命周期的各个阶段,同时会触发相应的事件。整个生命周期包括了:installing --> installed --> activating --> activated --> redundant。当Service Worker安装(installed)完毕后,会触发install事件;而激活(activated)后,则会触发activate事件。

该状态发生在service worker注册之后,表示开始安装。在这个过程会触发install事件,可以进行资源离线缓存。

  1. 在install回调事件函数中,可以调用event.waitUntil()方法并传入一个promise,直到promise完成才会结束install。
  2. 也可以使用self.skipWaiting()方法直接进入activating状态,无需等待其他的Service worker被关闭

SW已经完成了安装,进入了waiting状态,等待其他的Service worker被关闭

在这个状态下没有被其他的SW控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。

在这个状态会处理activate事件回调,并提供处理功能性事件:fetch、sync、push。

除了支持event.waitUntil()方法以外,在activate回调事件函数中,还可以使用self.clients.claim()方法控制当前打开的网页,且不需要刷新。

这个状态表示一个SW的生命周期结束,正在被另一个SW替代。

工作流程

  1. 在主线程成功注册 Service Worker 之后,开始下载并解析执行 Service Worker 文件,执行过程中开始安装 Service Worker,在此过程中会触发 worker 线程的 install 事件。
  2. 如果 install 事件回调成功执行(在 install 回调中通常会做一些缓存读写的工作,可能会存在失败的情况),则开始激活 Service Worker,在此过程中会触发 worker 线程的 activate 事件,如果 install 事件回调执行失败,则生命周期进入 Error 终结状态,终止生命周期。
  3. 完成激活之后,Service Worker 就能够控制作用域下的页面的资源请求,可以监听 fetch 事件。
  4. 如果在激活后 Service Worker 被 unregister 或者有新的 Service Worker 版本更新,则当前 Service Worker 生命周期完结,进入 Terminated 终结状态。

示例

// 在页面onload事件回调中,注册SW 
if ('serviceWorker' in navigator) { 
  window.addEventListener('load', () => { 
    navigator.serviceWorker.register('service-worker.js') 
      .then(registration => { 
        // 注册成功 
      }) 
      .catch(err => { 
        // 注册失败 
      }); 
  }); 
} 
// service-worker.js 
const CACHE_VERSION = 'unique_v1'; 

// 监听activate事件,激活后清除其他缓存 
self.addEventListener('activate', event => { 
  const cachePromise = caches.keys().then(keys => { 
    return Promise.all( 
      keys.map(key => { 
        if (key !== CACHE_VERSION) { 
          return caches.delete(key); 
        } 
      }) 
    ); 
  }); 
  event.waitUntil(cachePromise).then(() => { 
    // 通过clients.claim方法,让新的SW获得当前页面的控制权 
    return self.clients.claim(); 
  }); 
}); 

self.addEventListener('fetch', event => { 
  event.respondWith( 
    caches 
      .match(event.request, { 
        // 忽略url上的query部分 
        ignoreSearch: DEFAULT_CONFIG.ignoreURLParametersMatching, 
      }) 
      .then(response => { 
        // 如果匹配到缓存里的资源,则直接返回 
        if (response) { 
          return response; 
        } 
        // 匹配失败则继续请求,拷贝原始请求 
        const request = event.request.clone(); 
        const url = request.url; 
        if (matchOne(url, DEFAULT_CONFIG.exclude)) { 
          return fetch(request); 
        } else if (request.method === 'GET' && matchOne(url, DEFAULT_CONFIG.include)) { 
          return fetch(request).then(httpRes => { 
            // 正确请求才缓存 
            if (httpRes && [200, 304].includes(httpRes.status)) { 
              // 缓存资源 
              const responseClone = httpRes.clone(); 
              caches.open(DEFAULT_CONFIG.cacheId).then(cache => { 
                cache.put(event.request, responseClone); 
              }); 
            } 
            return httpRes; 
          }); 
        } else { 
          return fetch(request); 
        } 
      }), 
  ); 
}); 

总结

方法\类别 颗粒度 是否需要联网 能否主动更新 大小限制
HTTP缓存 单个资源 强缓存资源可离线使用 浏览器QuotaManager限制
Application Cache 整个应用 一般5MB
Service Worker 单个资源 浏览器QuotaManager限制

参考文档

一文读懂HTTP缓存机制[6]

借助Service Worker和cacheStorage缓存及离线开发[7]

Workbox webpack Plugins[8]让你的WebApp离线可用[9]

HTML5 离线缓存-manifest简介[10]

应用缓存初级使用指南[11]

第4章 Service Worker · PWA 应用实战[12]

Service Worker离线缓存实践[13]

参考资料

[1]一文读懂HTTP缓存机制: https://blog.csdn.net/sinat_36521655/article/details/106221905

[2]Chrome官方文档: https://developer.chrome.com/docs/extensions/reference/webRequest/#Caching

[3]window.applicationCache: https://webplatform.github.io/docs/apis/appcache/ApplicationCache/

[4]web worker: https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API

[5]文档中: https://juejin.cn/post/6844903485457055758

[6]一文读懂HTTP缓存机制: https://juejin.cn/post/6844904163172679688

[7]借助Service Worker和cacheStorage缓存及离线开发: https://www.zhangxinxu.com/wordpress/2017/07/service-worker-cachestorage-offline-develop/

[8]Workbox webpack Plugins: https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin

[9]让你的WebApp离线可用: https://pwa.alienzhou.com/3-rang-ni-de-webapp-li-xian-ke-yong

[10]HTML5 离线缓存-manifest简介: http://igeekbar.com/igeekbar/post/306.htm

[11]应用缓存初级使用指南: https://www.html5rocks.com/zh/tutorials/appcache/beginner/

[12]第4章 Service Worker · PWA 应用实战: https://lavas-project.github.io/pwa-book/chapter04.html

[13]Service Worker离线缓存实践: https://juejin.cn/post/6844903906670018568

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8