2016-08-27

[译] Fetch 请求的本地缓存

原文作者: Peter Bengtsson
原文地址: https://www.sitepoint.com/cache-fetched-ajax-requests/
译文地址: http://www.wemlion.com/post/cache-fetched-ajax-requests
本文由 文蔺 翻译,转载请保留此声明。
著作权属于原作者,本译文仅用于学习、研究和交流目的,请勿用于商业目的。

本文展示了如何使用实现 fetch 请求的本地缓存,遇到重复请求时,将会从 sessionStorage 中读取数据。这样做的好处是,无需为每个需要缓存的资源编写自定义代码。

如果你想在 JavaScript 盛会中露露脸,秀秀如何玩转 Promise、最前沿的 API 和 localStorage,那就接着往下看吧。

Fetch API

此时此刻,你对 fetch 可能已经很熟悉了。它是浏览器提供的用以替代旧版的XMLHttpRequest的原生 API。

并非所有浏览器都完美支持 fetch,但你可以使用 GitHub 上的 fetch polyfill(如果没事做,可以看看 Fetch 标准)。

原始替代版本

做个假设,我们准确了解需要下载的那个资源,并且只想下载一次。可以使用全局变量作为缓存,像下面这样:

let origin = null
fetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(information => {
    origin = information.origin  // your client's IP
  })

// 需要延时以确保 fetch 完成
setTimeout(() => {
  console.log('Your origin is ' + origin)
}, 3000)

On CodePen

上面使用了全局变量来保存缓存的数据。马上可以发现问题,一旦刷新页面或者跳转到其他页面,缓存的数据就消失了。

在剖析这个办法的短板之前,先将解决方案升级下。

fetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(info => {
    sessionStorage.setItem('information', JSON.stringify(info))
  })

// 需要延时以确保 fetch 完成
setTimeout(() => {
  let info = JSON.parse(sessionStorage.getItem('information'))
  console.log('Your origin is ' + info.origin)
}, 3000)

On CodePen

第一个问题是,fetch 是基于 Promise 的,意味着我们无法准确知晓 fetch 何时完成,因此在 fetch 完成之前,我们不能依赖它的执行。

第二个问题是,该解决方案详细指定了 URL 和缓存的内容(本例中的 information)。我们需要一个基于 URL 的通用解决方案。

第一次的简单实现

fetch外面再包装一层,同样也返回 Promise。调用该方法时,我们并不关心结果是来源于网络还是本地缓存。

之前你可能是这样做的:

fetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(issues => {
    console.log('Your origin is ' + info.origin)
  })

On CodePen

现在加上一层包装,重复的网络请求可以通过本地缓存进行优化。我们将这个包装过的方法简单称作 cachedFetch,代码如下:

cachedFetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(info => {
    console.log('Your origin is ' + info.origin)
  })

该方法首次运行的时候,需要发出网络请求,并将结果缓存下来。第二次请求时,则会直接从本地存储中取出数据。

首先试试简单地将 fetch 包装下:

const cachedFetch = (url, options) => {
  return fetch(url, options)
}

On CodePen

这当然能工作,不过没什么用。接下来,来实现获取数据的存储

const cachedFetch = (url, options) => {
  // 将 URL 作为 sessionStorage 的 key
  let cacheKey = url
  return fetch(url, options).then(response => {
    // 仅在结果为 JSON 或其他非二进制数据情况下缓存结果
    let ct = response.headers.get('Content-Type')
    if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) {
      // 当然,除了 .text(),也有 .json() 方法
      // 不过结果最终还是会以字符串形式存在 sessionStorage 中
      // 如果不克隆 response,在其返回时就会被使用
      // 这里用这种方式,保持非入侵性
      response.clone().text().then(content => {
        sessionStorage.setItem(cacheKey, content)
      })
    }
    return response
  })
}

On CodePen

上面发生了不少事。

fetch 所返回的首个 Promise 实际上还是径直发出了 GET 请求。注意如果有 CORS(Cross-Origin Resource Sharing,跨域资源共享)的问题,.text().json().blob() 这些方法不会工作。

最有意思的点在于,我们需要克隆首个 Promise 返回的 Response 对象。如果不这样做,我们就介入过多,当该 Promise 的最终使用者调用如 .json() 这些方法时,会得到如下错误:

TypeError: Body has already been consumed.

