PWA 系列 -- Fetch 技术

PWA 系列 -- Fetch 技术

前言


我们在之前推送的文章中为读者们分享了 PWA 这项技术,并有文章分别介绍了 ServiceWorker 和 Web Push 技术。今天我们会为大家分享 PWA 技术中的数据请求机制 -- Fetch 技术。阅读本文,你将会了解 Fetch 技术的介绍,使用场景,实例,最后,我们会看一下 Fetch 技术的未来发展。结合上一篇的 Catch 技术,两个技术组合为前端开发者带来了精细控制 web 数据的能力。

背景信息

MLHttpRequest 是微软在 IE5 提供的一个接口,使得 JS 可以在不刷新整个页面的前提下向服务器异步请求数据。它随后被 Mozilla、Apple 和 Google 采纳。W3C 从2006年也开始对它进行了标准化,提出了 XMLHttpRequest Level 1和 XMLHttpRequest Level 2。

  • XMLHttpRequest Level 1 在2006年完成标准草案,主要存在以下缺点:
    受同源策略的限制,不能发送跨域请求;
  • 不能发送二进制文件(如图片、视频、音频等),只能发送纯文本数据;
  • 在发送和获取数据的过程中,无法实时获取进度信息,只能判断是否完成;

XMLHttpRequest Level 2 在2008年完成标准草案,新增了以下功能:

  • 可以发送跨域请求,在服务端允许的情况下;
  • 支持发送和接收二进制数据;
  • 新增formData对象,支持发送表单数据;
  • 发送和获取数据时,可以获取进度信息;
  • 可以设置请求的超时时间;

从上述信息可以看到,XMLHttpRequest 可以提供完备的功能,是 Ajax 的关键技术,可以局部交换客户端及服务器之间的数据,从根本上保证了 Web 页面的动态性。而本文重点讨论的是一个更加强大的技术 ServiceWorker Fetch API。
本文主要面向前端开发者和客户端开发者,文章会在面向客户端开发者的内容上加以说明,未说明的内容则适用全部开发者。读者可以根据自身需求或兴趣选择性阅读。

接口说明

Fetch API 的 Request 有几种模式(Request.mode):

  • same-origin — 如果一个请求是跨域的,会返回一个错误,这样确保所有的请求遵守同源策略;
  • no-cors — 允许不带 CORS 头部跨域请求资源,但请求的方法只限定为 HEAD, GET 或 POST。如果 ServiceWorkers 拦截了这些请求,它们不能添加或修改除了这些之外的请求头。另外,JS 不允许访问 Response 对象的属性,这确保 ServiceWorkers 不会影响 Web 的语义,并防止由于跨域泄露数据而导致的安全和隐私问题;
  • cors — 允许附带 CORS 头部跨域请求资源,例如访问第三方提供的 API 以获取资源。这些请求都需要遵守 CORS 协议,并且只有部分 Headers 暴露给 Response,但 Body 是可读的;
  • navigate — 支持 navigation 请求,即跳转的目标地址为 HTML 文档的请求。参考 Chrome 49: New value "navigate" for Request.mode;

Request 的 credentials 属性决定了是否允许跨域访问 Cookie,与 XHR 的 withCredentials 类似。它也有几种模式:

  • omit: 不允许发送 Cookie,属于 Fetch 请求的默认行为;
  • same-origin: 如果 URL 与调用的 JS 同源,则允许发送 Cookie,否则就不允许;
  • include: 允许发送 Cookie,跨域请求也允许;

Request 的 cache 属性决定了 Fetch 请求与内核 HTTP cache 交互的模式:

  • default — 默认情形,内核会以下面方式使用 HTTP cache:
  • 如果命中 HTTP cache 并且资源是 fresh 的,会直接使用 cache。
  • 如果命中 HTTP cache 并且资源是 stale 的,内核会发起条件请求,如果服务器回应资源未有修改,则直接使用 cache,否则会下载资源和更新 cache。
  • 如果没有命中 HTTP cache,内核会发起普通请求,下载资源和更新 cache。
  • no-store — 内核忽略 HTTP cache,直接向服务器请求资源,资源下载成功后也不会更新 HTTP cache。
  • reload — 内核忽略 HTTP cache,直接向服务器请求资源,但资源下载成功后会更新 HTTP cache。
  • no-cache — 内核会以下面方式使用 HTTP cache:
  • 如果命中 HTTP cache 并且资源是 fresh/stale 的,内核会发起条件请求,如果服务器回应资源未有修改,则直接使用 cache,否则会下载资源和更新 cache。
  • 如果没有命中 HTTP cache,内核会发起普通请求,下载资源和更新 cache。
  • force-cache — 内核会以下面方式使用 HTTP cache:
  • 如果命中 HTTP cache 并且资源是 fresh/stale 的,会直接使用 cache。
  • 如果没有命中 HTTP cache,内核会发起普通请求,下载资源和更新 cache。
  • only-if-cached — 内核会以下面方式使用 HTTP cache:
  • 如果命中 HTTP cache 并且资源是 fresh/stale 的,会直接使用 cache。
  • 如果没有命中,会返回错误。
  • 这个模式只允许在 Request.mode = "same-origin"时使用。

