想象这样一种情况:你在早上发布了一个新功能,但到了下午,用户们就开始频繁点击某个按钮,结果你的用户界面开始出现各种异常现象——结果顺序混乱、更新内容缺失,还有一些无法重现的随机故障。
这就是简单的 `fetch()` 代码与实际生产环境中的网络请求处理之间的差距。
在本指南中,你将学习如何弥补这一差距。我们会从最基本的请求处理开始,逐步添加真实应用程序所需要的各种功能:请求顺序控制、错误处理、重试机制以及取消操作。之后,我们还会介绍一些高级主题,比如速率限制、断路器、请求合并以及缓存技术,这样你就可以根据自己的需求选择合适的工具。
我们将涵盖的内容
先决条件
你不需要成为专家,但应该已经掌握以下基础知识:
-
核心JavaScript语法及`async/await`机制
-
浏览器中基本的DOM操作
-
如何使用npm脚本运行Node.js项目
-
如何在浏览器的开发者工具中查看请求详情
这个仓库的作用
本文所配套的代码存储在GitHub仓库js-fetch-production-demo中。该仓库包含一个简单的Express后端服务以及一个使用纯JavaScript编写的前端应用。
这个应用程序模拟了一个票务排队系统:每当有请求发送到后端时,系统会为对应的队列ID分配一个新的编号;每次请求都会更新该队列ID的计数器值,而前端应用会将返回的编号显示在DOM中。
后端服务提供了`/tickets/:id/nextNumber`这个接口,每当有请求到达时,该接口都会先更新对应队列ID的计数器值,然后再返回下一个编号。
前端允许你选择票号、发送请求,并将返回的每个编号添加到页面上,这样你就能清楚地看到响应是如何随时间陆续到达的。
随着文章逐步深入各个层次,我们会进一步扩展这个应用程序,来演示现实世界中网络编程模式所面临的挑战及其解决方案。
安装方法
在项目根目录下,使用以下命令安装所有依赖项:
npm run install:all
运行方法
在项目根目录下,启动两个服务器:
npm run dev
然后打开浏览器的 http://localhost:5173 链接。
-
后端运行在 http://localhost:3000 上
-
前端运行在 http://localhost:5173 上
基本的 `fetch` 操作
我们先从最简单的情况开始:点击一个按钮会触发一次请求,用户界面会将返回的票号添加到页面上。
在我们的演示中,后端提供了 GET /tickets/:id/nextNumber 这个接口。每次请求都会为该票号增加计数器的值,并返回新的编号。
对于简单的请求流程来说,这种基本的 `fetch` 模式就已经足够使用了:
const res = await fetch("/tickets/1/nextNumber");
const ticket = await res.json();
document.querySelector(".tickets").append(ticket.ticketNumber);
处理网络延迟问题及防止响应顺序混乱
在当前阶段,一切看起来都很正常。但实际上,网络传输的速度并不总是可预测的。首先,不同请求的响应时间可能会有所差异;为了模拟这种情况,我们可以在后端添加一些随机延迟:
// /backend/index.js
app.get('/tickets/:id/nextNumber', (req, res) => {
const ticketId = req.params.id;
// 如果计数器不存在,则将其初始化为0
if (!counters[ticketId]) {
counters[ticketId] = 0;
}
counters[ticketId]++;
const assignedNumber = counters[ticketId];
// 添加延迟以模拟网络传输缓慢的情况
const delay = Math.floor(Math.random() * 5000);
setTimeout(() => {
res.json({
ticketId: ticketId,
ticketNumber: assignedNumber
});
}, delay);
});
很明显,如果请求处理速度较慢,用户界面会显得反应迟缓,因此添加一个加载指示器会很有帮助。不过这属于用户界面层面的优化措施,并非网络编程模式中的解决方案。
另一个更为严重的问题是:如果用户快速连续点击多次,响应可能会按错误的顺序到达:
在实际应用环境中,这种情况绝对是不能被允许的。那么,我们该如何确保用户界面始终显示正确的票号顺序呢?即使响应的实际到达顺序与预期不同,我们也必须保证这一点得到满足。
我们的使用场景很简单:快速连续点击显然不是用户的本意,因此我们可以在第一个请求完成之前禁用该按钮(这又是一项UI层面的优化措施)。
但我们还可以做得更多:当有新的请求被发起时,可以取消所有尚未完成的请求。这时,《AbortController》API就派上了用场。我们可以为每个请求创建一个`AbortController`实例,在有新请求启动时调用其`abort()`方法。这样就能确保只有最新的请求处于活动状态,而之前的所有请求都会被取消。
通过这些UI优化措施以及错误处理机制,我们现在可以顺利处理快速连续的点击操作,而无需担心响应会出错或顺序混乱的问题。前端代码如下:
// frontend/main.js
const ticketIdInput = document.getElementById('ticketId');
const fetchBtn = document.getElementById('fetchBtn');
const ticketList = document.getElementById('ticketList');
const loading = document.getElementById('loading');
let currentController = null;
function setLoadingState(isLoading) {
fetchBtn.disabled = isLoading;
loading.classList.toggle('hidden', !isLoading);
}
fetchBtn.addEventListener('click', async () => {
const ticketId = ticketIdInput.value.trim();
if (!ticketId) {
alert('请输入票号');
return;
}
// 在开始新请求之前,取消该请求队列中所有尚未完成的请求
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
setLoadingState(true);
try {
const res = await fetch `/tickets/${ticketId}/nextNumber`, { signal: currentController.signal });
const data = await res.json();
// 将结果添加到DOM中
const ticketElement = document.createElement('div');
ticketElement.className = 'ticket-item';
ticketElement.textContent = `队列 \({data.ticketId}: #\){data.ticketNumber}`;
ticketList.appendChild(ticketElement);
// 将页面滚动到最新条目处
ticketElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
if (error.name === 'AbortError') return;
console.error('获取票号信息时出现错误:', error);
alert('获取票号信息失败');
} finally {
setLoadingState(false);
}
});
这段代码位于仓库的`01-abortController`分支中,你可以切换到该分支查看完整的实现代码:
git checkout 01-abortController
处理HTTP错误及不可靠的响应
网络环境有时也会出现其他不可预测的情况。比如,如果请求因为网络故障而失败,或者服务器返回了500错误码,该怎么办呢?`fetch()` API在遇到HTTP错误时并不会抛出异常,因此我们需要检查响应状态并据此采取相应的处理措施。
让我们在后端添加一些随机生成的错误情况来测试这些处理机制吧:
app.get('/tickets/:id/nextNumber', (req, res) => {
const ticketId = req.params.id;
// 如果计数器不存在,则将其初始化为0
if (!counters[ticketId]) {
counters[ticketId] = 0;
}
counters[ticketId]++;
const assignedNumber = counters[ticketId];
const shouldFail = Math.random() < 0.3; // 有30%的概率出现错误,返回500状态码
const delay = Math.floor(Math.random() * 5000);
setTimeout(() => {
if (shouldFail) {
res.status(500).json({
error: '后端系统发生随机故障',
ticketId: ticketId
});
return;
}
res.json({
ticketId: ticketId,
ticketNumber: assignedNumber
});
}, delay);
});
如果你运行这个应用程序,你会看到这样的界面:
这有点奇怪,因为在前端代码中,我们将fetch()放在了try/catch块中,所以本应该能够捕获到任何错误。但实际上,fetch()只会在网络错误时抛出异常,而不会对HTTP错误做出反应。因此,如果服务器返回了500错误码,fetch()还是会正常执行完毕,我们需要通过检查响应状态来判断是否真的发生了错误。
为了解决这个问题,我们可以在调用fetch()之后检查res.ok的值:
try {
const res = await fetch(`/tickets/${ticketId}/nextNumber`, { signal: currentController.signal });
if (!res.ok) {
throw new Error(`HTTP错误!状态码:${res.status}`);
}
const data = await res.json();
// 将数据添加到DOM中
const ticketElement = document.createElement('div');
ticketElement.className = 'ticket-item';
ticketElement.textContent = `队列 \({data.ticketId}: #\){data.ticketNumber}`;
ticketList.appendChild(ticketElement);
// 将元素滚动到可视区域
ticketElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (error) {
if (error.name === 'AbortError') return;
console.error('获取票券时出现错误:', error);
alert('获取票券失败');
} finally {
setLoadingState(false);
}
这样就能确保我们既能捕获网络错误,也能捕获HTTP错误。另外需要注意的是,尽管后端会返回500错误码,但它仍然会更新计数器,因此下一次成功的请求将会返回一个递增的票券编号。
这个请求并不具备幂等性,也就是说,重复发送相同的请求可能会产生不同的结果。在设计API时,需要考虑端点是否应该具有幂等性,以及这种特性会如何影响客户端上的错误处理和重试机制。
包含错误处理逻辑的代码位于仓库中的02-errorHandling分支中,你可以切换到这个分支来查看完整的实现代码:
git checkout 02-errorHandling
为临时性故障添加自动重试机制
到目前为止,我们已经使用原始的fetch()函数实现了基本的错误处理和取消功能。但是目前,如果某个请求失败了,用户就需要手动再次点击按钮来尝试重新发送请求。然而,有些错误其实是暂时性的,只要重新尝试一次就可以解决。
实现重试机制意味着我们会在一定次数内自动重新尝试失败的请求,直到成功为止。我们可以使用简单的循环并在每次重试之间设置延迟时间,不过重试策略也可以设计得更加复杂一些。
例如,你可以采用指数退避算法,让每次重试之间的延迟时间呈指数级增加,这样就可以避免在短时间内向服务器发送过多的请求,从而避免对服务器造成负担。此外,你的重试逻辑还需要区分哪些错误是可以重试的(比如网络错误、500错误),哪些是不能重试的(比如400错误)。
如果你试图完全使用原始的`fetch()`函数来实现这些功能,情况很快就会失控,因此像ky这样的库就显得非常有用。使用`ky`,你只需指定重试次数,它就会帮你处理所有的重试逻辑,包括指数级退避策略,以及仅针对某些类型的错误进行重试。此外,`ky`还内置了对`AbortController`的支持,因此你可以轻松地将其与自己现有的取消机制集成起来。
让我们把`ky`添加到我们的项目中,看看它如何简化我们的代码:
cd frontend
npm install ky
然后我们可以更新前端代码,使用`ky`代替`fetch()`:
import ky from 'ky';
...
fetchBtn.addEventListener('click', async () => {
const ticketId = ticketIdInput.value.trim();
if (!ticketId) {
alert('请输入票号');
return;
}
// 在开始新的请求之前,取消该队列中所有正在进行的请求
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
setLoadingState(true);
try {
const data = await ky
.get(`/tickets/${ticketId}/nextNumber`, { signal: currentController.signal })
.json();
// 将数据添加到DOM中
...
} catch (error) {
if (error.name === 'AbortError') return;
console.error('获取票号时出现错误:', error);
} finally {
setLoadingState(false);
}
});
使用`ky`,我们还可以通过简单的配置选项轻松实现重试功能:
const data = await ky
.get `/tickets/${ticketId}/nextNumber`, {
signal: currentController.signal,
retry: {
limit: 3, // 最多重试3次
methods: ['get'], // 仅对GET请求进行重试
statusCodes: [500], // 仅在遇到500错误时重试
backoffLimit: 10000 // 每次重试之间的最大延迟为10秒
}
})
.json();
相当方便吧?这样我们就不必自己编写所有的重试逻辑了,而且还可以通过不同的配置选项来灵活地调整重试行为。
git checkout 03-retries
npm install
npm run dev
通过这种方式,我们将原本简单的`fetch()`调用改进成了一个更加健壮的网络处理方案,这种方案能够应对网络速度慢、响应顺序混乱、随机出现故障等情况,而且所需的代码量和复杂度都大大降低了。
git checkout 03-retries
npm install
npm run dev
很多时候,这些措施就已经足以让你的应用程序的网络功能更具弹性,并使其具备上线运行的条件。但是,真正适用于生产环境的API通常还需要一些额外的机制和功能,而不仅仅是重试和取消请求这些功能。
例如,你可能想要实现缓存机制,以避免不必要的网络请求;或者如果后端服务对请求频率有限制,那么你就需要在前端实现速率限制或电路断路器机制,以防止服务器负担过重。如果你使用的是分布式后端架构,那么可能还需要为跨多个服务的请求添加追踪功能以及关联标识符。
为了简要介绍这些内容,我们将介绍一个名为ffetch的库。ffetch是一个现代化的请求处理框架,它预先提供了许多我们所需要的功能,包括重试、取消请求、缓存等等。此外,它的API也非常灵活,允许你通过插件和中间件来自定义它的行为。
如果我们将前端代码改用ffetch,代码大概会如下所示:
// frontend/main.js
import { createClient } from '@fetchkit/ffetch';
...
const api = createClient({
timeout: 10000,
retries: 3,
throwOnHttpError: true, // 遇到HTTP错误时会自动抛出异常
shouldRetry: ({ response }) => response?.status === 500, // 只在遇到500错误时才进行重试
});
...
然后在我们的点击处理函数中,代码会写成这样:
const response = await api `/tickets/${ticketId}/nextNumber`, {
signal: currentController.signal
});
const data = await response.json();
完整的代码可以在仓库的04-ffetch分支中找到,你可以切换到这个分支来查看具体的实现细节:
git checkout 04-ffetch
npm install
npm run dev
速率限制
大多数API都会实施某种形式的速率限制。这意味着,如果你在短时间内发送了过多的请求,服务器就会以429 Too Many Requests错误来拒绝这些请求。为了解决这个问题,你可以在前端实现速率限制机制,以确保不会超出服务器的承受范围。
使用ffetch,你可以集中管理所有的重试策略,而无需在每个请求处理的地方都单独处理429错误。一种实际可行的方法是只进行有限次数的重试,并采用指数级延迟机制,从而使重试请求之间的间隔时间逐渐增加。
import { createClient } from '@fetchkit/ffetch';
const api = createClient({
timeout: 10000,
retries: 2,
throwOnHttpError: true,
shouldRetry: ({ response }) => response?.status === 429, // 只在遇到429错误时才进行重试
retryDelay: ({ attempt }) => 2 ** attempt * 200, // 指数级延迟:每次重试间隔200毫秒或400毫秒
});
电路断路器
速率限制和后端服务中断虽然有一定的关联,但并不完全相同。电路断路器是一种用于应对重复性故障的机制:当达到某个阈值时,它会暂时停止所有出站请求,之后再允许系统进行恢复检查。
在ffetch中,可以通过电路插件来处理这个问题:
import { createClient } from '@fetchkit/ffetch';
import { circuitPlugin } from '@fetchkit/ffetch/plugins/circuit';
const api = createClient({
timeout: 10000,
retries: 2,
throwOnHttpError: true,
shouldRetry: ({ response }) =>
[500, 502, 503, 504].includes(response?.status ?? 0),
plugins: [
circuitPlugin({
threshold: 5,
reset: 30000
})
]
});
这种方式可以帮助前端在出现问题时迅速作出反应,减少对不健康服务的无用负担,并在重置窗口过后自动恢复正常运行。
请求合并
在某些情况下,你的应用程序中可能会有多个组件或部分需要获取相同的数据。(与文章前面提到的用户快速点击按钮的情况不同,在这里我们实际上可能需要所有这些响应结果。)
与其发送多份相同的请求,不如采用请求合并的方法,将它们合并成一次请求并共享响应结果。ffetch通过其dedupe插件提供了对此功能的内置支持:
import { createClient } from '@fetchkit/ffetch';
import { dedupePlugin } from '@fetchkit/ffetch/plugins/dedupe';
const api = createClient({
timeout: 10000,
retries: 2,
throwOnHttpError: true,
plugins: [dedupePlugin({ ttl: 1000 })]
});
// 同一个请求被发送了两次 -> 只会执行一次请求,结果会被共享
const [r1, r2] = await Promise.all([
api('/tickets/1/nextNumber'),
api('/tickets/1/nextNumber')
]);
缓存
缓存机制可以保存响应结果,这样以后再请求相同资源时就不需要访问网络了。这种方式既能节省带宽,又能减少延迟,同时还能保护后端免受不必要的负担。
下面介绍的这些技术并不局限于任何特定的fetch库——它们同样适用于普通的fetch、ky、axios或其他请求库。
HTTP缓存头信息
最简单的缓存方式在客户端不会产生任何成本。如果服务器设置了正确的响应头信息,浏览器会自动处理后续的缓存逻辑。
Cache-Control: max-age=60, stale-while-revalidate=30
max-age=60表示浏览器会在60秒内继续使用缓存的响应结果,而不会再次访问网络。stale-while-revalidate=30则会让浏览器在缓存失效后的额外30秒内仍然继续使用缓存的副本,同时会在后台重新获取最新的数据。
通常来说,这应该是首先尝试的方法。在编写任何客户端缓存代码之前,先检查你的API是否可以直接返回合适的Cache-Control头信息。
内存缓存
<当他你需要更精细的控制机制,或者当你的API无法设置请求头信息时,你可以使用普通的JavaScript `Map>` 结构来自行缓存响应数据。具体操作方法是:以URL作为键,将响应数据以及时间戳一起存储起来;如果某个条目的缓存数据仍然有效,就可以直接使用这些缓存数据而无需再进行网络请求。
const cache = new Map();
const TTL_MS = 60_000; // 1分钟
async function cachedFetch(url, options) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < TTL_MS) {
return cached.data;
}
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
cache.set(url, { data, timestamp: Date.now() });
return data;
}
这种缓存机制被设计得相当简单。它的主要局限性在于:当页面重新加载时,这些缓存数据会消失;同时,不同标签页之间也无法共享这些缓存数据。但对于大多数生命周期较短的UI状态来说,这样的设计已经足够使用了。
基于存储的缓存
如果你需要让缓存数据在页面重新加载后仍然保留下来,可以将这些数据写入localStorage或sessionStorage中:
function getCached(key) {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const { data, expiresAt } = JSON.parse(raw);
if (Date.now() > expiresAt) {
localStorage.removeItem(key);
return null;
}
return data;
} catch {
return null;
}
}
function setCached(key, data, ttlMs = 60_000) {
localStorage.setItem(key, JSON.stringify({ data, expiresAt: Date.now() + ttlMs }));
}
async function fetchWithStorage(url) {
const key = `cache:${url}`;
const cached = getCached(key);
if (cached) return cached;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setCached(key, data);
return data;
}
需要注意的是,localStorage是同步操作的,其存储容量大约为5MB,并且只能存储字符串类型的数据。对于那些变化不频繁、数据量较小的信息(比如用户设置或参考数据),使用localStorage是非常合适的。而对于数据量较大的情况,可以考虑使用IndexedDB,或者像idb-keyval这样的库,它们可以为开发者提供更简洁的API来操作IndexedDB。
缓存数据的失效处理
使用缓存机制会带来一个经典的问题:缓存数据可能会变得过时。以下是一些常见的解决策略:
-
基于时间的失效机制(TTL):上面提到的示例就是使用这种机制的。这种方法虽然简单,但缓存数据仍然有可能在
TTL_MS毫秒内变得过时。 -
手动失效处理:在数据发生变更后(通过POST/PUT/DELETE操作),需要手动删除相关的缓存键,这样下一次读取时就能获取到最新的数据。
-
“在验证之前保持缓存有效”策略:首先立即提供缓存的副本,然后在后台重新获取最新数据。浏览器的
Cache-Control头信息支持这种机制。你也可以手动实现这一功能,即同时返回缓存值,并在后台发起一个fetch请求来获取最新数据。
选择哪种策略取决于数据变化的频率,以及用户能够接受多少程度的数据过时现象。
结论
在这篇文章中,我们首先使用了一个简单的`fetch()`调用,然后逐步添加了一些模式来应对现实世界中的网络挑战:响应顺序混乱、网络速度缓慢、随机出现的故障、重试机制、取消操作、速率限制、断路保护、请求合并以及缓存功能等。
我们还介绍了一些库,比如`ky`和`ffetch`,这些库能够直接提供许多这样的功能,因此我们可以更轻松地编写适用于生产环境的网络代码,而无需重复开发相同的逻辑。
刚开始使用这些技术时,并不需要全部功能。你可以先从`res.ok`和`AbortController`开始入手;当错误日志中开始出现暂时性的故障时,再添加重试机制;而当下游依赖组件存在可靠性问题时,就可以使用断路保护功能了。
让这些问题先暴露出来,然后再根据具体情况选择相应的解决方案。关键在于理解各种功能之间的权衡关系,并为你的具体应用场景挑选最合适的工具。
掌握了这些模式,你就能更好地构建出具备弹性、用户体验良好的应用程序,从而有效应对现实世界中网络环境的不确定性。