另外需要注意的一点是,需要注意响应类型:我们只存储状态码为 200 内容类型为 application/jsontext/*的响应。因为 sessionStorage 只能存储文本数据。

下面是使用示例:

cachedFetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(info => {
    console.log('Your origin is ' + info.origin)
  })

cachedFetch('https://httpbin.org/html')
  .then(r => r.text())
  .then(document => {
    console.log('Document has ' + document.match(/<p>/).length + ' paragraphs')
  })

cachedFetch('https://httpbin.org/image/png')
  .then(r => r.blob())
  .then(image => {
    console.log('Image is ' + image.size + ' bytes')
  })

让人喜欢的是,这个解决方案到目前为止可以正常工作,也不会干扰 JSON HTML 请求。当数据为图片的时候,它也不会试图将其存在 sessionStorage 中。

真实返回命中缓存的第二次实现

我们的第一次实现,仅仅只关心响应结果的存储。当你第二次调用 cachedFetch 时,并未试着从 sessionStorage检索任何内容。我们要做的,首先是返回一个 Promise,它需要返回一个 Response 对象

先看下最基本的实现:

const cachedFetch = (url, options) => {
  // 将 URL 作为 sessionStorage 的 key
  let cacheKey = url

  // 命中缓存的新代码开始
  let cached = sessionStorage.getItem(cacheKey)
  if (cached !== null) {
    // it was in sessionStorage! Yay!
    let response = new Response(new Blob([cached]))
    return Promise.resolve(response)
  }
  // 命中缓存的新代码结束

  return fetch(url, options).then(response => {
    // 仅在结果为 JSON 或其他非二进制数据情况下缓存结果
    if (response.status === 200) {
      let ct = response.headers.get('Content-Type')
      if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) {
        // 当然,除了 .text(),也有 .json() 方法
        // 不过结果最终还是会以字符串形式存在 sessionStorage 中
        // 如果不克隆 response,在其返回时就会被使用
        // 这里用这种方式,保持非入侵性
        response.clone().text().then(content => {
          sessionStorage.setItem(cacheKey, content)
        })
      }
    }
    return response
  })
}

On CodePen

这已经可以工作了!

打开 CodePen 查看上面代码的实际效果,记得开启浏览器开发者工具中的 Network tab。多点几次 “Run” 按钮(CodePen 的右上角),可以发现,只有图片被反复请求。

本解决方案的好处是避免了“意面式回调”(callback spaghetti)。sessionStorage.getItem 的调用是同步的(也就是阻塞的),所以在 Promise 或者回调中无需应对“它在本地存储中是否存在?”这种问题。只要有内容,就返回缓存结果。否则就按正常逻辑执行。

考虑失效时间的第三次实现

到目前为止我们一直在使用 sessionStorage,它有点像 localStorage,除了在打开新页面时会被清除这一点。这意味着我们在使用一种“自然形式”,内容不会缓存很久。如果要使用 localStorage 来缓存内容,那就算远程内容改变了,浏览器还是会“永远”卡在本地内容。这太糟糕了。

更好的解决办法是提供用户控制。(这里的用户指的是使用 cachedFetch 函数的 Web 开发者。)就像 Memcached 或 Redis 这些服务端存储一样,我们可以指定缓存的使用期。

例如在 Python (with Flask) 中:

>>> from werkzeug.contrib.cache import MemcachedCache
>>> cache = MemcachedCache(['127.0.0.1:11211'])
>>> cache.set('key', 'value', 10)
True
>>> cache.get('key')
'value'
>>> # waiting 10 seconds
...
>>> cache.get('key')
>>>

对此,目前 sessionStoragelocalStorage 都没有内建的功能实现,所以需要自己手动来实现。通过对比存储与缓存命中时的时间戳,可以达成目的。

在此之前,先看看大概应该长什么样子:

// 使用默认过期时间,如 5 min
cachedFetch('https://httpbin.org/get')
  .then(r => r.json())
  .then(info => {
    console.log('Your origin is ' + info.origin)
  })

// 传递以秒为单位的数值
cachedFetch('https://httpbin.org/get', 2 * 60)  // 2 min
  .then(r => r.json())
  .then(info => {
    console.log('Your origin is ' + info.origin)
  })

// 和  fetch 选项放在一起,使用自定义的名字
let init = {
  mode: 'same-origin',
  seconds: 3 * 60 // 3 min
}
cachedFetch('https://httpbin.org/get', init)
  .then(r => r.json())
  .then(info => {
    console.log('Your origin is ' + info.origin)
  })

最重要的来了,每次保存响应数据的时候,需要记录何时存储的。现在我们也可以切换到 localStorage 上了。代码会保证我们不会命中过期的缓存,在 localStorage 中内容原本是持久化的。

下面是最终的解决方案:

const cachedFetch = (url, options) => {
  let expiry = 5 * 60 // 默认 5 min
  if (typeof options === 'number') {
    expiry = options
    options = undefined
  } else if (typeof options === 'object') {
    // 但愿你别设置为 0
    expiry = options.seconds || expiry
  }
  // 将 URL 作为 localStorage 的 key
  let cacheKey = url
  let cached = localStorage.getItem(cacheKey)
  let whenCached = localStorage.getItem(cacheKey + ':ts')
  if (cached !== null && whenCached !== null) {
    // 耶! 它在 localStorage 中
    // 尽管 'whenCached' 是字符串
    // 但减号运算符会将其转换为数字
    let age = (Date.now() - whenCached) / 1000
    if (age < expiry) {
      let response = new Response(new Blob([cached]))
      return Promise.resolve(response)
    } else {
      // 清除旧值
      localStorage.removeItem(cacheKey)
      localStorage.removeItem(cacheKey + ':ts')
    }
  }

  return fetch(url, options).then(response => {
    // 仅在结果为 JSON 或其他非二进制数据情况下缓存结果
    if (response.status === 200) {
      let ct = response.headers.get('Content-Type')
      if (ct && (ct.match(/application\/json/i) || ct.match(/text\//i))) {
        // 当然,除了 .text(),也有 .json() 方法
        // 不过结果最终还是会以字符串形式存在 sessionStorage 中
        // 如果不克隆 response,在其返回时就会被使用
        // 这里用这种方式,保持非入侵性
        response.clone().text().then(content => {
          localStorage.setItem(cacheKey, content)
          localStorage.setItem(cacheKey+':ts', Date.now())
        })
      }
    }
    return response
  })
}

On CodePen

未来更好、更理想、更酷的实现

我们在避免过度变动 Web API,最棒的是 localStorage 可比依赖网络快得多了。看看这篇文章对 localStorage 和 XHR 的比较: localForage vs. XHR。它还衡量了其他内容,但得出基本结论,localStorage 确实很快,磁盘缓存热身(disk-cache warm-ups,?不知如何翻译,请读者赐教)也很少出现。

接下来,我们还能怎样改进方案呢?

处理二进制响应

我们的实现没有考虑缓存非文本的内容,如图片等等,但这并非不可能。需要一些更多的代码。特别的,我们可能想存储更多关于 Blob 的信息。从根本上说,所有响应都是 Blob。对文本和 JSON 来说,它只是字符串数组,typesize 并不真正那么重要,因为从字符串本身就能识别出来。对二进制内容而言,需要将它们转换为 ArrayBuffer

关注更多内容,请看 CodePen 上支持图片的实现。

使用哈希键值缓存

另外一点潜在的优化点是对用作 key 的每个 URL 进行哈希处理,使其变得更小,以空间换取速度(trade space for speed)。在上面的例子中,我们使用了很多非常短小整洁的 URL(如 https://httpbin.org/get),但如果你使用了大量的带有很多查询字符串的长 URL,这样做就很有意义了。

办法之一是使用这个不错的算法,以其安全快速而知名:

const hashstr = s => {
  let hash = 0;
  if (s.length == 0) return hash;
  for (let i = 0; i < s.length; i++) {
    let char = s.charCodeAt(i);
    hash = ((hash<<5)-hash)+char;
    hash = hash & hash; // Convert to 32bit integer
  }
  return hash;
}

如果觉得这个不错,看下 CodePen。在控制台上可以看到类似 557027443 这样的 key 值。

结语

现在我们拥有了一个可以使用在 web app 中的工作方案了,我们使用 Web API,并且知晓响应结果会很好地为用户缓存下来。

最后一件事大概是这个扩展置于本文之外,将其作为一个真实、具体的项目,加上测试和 README,并发布到 npm 上 —— 换个时间再做吧!