D03 请求层封装
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 验证与讨论
- 执行
node js-practice/usage.mjs,确认输出true Leanne Graham、false HTTP、true 2。 - 把
baseURL改为不可达地址,确认返回error.code为NETWORK且重试一次后返回。 - 把
timeout改为1,确认返回TIMEOUT。 - 传入
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 |
- 上一篇:D02-表单校验与异步提交
- 返回:D00-总结 | 科目 README
参考文献
[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
还没有评论,快来抢沙发吧~