想象这样一种情况:你在早上发布了一个新功能,但到了下午,用户们就开始频繁点击某个按钮,结果你的用户界面开始出现各种异常现象——结果顺序混乱、更新内容缺失,还有一些无法重现的随机故障。

这就是简单的 `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 链接。

基本的 `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库——它们同样适用于普通的fetchkyaxios或其他请求库。

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状态来说,这样的设计已经足够使用了。

基于存储的缓存

如果你需要让缓存数据在页面重新加载后仍然保留下来,可以将这些数据写入localStoragesessionStorage中:

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`开始入手;当错误日志中开始出现暂时性的故障时,再添加重试机制;而当下游依赖组件存在可靠性问题时,就可以使用断路保护功能了。

让这些问题先暴露出来,然后再根据具体情况选择相应的解决方案。关键在于理解各种功能之间的权衡关系,并为你的具体应用场景挑选最合适的工具。

掌握了这些模式,你就能更好地构建出具备弹性、用户体验良好的应用程序,从而有效应对现实世界中网络环境的不确定性。

Comments are closed.