全局记忆 fetch() 以防止多个相同的请求
Global memoizing fetch() to prevent multiple of the same request
我有一个 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 独立地重新加载数据。一切都很好,也很解耦,只是有点……太解耦了。需要相同数据的嵌套元素(部分匹配 URLs)最终可能会同时发出相同的请求。
您的概念通常会很好地工作。
您的实施中缺少一些东西:
首先不应缓存失败的响应,或者当您看到失败时将其从缓存中删除。失败不仅仅是被拒绝的承诺,还有任何没有 return 适当成功状态(可能是 2xx 状态)的请求。
JSON.stringify(sigObject)
不是完全相同数据的规范表示,因为根据 sigObject
的构建方式,属性可能不会以相同的顺序进行字符串化。如果您抓取属性,对它们进行排序并按排序顺序将它们插入到一个临时对象中,然后将其字符串化,它会更规范。
我建议使用 Map
对象而不是 globalFetch.inFlight
的常规对象,因为当您定期 adding/removing 项目时它会更有效率并且会永远不会与 属性 名称或方法发生任何名称冲突(尽管您的散列可能无论如何都不会冲突,但是对于这种事情使用 Map
对象仍然是更好的做法)。
项目应该从缓存中老化(正如您显然已经知道的那样)。你可以只使用一个 setInterval()
的 运行 每隔一段时间(它不必 运行 非常频繁 - 也许每 30 分钟一次)它只是迭代缓存中的所有项目并删除任何早于一定时间的内容。由于您在找到时间时已经在检查时间,因此您不必经常清理缓存 - 您只是试图防止不间断地积累不会被重新使用的陈旧数据 -已请求 - 因此它不会自动替换为较新的数据,也不会从缓存中使用。
如果您在请求参数或 URL 中有任何不区分大小写的属性或值,当前设计会将不同的大小写视为不同的请求。不确定这对您的情况是否重要,或者是否值得为此做任何事情。
真正写代码的时候需要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
执行说明:
根据您的情况,您可能希望自定义清理间隔时间。这设置为 运行 每 10 分钟清理一次,以防止它无限增长。如果您发出数百万个请求,您可能会 运行 更频繁地间隔或限制缓存中的项目数量。如果您没有提出那么多请求,则可以降低频率。它只是在某个时候清理旧的过期条目,这样如果从未重新请求它们就不会永远累积。在 main 函数中检查过期时间已经防止它使用过期的条目 - 这就是为什么它不必经常 运行。
这看起来像 response.ok
从 fetch()
结果和承诺拒绝确定失败的请求。在某些情况下,您可能希望使用与此不同的标准来自定义什么是失败请求,什么不是失败请求。例如,如果您不认为 404 可能是暂时的,则缓存 404 以防止在到期时间内重复它可能很有用。这实际上取决于您对所针对的特定主机的响应和行为的具体使用。不缓存失败结果的原因是失败是暂时的(暂时的打嗝或计时问题,如果前一个失败,你想要一个新的、干净的请求)。
有一个设计问题,当您获得缓存命中时,您是否应该更新缓存中的 .expires
属性。如果您确实更新了它(就像这段代码那样),那么如果一个项目在过期之前不断被请求,它可能会在缓存中停留很长时间。但是,如果你真的希望它只被缓存最长的时间,然后强制一个新的请求,你可以只删除过期时间的更新,让原来的结果过期。根据您的具体情况,我可以看到两种设计的论点。如果这在很大程度上是不变的数据,那么只要它不断被请求,您就可以让它留在缓存中。如果它是可以定期更改的数据,那么您可能希望它在过期时间之前被缓存,即使它被定期请求也是如此。
考虑使用 ServiceWorker or Workbox to separate caching logic from your application. The Stale-While-Revalidate 策略可以适用于此。
我有一个 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 独立地重新加载数据。一切都很好,也很解耦,只是有点……太解耦了。需要相同数据的嵌套元素(部分匹配 URLs)最终可能会同时发出相同的请求。
您的概念通常会很好地工作。
您的实施中缺少一些东西:
首先不应缓存失败的响应,或者当您看到失败时将其从缓存中删除。失败不仅仅是被拒绝的承诺,还有任何没有 return 适当成功状态(可能是 2xx 状态)的请求。
JSON.stringify(sigObject)
不是完全相同数据的规范表示,因为根据sigObject
的构建方式,属性可能不会以相同的顺序进行字符串化。如果您抓取属性,对它们进行排序并按排序顺序将它们插入到一个临时对象中,然后将其字符串化,它会更规范。我建议使用
Map
对象而不是globalFetch.inFlight
的常规对象,因为当您定期 adding/removing 项目时它会更有效率并且会永远不会与 属性 名称或方法发生任何名称冲突(尽管您的散列可能无论如何都不会冲突,但是对于这种事情使用Map
对象仍然是更好的做法)。项目应该从缓存中老化(正如您显然已经知道的那样)。你可以只使用一个
setInterval()
的 运行 每隔一段时间(它不必 运行 非常频繁 - 也许每 30 分钟一次)它只是迭代缓存中的所有项目并删除任何早于一定时间的内容。由于您在找到时间时已经在检查时间,因此您不必经常清理缓存 - 您只是试图防止不间断地积累不会被重新使用的陈旧数据 -已请求 - 因此它不会自动替换为较新的数据,也不会从缓存中使用。如果您在请求参数或 URL 中有任何不区分大小写的属性或值,当前设计会将不同的大小写视为不同的请求。不确定这对您的情况是否重要,或者是否值得为此做任何事情。
真正写代码的时候需要
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
执行说明:
根据您的情况,您可能希望自定义清理间隔时间。这设置为 运行 每 10 分钟清理一次,以防止它无限增长。如果您发出数百万个请求,您可能会 运行 更频繁地间隔或限制缓存中的项目数量。如果您没有提出那么多请求,则可以降低频率。它只是在某个时候清理旧的过期条目,这样如果从未重新请求它们就不会永远累积。在 main 函数中检查过期时间已经防止它使用过期的条目 - 这就是为什么它不必经常 运行。
这看起来像
response.ok
从fetch()
结果和承诺拒绝确定失败的请求。在某些情况下,您可能希望使用与此不同的标准来自定义什么是失败请求,什么不是失败请求。例如,如果您不认为 404 可能是暂时的,则缓存 404 以防止在到期时间内重复它可能很有用。这实际上取决于您对所针对的特定主机的响应和行为的具体使用。不缓存失败结果的原因是失败是暂时的(暂时的打嗝或计时问题,如果前一个失败,你想要一个新的、干净的请求)。有一个设计问题,当您获得缓存命中时,您是否应该更新缓存中的
.expires
属性。如果您确实更新了它(就像这段代码那样),那么如果一个项目在过期之前不断被请求,它可能会在缓存中停留很长时间。但是,如果你真的希望它只被缓存最长的时间,然后强制一个新的请求,你可以只删除过期时间的更新,让原来的结果过期。根据您的具体情况,我可以看到两种设计的论点。如果这在很大程度上是不变的数据,那么只要它不断被请求,您就可以让它留在缓存中。如果它是可以定期更改的数据,那么您可能希望它在过期时间之前被缓存,即使它被定期请求也是如此。
考虑使用 ServiceWorker or Workbox to separate caching logic from your application. The Stale-While-Revalidate 策略可以适用于此。