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

avatar
前端工程师 @公众号:ELab团队

背景

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

  • HTTP缓存
  • 离线缓存(Application Cache)
  • Service Worker

HTTP缓存

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

缓存机制流程

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

强缓存

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

  • no-store:禁止使用缓存,每次请求都去服务端拿最新的资源;
  • no-cache:不使用强缓存,直接进入协商缓存模块,向服务端请求校验资源是否“新鲜”;
  • private:私有缓存,中间代理服务端不可缓存资源
  • public:公共缓存,中间代理服务端可以缓存资源
  • max-age:单位:秒,缓存的最长有效时间。其起始时间为缓存时响应头中的Date字段,即有效期到responseDate + max-age,发起请求时超过该时间则缓存过期。
  • must-revalidate:缓存一旦过期,则必须重新向服务端验证。

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有两种类型:

  • 强校验:资源hash值具有唯一性,一旦变化则hash也变化。
  • 弱校验:资源hash值以W/开头,若资源变化较小,则同样可能命中缓存。

例如下面这样:

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文件的规则,像文件保存在本地,之后当网络在处于离线状态时,浏览器会通过被离线存储的数据进行页面展示。主要应用在内容变动少、相对固定的场景下。其流程大致如下: image.png 它具备以下优势:

  • 离线浏览 - 用户可在应用离线时使用它们。
  • 更快的速度 - 已缓存资源加载得更快。
  • 减少服务器负载 - 浏览器将只从服务器下载更新过或更改过的资源。

文件配置

一个比较典型的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(必填)

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

  • NETWORK(可选)

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

NETWORK: 
index.css 
  • FALLBACK(可选)

标识出指定资源无法访问时,浏览器会使用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 对象来访问和操作离线缓存。

  • 缓存状态

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

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

注意事项

  • 更新清单中列出的某个文件并不意味着浏览器会重新缓存该资源,清单文件本身必须进行更改。
  • 浏览器对缓存数据的容量限制可能不太一样(某些浏览器设置的限制是每个站点5MB)。
  • 如果manifest文件,或者内部列举的某一个文件不能正常下载,整个更新过程都将失败,浏览器继续全部使用老的缓存。
  • 引用manifest的html必须与manifest文件同源,在同一个域下。FALLBACK中的资源必须和manifest文件同源。
  • 浏览器会自动缓存引用manifest文件的HTML文件,这就导致如果改了HTML内容,也需要更新manifest 文件版本或者由程序来更新应用缓存才能做到更新。

Service Worker

简介

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

特点

  • 独立于JS引擎的主线程,在后台运行的脚本,不影响页面渲染
  • 被install后就永远存在,除非被手动卸载。手动卸载方式:
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可以通过fetch这个api,来拦截网络和处理网络请求,再配合cacheStorage来实现web页面的缓存管理以及与前端postMessage通信。

  • 不能直接操纵dom:因为sw是个独立于网页运行的脚本,所以在它的运行环境里,不能访问窗口的window以及dom。
  • 生产环境必须是https的协议才能使用。在本地调试时,http://localhosthttp://127.0.0.1 也可以使用,不过需要勾选Bypass for network,否则资源静态资源都会被缓存(没有hash值),导致无法调试。

  • 异步实现,sw大量使用promise。
  • 根据文档中的描述,SW层面上cacheStorage容量不受限,但还是受到宿主环境 QuotaManager 的限制。

作用域

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事件。

  • Installing

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

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

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

  • Activating

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

  • Activated

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

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

  • Redundant

这个状态表示一个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缓存机制

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

Workbox webpack Plugins

让你的WebApp离线可用

HTML5 离线缓存-manifest简介

应用缓存初级使用指南

第4章 Service Worker · PWA 应用实战

Service Worker离线缓存实践