D03 请求层封装

2026-10-31 16:580 阅读约 15 分钟JavaScript

D03 请求层封装

摘要:业务代码直接调用 fetch 会重复处理四件事:超时、状态码、错误结构、取消。封装做法是建立一个客户端,统一注入基础地址与请求头,把响应收敛为 {ok, data, error} 结构,并内置超时、有限重试、进行中请求去重与响应缓存。本文给出客户端实现、四类错误分类与五项策略配置。
关键词:请求层,超时,重试,去重,错误结构

所属:frontend/js / D-实战 | 上一篇:D02-表单校验与异步提交 | 下一篇:无 | 返回:D00-总结

1 引言

同一个项目里出现三种错误处理方式:有的地方判断 res.ok,有的地方 try/catch,有的地方忽略返回值。调用方无法确认失败时拿到什么。

本文把网络细节收进一个客户端,业务侧只处理 ok 与 error.code。范围见表 1-1。

项 本文覆盖 本文不覆盖
客户端 基础地址、请求头、JSON 编解码 服务端接口设计
可靠性 超时、重试、幂等性、去重 断网重连与队列
结果 {ok, data, error} 结构、错误分类 全站错误提示组件

2 预备知识

项 要求 核对方式
环境 Node.js 20+(内置 fetch 与 AbortSignal.any) 可运行 ESM 脚本
前置 B03、C04 理解 Promise 与取消
文件 js-practice/client.mjs 编码 UTF-8

3 原理与实现

3.1 一次请求的分层

请求从业务方法进入,依次经过组装、发送、判定、重试,最后统一为结果对象,如图 3-1 所示。

flowchart TD
    A["业务调用 api.getUser(id)"] --> B["组装 URL 与请求头"]
    B --> C{"缓存命中且未过期"}
    C -->|是| D["返回缓存结果"]
    C -->|否| E["fetch 带超时信号"]
    E --> F{"HTTP 状态是否 2xx"}
    F -->|否| G["分类错误:HTTP"]
    F -->|是| H["解析 JSON"]
    H --> I{"是否可重试错误"}
    I -->|是且未超次数| E
    I -->|否| J["返回 ok / error 结构"]
    G --> J

图 3-1 请求从组装到结果的判定顺序

图上可见:重试位于状态码判定之后,只对网络错误与 5xx 生效;4xx 属于请求本身的问题,重试没有意义。

3.2 完整实现

// js-practice/client.mjs;运行环境:Node.js 20+
const RETRYABLE = new Set([408, 425, 429, 500, 502, 503, 504]);

function makeError(code, message, extra = {}) {
  return { code, message, ...extra };
}

export function createClient({ baseURL = '', timeout = 5000, retries = 2, headers = {} } = {}) {
  const inflight = new Map();
  const cache = new Map();

  async function request(path, { method = 'GET', body, signal, cacheMs = 0, dedupe = true } = {}) {
    const url = new URL(path, baseURL || 'http://localhost').toString();
    const key = `${method} ${url} ${body ? JSON.stringify(body) : ''}`;

    if (method === 'GET' && cacheMs > 0) {
      const hit = cache.get(key);
      if (hit && hit.expire > Date.now()) return hit.value;
    }

    if (dedupe && inflight.has(key)) return inflight.get(key);

    const task = (async () => {
      let attempt = 0;
      for (;;) {
        attempt += 1;
        const timeoutSignal = AbortSignal.timeout(timeout);
        const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;

        try {
          const res = await fetch(url, {
            method,
            signal: combined,
            headers: { 'content-type': 'application/json', ...headers },
            body: body ? JSON.stringify(body) : undefined,
          });

          const text = await res.text();
          const data = text ? JSON.parse(text) : null;

          if (!res.ok) {
            const result = {
              ok: false,
              data: null,
              error: makeError('HTTP', data?.message ?? `HTTP ${res.status}`, { status: res.status }),
            };
            if (RETRYABLE.has(res.status) && attempt <= retries) continue;
            return result;
          }

          const result = { ok: true, data, error: null };
          if (method === 'GET' && cacheMs > 0) cache.set(key, { value: result, expire: Date.now() + cacheMs });
          return result;
        } catch (err) {
          if (signal?.aborted) return { ok: false, data: null, error: makeError('CANCELED', '请求已取消') };
          if (err.name === 'TimeoutError') {
            return { ok: false, data: null, error: makeError('TIMEOUT', `超过 ${timeout}ms`) };
          }
          if (attempt <= retries) continue;      // 网络错误按次数重试
          return { ok: false, data: null, error: makeError('NETWORK', err.message) };
        }
      }
    })().finally(() => inflight.delete(key));

    if (dedupe) inflight.set(key, task);
    return task;
  }

  return {
    request,
    get: (path, options) => request(path, { ...options, method: 'GET' }),
    post: (path, body, options) => request(path, { ...options, method: 'POST', body }),
    clearCache: () => cache.clear(),
  };
}

