全局记忆 fetch() 以防止多个相同的请求

Global memoizing fetch() to prevent multiple of the same request

我有一个 SPA,由于技术原因,我有不同的元素可能几乎同时触发相同的 fetch() 调用。[1]

与其疯狂地试图阻止多个不相关的元素来协调元素的加载,我正在考虑创建一个 gloabalFetch() 调用,其中:


async function globalFetch(resource, init) {
  const sigObject = { ...init, resource }
  const sig = JSON.stringify(sigObject)

  // If it's already happening, return that one
  if (globalFetch.inFlight[sig]) {

  // NOTE: I know I don't yet have sig.timeStamp, this is just to show
  // the logic
  if (Date.now - sig.timeStamp < 1000 * 5) {  
    return globalFetch.inFlight[sig]
  } else {
    delete globalFetch.inFlight[sig]
  }

  const ret = globalFetch.inFlight[sig] = fetch(resource, init)
  return ret
}
globalFetch.inFlight = {}

它显然缺少一种获取请求时间戳的方法。另外,它缺少一种批量删除旧请求的方法。除此之外...这是解决问题的好方法吗?

或者,是否已经有了一些东西,而我正在重新发明轮子...?

[1] 如果你很好奇,我有几个位置感知元素,它们会根据 URL 独立地重新加载数据。一切都很好,也很解耦,只是有点……太解耦了。需要相同数据的嵌套元素(部分匹配 URLs)最终可能会同时发出相同的请求。

您的概念通常会很好地工作。

您的实施中缺少一些东西:

  1. 首先不应缓存失败的响应,或者当您看到失败时将其从缓存中删除。失败不仅仅是被拒绝的承诺,还有任何没有 return 适当成功状态(可能是 2xx 状态)的请求。

  2. JSON.stringify(sigObject) 不是完全相同数据的规范表示,因为根据 sigObject 的构建方式,属性可能不会以相同的顺序进行字符串化。如果您抓取属性,对它们进行排序并按排序顺序将它们插入到一个临时对象中,然后将其字符串化,它会更规范。

  3. 我建议使用 Map 对象而不是 globalFetch.inFlight 的常规对象,因为当您定期 adding/removing 项目时它会更有效率并且会永远不会与 属性 名称或方法发生任何名称冲突(尽管您的散列可能无论如何都不会冲突,但是对于这种事情使用 Map 对象仍然是更好的做法)。

  4. 项目应该从缓存中老化(正如您显然已经知道的那样)。你可以只使用一个 setInterval() 的 运行 每隔一段时间(它不必 运行 非常频繁 - 也许每 30 分钟一次)它只是迭代缓存中的所有项目并删除任何早于一定时间的内容。由于您在找到时间时已经在检查时间,因此您不必经常清理缓存 - 您只是试图防止不间断地积累不会被重新使用的陈旧数据 -已请求 - 因此它不会自动替换为较新的数据,也不会从缓存中使用。

  5. 如果您在请求参数或 URL 中有任何不区分大小写的属性或值,当前设计会将不同的大小写视为不同的请求。不确定这对您的情况是否重要,或者是否值得为此做任何事情。

  6. 真正写代码的时候需要Date.now(),而不是Date.now.

这是实现上述所有内容的示例实现(区分大小写除外,因为它是特定于数据的):

function makeHash(url, obj) {
    // put properties in sorted order to make the hash canonical
    // the canonical sort is top level only, 
    //    does not sort properties in nested objects
    let items = Object.entries(obj).sort((a, b) => b[0].localeCompare(a[0]));
    // add URL on the front
    items.unshift(url);
    return JSON.stringify(items);
}

async function globalFetch(resource, init = {}) {
    const key = makeHash(resource, init);

    const now = Date.now();
    const expirationDuration = 5 * 1000;
    const newExpiration = now + expirationDuration;

    const cachedItem = globalFetch.cache.get(key);
    // if we found an item and it expires in the future (not expired yet)
    if (cachedItem && cachedItem.expires >= now) {
        // update expiration time
        cachedItem.expires = newExpiration;
        return cachedItem.promise;
    }

    // couldn't use a value from the cache
    // make the request
    let p = fetch(resource, init);
    p.then(response => {
        if (!response.ok) {
            // if response not OK, remove it from the cache
            globalFetch.cache.delete(key);
        }
    }, err => {
        // if promise rejected, remove it from the cache
        globalFetch.cache.delete(key);
    });
    // save this promise (will replace any expired value already in the cache)
    globalFetch.cache.set(key, { promise: p, expires: newExpiration });
    return p;
}
// initalize cache
globalFetch.cache = new Map();

// clean up interval timer to remove expired entries
// does not need to run that often because .expires is already checked above
// this just cleans out old expired entries to avoid memory increasing
// indefinitely
globalFetch.interval = setInterval(() => {
    const now = Date.now()
    for (const [key, value] of globalFetch.cache) {
        if (value.expires < now) {
            globalFetch.cache.delete(key);
        }
    }
}, 10 * 60 * 1000); // run every 10 minutes

执行说明:

  1. 根据您的情况,您可能希望自定义清理间隔时间。这设置为 运行 每 10 分钟清理一次,以防止它无限增长。如果您发出数百万个请求,您可能会 运行 更频繁地间隔或限制缓存中的项目数量。如果您没有提出那么多请求,则可以降低频率。它只是在某个时候清理旧的过期条目,这样如果从未重新请求它们就不会永远累积。在 main 函数中检查过期时间已经防止它使用过期的条目 - 这就是为什么它不必经常 运行。

  2. 这看起来像 response.okfetch() 结果和承诺拒绝确定失败的请求。在某些情况下,您可能希望使用与此不同的标准来自定义什么是失败请求,什么不是失败请求。例如,如果您不认为 404 可能是暂时的,则缓存 404 以防止在到期时间内重复它可能很有用。这实际上取决于您对所针对的特定主机的响应和行为的具体使用。不缓存失败结果的原因是失败是暂时的(暂时的打嗝或计时问题,如果前一个失败,你想要一个新的、干净的请求)。

  3. 有一个设计问题,当您获得缓存命中时,您是否应该更新缓存中的 .expires 属性。如果您确实更新了它(就像这段代码那样),那么如果一个项目在过期之前不断被请求,它可能会在缓存中停留很长时间。但是,如果你真的希望它只被缓存最长的时间,然后强制一个新的请求,你可以只删除过期时间的更新,让原来的结果过期。根据您的具体情况,我可以看到两种设计的论点。如果这在很大程度上是不变的数据,那么只要它不断被请求,您就可以让它留在缓存中。如果它是可以定期更改的数据,那么您可能希望它在过期时间之前被缓存,即使它被定期请求也是如此。

考虑使用 ServiceWorker or Workbox to separate caching logic from your application. The Stale-While-Revalidate 策略可以适用于此。