Request 或 Response 的 headers 属性,返回一个 Headers 对象,有一个特殊的 guard 属性,取值如下:

Request 的 redirect 属性在 Chrome 46版本实现支持,取值如下:

Response 的 redirected 属性标记请求是否有经过跳转。
Response 的 type 属性标记响应的类型,取值如下:

Request/Response 的 bodyUsed 属性用于标记 Body 是否已被读取过。Request 和 Response 的 body 只能被读取一次,如果需要重复读取,必须在读取之前调用 clone 获取到一个克隆对象。

基本流程

本节内容涉及到内核流程,读者可以根据自身需求选择性阅读。

  • 正常 Fetch 流程

ServiceWorkers 的 Fetch,是通过 ServiceWorkerGlobalScope.idl 暴露给页端使用。其中,FetchManager 是各类 Fetch 的入口和出口,WorkerThreadableLoader 管理 worker 线程的资源请求,接着会走到 ResourceFetcher 和 HttpCacheTransaction,往下走到网络或者被拦截,响应会经过 ServiceWorker 的缓存处理模块,比如,ServiceWorkerWriteToCacheJob。

  • 跨域检查不通过的流程

ServiceWorkers 的 Fetch,收到响应后,会在 DocumentThreadableLoader 进行跨域检查,JS 执行时(ScriptLoader::executeScript)也会进行跨域检查,如果检查不通过,会出现 “TypeError: Failed to fetch.” 之类的错误。
注1,想了解基本的 Fetch 流程,读者可自行检索文章 - Introduction to fetch()。
注2,想了解如何使用 Fetch 技术搭建离线应用,读者可自行检索文章 - The Offline Cookbook。

请求拦截

页端拦截

页端 JS 可以监听 Fetch 事件,通过 FetchEvent.respondWith 返回符合期望的 Response,即通过拦截 Response 而达到控制存储策略的目的。一般来说,有以下几种方式:

  1. 仅使用 Cache
self.addEventListener('fetch', function(event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

2. 仅使用网络

self.addEventListener('fetch', function(event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behaviour
});

3. 优先使用缓存,失败则使用网络

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      returnresponse || fetch(event.request);
});

4. 缓存与网络竞争,谁快就用谁

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  returnnewPromise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map(p => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach(p => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b))
      .catch(() => reject(Error("All failed")));
};self.addEventListener('fetch', function(event) {
  event.respondWith(
    promiseAny([
      caches.match(event.request),
      fetch(event.request)
});

5. 优先使用网络,失败则使用缓存

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      returncaches.match(event.request);
});

6. 先使用缓存,再访问网络更新缓存,等同于后置验证

Code in the page:
var networkDataReceived = false;
startSpinner();
// fetch fresh data
var networkUpdate = fetch('/data.json').then(function(response) {
  returnresponse.json();
}).then(function(data) {
  networkDataReceived = true;
  updatePage();
// fetch cached data
caches.match('/data.json').then(function(response) {
  if(!response) 
    throwError("No data");
  return response.json();
}).then(function(data) {
  // don't overwrite newer network data
  if(!networkDataReceived) {
    updatePage(data);
}).catch(function() {
  // we didn't get cached data, the network is our last hope:
  return networkUpdate;
}).catch(showErrorMessage).then(stopSpinner);
Code in the ServiceWorker:
self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function(cache) {
      returnfetch(event.request).then(function(response) {
        cache.put(event.request, response.clone());
        returnresponse;
});

7. 常规的回退流程,在缓存和网络都不可用时,可以提供一个默认页面

self.addEventListener('fetch', function(event) {
  event.respondWith(
    // Try the cache
    caches.match(event.request).then(function(response) {
      // Fall back to network
      returnresponse || fetch(event.request);
    }).catch(function() {
      // If both fail, show a generic fallback:
      returncaches.match('/offline.html');
      // However, in reality you'd have many different
      // fallbacks, depending on URL & headers.
      // Eg, a fallback silhouette image for avatars.
});

注,本节内容来自官方文档 The Offline Cookbook。

客户端拦截

本节内容适合客户端开发者,读者可以根据自身需求选择性阅读。
一般来说,基于 WebView 的 Hybrid Apps 实现离线的方式是,提前将资源下载到客户端,在页面发起资源请求时,客户端就可以在 WebViewClient.shouldInterceptRequest 接口进行拦截,使用本地资源构造相应的 Response,让其优先使用本地资源,从而实现离线的功能。但是,ServiceWorker 的请求并不会和具体的 WebView 关联,即不会在 WebViewClient.shouldInterceptRequest 接口回调,那么我们如何进行 ServiceWorker fetch 请求的拦截呢?
Android7.0(Chromium49)提供了 ServiceWorkerController.shouldInterceptRequest 接口,专门用于拦截 ServiceWorker 的请求。拦截的流程如下:

客户端拦截的代码示例

ServiceWorkerController swController = ServiceWorkerController.getInstance();
swController.setServiceWorkerClient(newServiceWorkerClient() {
  @Override