3.3 业务方法与验证

// js-practice/usage.mjs;运行环境:Node.js 20+;以下服务需可访问,示例用真实公开接口
import { createClient } from './client.mjs';

const api = createClient({ baseURL: 'https://jsonplaceholder.typicode.com', timeout: 3000, retries: 1 });

const user = await api.get('/users/1', { cacheMs: 60_000 });
console.log(user.ok, user.data?.name);           // true Leanne Graham

const missing = await api.get('/users/9999');
console.log(missing.ok, missing.error.code);     // false HTTP

// 去重:同一时刻两次相同请求只发一次
const [r1, r2] = await Promise.all([api.get('/users/2'), api.get('/users/2')]);
console.log(r1 === r2, r1.data?.id);             // true 2

错误分类见表 3-1。

error.code 触发条件 调用方处理
HTTP 状态码非 2xx 按 status 区分提示
TIMEOUT 超过 timeout 未完成 提示稍后重试,可放宽超时
NETWORK 断网、DNS 失败、跨域被拒 提示检查网络
CANCELED 外部信号取消 静默处理,不弹提示
自定义 业务层补充 按业务码处理

策略配置见表 3-2。

策略 默认值 说明
超时 5000 毫秒 每个请求独立计时,重试后重新计时
重试 2 次 仅对网络错误与 RETRYABLE 状态码生效
去重 开启 相同方法与路径的进行中请求共用结果
缓存 关闭 仅 GET,按 cacheMs 过期
取消 外部传入信号 与超时信号合并,任一触发即中止

4 验证与讨论

  1. 执行 node js-practice/usage.mjs,确认输出 true Leanne Graham、false HTTP、true 2。
  2. 把 baseURL 改为不可达地址,确认返回 error.code 为 NETWORK 且重试一次后返回。
  3. 把 timeout 改为 1,确认返回 TIMEOUT。
  4. 传入 AbortController 并在 100 毫秒后 abort(),确认返回 CANCELED 且不弹错误提示。
序号 验收项 判定标准
1 结果结构 成功与失败都返回 {ok, data, error}
2 错误分类 五类错误可区分
3 去重 并发相同请求共用同一个 Promise
4 重试边界 4xx 不重试,5xx 与网络错误重试
5 取消 取消后立即返回,不等待超时

五项取舍见表 4-1。

决策点 采用方案 备选方案 不选的理由
返回结构 统一对象,不抛异常 失败即 throw 调用方到处 try/catch,易漏
重试对象 只重试幂等方法与 5xx 全部请求重试 非幂等写操作重试会产生重复数据
去重范围 全局按方法加路径加请求体 不做去重 重复请求浪费带宽且结果可能不同
缓存位置 客户端内存缓存 浏览器 HTTP 缓存 服务端未配置缓存头时不可控
取消信号 外部信号与超时合并 只做超时 页面切换时旧请求仍占用连接

讨论与局限。 内存缓存不跨标签页,多标签同时打开时仍会各自请求。另,AbortSignal.any 需要较新运行时;旧环境应手动为两个信号注册回调,共用同一个 controller.abort()。

5 常见问题

现象 原因 处理
失败时调用方拿不到原因 客户端已 throw 统一返回结果对象,不抛异常
重复提交产生两条数据 写操作被重试 写操作关闭重试,或加幂等键
超时时间不生效 只传了信号给 setTimeout 信号必须传给 fetch
取消后仍报错 未判断 CANCELED 在业务层对 CANCELED 静默
缓存返回旧数据 cacheMs 过长或未清理 提供 clearCache 并在写操作后调用

6 结论

序号 结论 依据
1 结果统一为 {ok, data, error},不抛异常 表 4-1
2 只重试幂等请求与可恢复状态码 图 3-1
3 相同请求去重,减少带宽与不一致 表 3-2
4 超时与取消共用同一个信号 表 3-2

参考文献

[1] MDN Web Docs · Fetch 使用指南,https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch(访问于 2026-10-31)
[2] RFC 9110 · HTTP Semantics,https://www.rfc-editor.org/rfc/rfc9110.html(访问于 2026-10-31)

返回文章列表

评论 0

0/2000

还没有评论,快来抢沙发吧~