【问题标题】:Global memoizing fetch() to prevent multiple of the same request全局记忆 fetch() 以防止多个相同的请求
【发布时间】:2022-01-17 09:28:24
【问题描述】:

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

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

  • init 参数被序列化(与resource 参数一起)并用作哈希
  • 发出请求时,将其排队并存储其哈希
  • 当另一个请求到来并且哈希匹配(这意味着它正在运行)时,将不会发出另一个请求,它会从前一个请求返回

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 独立重新加载数据。这一切都很好并且解耦了,只是它有点……太解耦了。需要相同数据的嵌套元素(具有部分匹配的 URL)最终可能会同时发出相同的请求。

【问题讨论】:

  • 如果是GET请求,浏览器会自动缓存。
  • ...?只有在服务器允许的情况下,API 请求才不会发生...
  • “API 请求不会发生”你的意思是 POST 请求?否则,是的,听起来您正在重新发明Cache API,不同之处在于您需要处理甚至在响应可用之前提出新请求的情况,这可以通过在尝试之前等待上一个请求结束来完成开始一个新的:jsfiddle.net/852tzcjd 但是只有 GET 请求是可缓存的。
  • 你错过了最重要的用例——我可以请求/something,然后是/somethingElse,然后又是/something。使用 caches 对象可能是个好主意,但是您提出的实现将不起作用

标签: javascript async-await promise fetch


【解决方案1】:

您的概念通常可以正常工作。

你的实现中缺少一些东西:

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

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

  3. 我建议对globalFetch.inFlight 使用Map 对象而不是常规对象,因为它在您定期添加/删除项目时效率更高,并且永远不会与属性名称或方法发生任何名称冲突(尽管您的hash 可能无论如何都不会发生冲突,但使用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. 这看起来像fetch() 结果中的response.ok,并承诺拒绝以确定失败的请求。在某些情况下,您可能希望使用一些不同的标准来自定义失败的请求和失败的请求。例如,如果您认为 404 可能不是暂时的,则缓存 404 以防止在到期时间内重复它可能很有用。这实际上取决于您对所针对的特定主机的响应和行为的具体使用。不缓存失败结果的原因是失败是暂时的(临时打嗝或时间问题,如果前一个失败,您想要一个新的、干净的请求)。

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

【讨论】:

  • 这个答案大部分是 IMO 的好建议,但我反对 setInterval 作为缓存清理方法(因为它引入了一些需要在内存方面进行管理的东西,并且可能导致内存泄漏),而是检查正在使用的项目的寿命(也许,作为缓存值的额外属性),并在值超过设定的时间量时用新值替换所述值。这样,您就不必维护长时间运行的进程,而这只是一个额外的 IF 语句。
  • @spersico - OP 已经在检查正在使用的项目的寿命(这在他们的代码中已经显示为部分预期的实现)。间隔的要点是必须在某个时间点清理未使用的过期项目(不必非常频繁,并且可以独立于允许的缓存间隔,因为寿命检查)以避免无限的可能性不断增长的缓存。每隔一段时间运行一次清理只是防止缓存随着时间的推移无限增长,填充大量不经常使用和过期的缓存项。
  • @spersico - 此外,setInterval() 如何可能引入内存泄漏责任?您遍历缓存,检查每个项目,删除已过期的项目并保留未过期并保留在缓存中的其他任何内容。
  • 当你有 setInterval 时,你基本上有一个需要运行的长时间运行的进程。你从哪里开始 setInterval() 以便以后可以杀死它?如何避免多次运行 setInterval()?你什么时候开始运行 setInterval?不要误会我的意思,我并不是说使用 setInterval 是错误的,我只是说 IMO,如果你可以用 if 语句解决它并替换旧值,你可能应该这样做。
  • @spersico - 我认为您对 setInterval() 的作用有些困惑。它不会导致任何网页运行的时间比其他方式更长,并且它本身不会导致任何内存泄漏。 OP 当时已经有一个if 声明,并且已经在检查它。没关系。
【解决方案2】:

考虑使用ServiceWorkerWorkbox 将缓存逻辑与您的应用程序分开。 Stale-While-Revalidate 策略可以在这里应用。

【讨论】:

  • “Stale-While-Revalidate”策略是否允许处理“并行”请求,即让第二个同步执行的请求使用第一个,即使没有响应(甚至如果软件甚至没有时间打开缓存?)。我认为 OP 在 const r1 = fetch(req); const r2 = fetch(req); 的情况下,他们希望 r2 不发出新的网络请求。
  • 好观察,肯定需要并行请求测试。
  • 好吧,我就是这么想的,有点奇怪,API 中已经没有任何东西可以处理了......
猜你喜欢
  • 2010-12-04
  • 2021-03-22
  • 2020-09-19
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-12-18
  • 2018-09-14
相关资源
最近更新 更多