从 Ajax 到 Fetch:请求层到底该抽象到什么程度

判断一段 Fetch 代码有没有问题,光看 try/catch 包没包住是不够的。下面这段代码看起来挑不出毛病:

1try {
2  const response = await fetch('/api/list');
3  const data = await response.json();
4  render(data);
5} catch (error) {
6  showToast('加载失败');
7}

但只要把后端接口临时改成返回 500 再跑一遍,就会发现页面照样往下走,render 收到一个错误信息对象当成列表数据去渲染,界面直接花了。

这才是 Fetch 最容易被误解的地方:它不会因为 HTTP 状态码是 4xx 或 5xx 就让 Promise reject。只要网络层面收到了响应,无论状态码是多少,Fetch 都认为这次请求"成功"了,catch 里能进去的只有网络层面的失败——DNS 解析不了、连接被拒绝、CORS 拦截、主动 abort。至于服务端明明白白告诉你"这次请求出错了",Fetch 完全不当回事,它把这个判断权交还给了调用者,靠的是 response.ok 这个布尔值。

这个设计初看有点反直觉,但细想是有道理的:HTTP 状态码本身就是协议约定的一部分,404、500 不是异常,是响应的正常组成部分。Fetch 选择把它当作一等公民放进返回值里,而不是塞进异常机制。习惯了 XMLHttpRequest 或者其他语言里"失败就抛异常"思维的人,第一次写 Fetch 基本都会在这里摔一跤。

Ajax 时代的判断方式

在聊 Fetch 的机制之前,有必要说清楚它想替代的是什么。大家更熟悉的 Ajax 实现通常是 XMLHttpRequest

1var xhr = new XMLHttpRequest();
2xhr.open('GET', '/api/list');
3xhr.onreadystatechange = function () {
4  if (xhr.readyState === 4 && xhr.status === 200) {
5    console.log(xhr.responseText);
6  }
7};
8xhr.send();

readyState 有 0 到 4 五个状态值,真正能拿到完整响应的只有 4。我见过一种很经典的 bug:判断写成 if (xhr.readyState === 4) 却忘了再判断 status,接口返回 500 时也照样解析 responseText,页面直接渲染出一堆 undefined。也见过反过来的情况,只判断 status === 200,把 201、204 这些同样算成功的状态码当成失败——写死 200 在文件上传、创建资源这类接口上很容易出问题。

responseText 永远是字符串,需要自己 JSON.parse,一旦后端返回的不是合法 JSON(比如 nginx 直接吐了一个 HTML 错误页),parse 就会抛异常,没有 try/catch 包住的话回调直接中断。

这些代码单个用还能接受,一旦页面里有十几个接口,每处都写一遍状态判断,维护成本会很快上来。我经手过一个老后台项目,xhr.readyState === 4 && xhr.status === 200 这行判断全局搜出来有三十多处,每处后面跟着的错误处理还都不太一样,改一个全局提示文案要翻遍整个仓库。

早期项目会基于 XMLHttpRequest 再封装一层收起这些重复逻辑,把 readyStatestatus 的判断收在一个函数里,调用方只关心 onSuccessonError 两个回调。这样的封装能省掉重复判断,但回调形式在有依赖关系的请求链里依然容易变乱:先拿用户信息,再用部门 id 拉部门详情,再根据部门取权限列表,写出来就是一层套一层,俗称回调地狱。jQuery 的 $.ajax 加上 $.Deferred 才把这个问题缓解了一些,但那已经是引入一整个库的代价。

Fetch 用 Promise 天然规避了这层嵌套,也因此更容易和 async/await 组合:

1async function renderList() {
2  try {
3    const response = await fetch('/api/list');
4    const list = await response.json();
5    render(list);
6  } catch (error) {
7    showToast('列表加载失败');
8  }
9}

问题也正出在这里——这段代码结构上很干净,但语义上是错的,因为 catch 根本接不住 4xx/5xx。

response.ok 之外,还要担心解析失败

修正判断逻辑并不难,多加一层检查:

1const response = await fetch('/api/list');
2
3if (!response.ok) {
4  throw new Error('request failed');
5}

但这还不够。response.json() 本身也可能失败:服务端返回了空内容、HTML 错误页,或者 JSON 格式不合法,都会导致解析异常。204 No Content 这种本来就没有 body 的响应,response.json() 同样会抛错。一个稍微健壮点的封装,需要把 HTTP 错误、解析错误、网络错误统一收敛成一种业务层能理解的结构:

1async function parseJson(response) {
2  try {
3    return await response.json();
4  } catch (error) {
5    return null;
6  }
7}
8
9async function request(url, options = {}) {
10  try {
11    const response = await fetch(url, options);
12    const data = await parseJson(response);
13
14    if (!response.ok) {
15      return {
16        ok: false,
17        status: response.status,
18        message: (data && data.message) || `请求失败:${response.status}`,
19      };
20    }
21
22    return { ok: true, data };
23  } catch (error) {
24    return { ok: false, status: 0, message: '网络异常,请稍后重试' };
25  }
26}

这里有个不太显眼但容易踩的点:如果 HTTP 状态码本身就是失败,且你还想在失败分支里读一下 body 里的错误信息(比如后端在 500 响应体里塞了 { message: '库存不足' }),就必须先读一次 body 才能往下走。而 Response 的 body 是个流,只能被消费一次——如果代码里既想在失败分支读 response.json(),又想在某个日志埋点里把原始响应体记下来,第二次读就会直接抛 TypeError: body stream already read

解决办法是 response.clone(),它会在流被消费之前复制一份响应对象,两份可以分别独立读取:

1async function requestWithLog(url, options = {}) {
2  const response = await fetch(url, options);
3  const loggingCopy = response.clone();
4
5  loggingCopy.text().then(function (raw) {
6    reportRawResponse(url, response.status, raw);
7  });
8
9  const data = await parseJson(response);
10  return { ok: response.ok, status: response.status, data };
11}

这个坑我是在给接口调用加统一日志上报时踩到的:本来只是想把原始响应文本也顺手记一份,结果发现业务代码那边解析直接报错,查了半天才想起 body 只能读一次。clone() 之后两份 body 是完全独立的流,谁先读完不影响另一份。

Headers 大小写不敏感这件事

排查请求头相关问题时还遇到过一个不算 bug 的"陷阱"。后端同学在响应头里加了个 X-Request-Id,前端这边判断的时候写的是:

1const requestId = response.headers.get('x-request-id');

大小写对不上,但代码照样能拿到值——因为 Headers 对象在存取键名时是大小写不敏感的,get('X-Request-Id')get('x-request-id')get('X-REQUEST-ID') 拿到的是同一个值。这是 Fetch 规范里明确规定的行为,跟 HTTP 头字段本身大小写不敏感是一致的。好处是不用担心后端某次改动把大小写写法换了导致前端取不到值;坏处是如果代码里手误把两种大小写都写进了 set,很容易以为设置了两个头,实际只有一个生效:

1const headers = new Headers();
2headers.set('Content-Type', 'application/json');
3headers.set('content-type', 'text/plain');
4console.log(headers.get('Content-Type')); // text/plain,后者覆盖了前者

Headers 还能直接 for...of 遍历拿到按键名排序过的键值对,写统一日志或调试面板时比手动拼 XMLHttpRequestgetAllResponseHeaders() 字符串再解析省事得多。

手写请求层封装时有一点值得注意:如果调用方传进来的是一个普通对象 { 'Content-Type': 'A' },JS 对象的键是大小写敏感的,同一个调用方可能同时传 'Content-Type''content-type' 两个键而不自知。建议在封装函数内部,将入参 headers 统一实例化为 new Headers(options.headers) 再操作,借助 Headers 自身的规范化(normalize)能力把同名键合并,避免大小写碰撞带来的隐患。

流式响应:不是所有场景都要等一个完整 JSON

如果接口返回的是体积比较大的内容,或者需要边收边处理(比如导出大文件、SSE 类的持续推送),一次性 response.json()response.text() 就不合适了,因为它们都要等整个响应体收完才 resolve。这种场景可以直接操作 response.body 这个 ReadableStream

1async function readStreamedText(url, onChunk) {
2  const response = await fetch(url);
3  const reader = response.body.getReader();
4  const decoder = new TextDecoder('utf-8');
5
6  while (true) {
7    const { done, value } = await reader.read();
8    if (done) {
9      // { stream: true } 会把截断的多字节字符缓存在 decoder 内部等待拼接。
10      // 流结束时必须补一次无参 decode() 冲洗缓冲区,否则末尾残缺字节静默丢失。
11      const remaining = decoder.decode();
12      if (remaining) onChunk(remaining);
13      break;
14    }
15    onChunk(decoder.decode(value, { stream: true }));
16  }
17}

我们有个导出报表的功能,之前是后端生成完整文件再一次性返回,文件一大前端就要转圈转很久还不知道进度。改成后端边生成边写流、前端用 getReader() 边读边算进度百分比之后,用户至少能看到一个动起来的进度条,体验上好了不少。这也是前面提到的"上传进度 Fetch 拿不到、下载进度靠流式 reader 自己算"里说的那种下载侧方案——上传那一侧确实还得靠 XMLHttpRequestxhr.upload.onprogress

Fetch 和 XMLHttpRequest 各管一段

不少人会简单理解成"Fetch 就是更现代的 Ajax",但从能力角度看,Fetch 早期反而不如 XMLHttpRequest 全面。前面提到的上传进度是一个例子;另一个容易被忽略的差异是 cookie——Fetch 默认不带同源 cookie,必须显式写 credentials: 'include'(跨域)或 'same-origin',否则登录态传不过去。这个坑我亲手踩过:本地联调一切正常,一上测试环境接口全部 401,排查半天才发现是 cookie 没带上。XMLHttpRequest 默认就会带同源 cookie,习惯了它的人切到 Fetch 很容易在这里翻车。

所以团队里的实际约定是:普通业务请求一律用 Fetch,带进度条的文件上传还是退回去用 XMLHttpRequest。这说明所谓"新旧"并不是简单的替代关系,各自有各自更合适的场景。

手写封装还是上 axios

这半年团队里陆续有几个新项目直接选了 axios,没有再走自己封装 Fetch 这条路。原因说白了就是拦截器这一套现成机制:

1import axios from 'axios';
2
3const http = axios.create({
4  baseURL: '/api',
5  timeout: 8000,
6});
7
8http.interceptors.request.use(function (config) {
9  const token = getToken();
10  if (token) {
11    config.headers.Authorization = 'Bearer ' + token;
12  }
13  return config;
14});
15
16http.interceptors.response.use(
17  function (response) {
18    return response.data;
19  },
20  function (error) {
21    if (error.response && error.response.status === 401) {
22      redirectToLogin();
23    }
24    return Promise.reject(error);
25  }
26);

axios 默认就会把非 2xx 状态码变成 reject,这一点和 Fetch 的行为正好相反——它替你做了"该不该当成失败"这个判断,超时参数也是构造时的一个字段,不用像 Fetch 那样自己拿 AbortController 拼一层。请求体是对象会自动 JSON.stringify 并带上 Content-Type,是 FormData 又会自动跳过,不用手动分支判断。这些都是这几个月切过去的同事挂在嘴边的理由。

但这不代表手写封装没有意义,两者的取舍更多是团队规模和依赖态度的问题:

  • axios 是一个运行时依赖,体积不算大但也不是零成本;如果项目本身已经引入了不少第三方库,多一个问题不大,如果是个追求极致体积的小工具或者内嵌页面,自己封装 Fetch 更轻。
  • axios 的拦截器机制表达能力强,但也容易变成"万能垃圾桶"——项目大了之后,请求转发、错误提示、埋点、鉴权全塞进拦截器,出问题时不容易一眼看出是哪层逻辑在起作用。手写的 request 函数虽然啰嗦,但每一步都摊开写在一个函数里,新人接手时读起来反而更直接。
  • Fetch 是浏览器原生对象,ResponseHeadersReadableStream 这些能力天然可用,做流式读取、clone() 这类操作不需要绕开库的封装;axios 底层在浏览器环境还是基于 XMLHttpRequest,2022 年这个时间点它还没有默认切到 Fetch 适配器,遇到需要用流式响应的场景,axios 用起来会比较别扭。

我们组的选择是:内部工具类项目、体积敏感的嵌入页面继续手写 request 封装;业务中后台这种依赖已经不轻、team 里协作人数多的项目,允许直接上 axios,靠拦截器统一鉴权和错误处理。没有强制哪个更"先进",纯粹按项目性质挑。

超时和取消请求

Fetch 默认没有传统意义上的 timeout 参数,接口长时间不返回的话请求会一直挂着。实际项目里通常用 AbortController 做超时控制:

1async function requestWithTimeout(url, options = {}, timeout = 8000) {
2  const controller = new AbortController();
3  const timer = window.setTimeout(function () {
4    controller.abort();
5  }, timeout);
6
7  try {
8    return await request(url, {
9      ...options,
10      signal: controller.signal,
11    });
12  } finally {
13    window.clearTimeout(timer);
14  }
15}

取消请求在页面切换、搜索联想、弹窗关闭时也很有用。比如用户连续输入搜索关键字,旧请求比新请求更晚返回,就可能把新结果覆盖掉:

1let currentSearchController = null;
2
3async function search(keyword) {
4  if (currentSearchController) {
5    currentSearchController.abort();
6  }
7
8  currentSearchController = new AbortController();
9
10  const result = await request('/api/search?q=' + encodeURIComponent(keyword), {
11    signal: currentSearchController.signal,
12  });
13
14  if (result.ok) {
15    renderSearchResult(result.data);
16  }
17}

被取消的请求也会进入异常分支,封装层最好能区分"用户主动取消"和"真正失败",否则用户每次快速输入都可能看到错误提示。AbortController 中止请求时,catch 里拿到的 error 的 name'AbortError'

1try {
2  const response = await fetch(url, { signal });
3  // ...
4} catch (error) {
5  if (error.name === 'AbortError') {
6    return { ok: false, aborted: true };
7  }
8  return { ok: false, status: 0, message: '网络异常,请稍后重试' };
9}

我之前在一个搜索框上没做这个区分,用户打字稍微快一点,控制台就刷满了红色报错,监控那边也跟着误报。加上 aborted 标记后,页面层看到这个标记直接 return,既不提示也不上报。

还有个容易忽略的点:超时和取消其实是同一套机制的两种用法。定时器触发的 controller.abort(),和搜索框里手动调的 abort(),落到 catch 里都是 AbortError,光靠 error 本身分不清是超时还是主动取消。如果业务上需要给超时单独的提示文案,就得在 abort() 之前自己打个标记,比如维护一个 isTimeoutAbort 的外部变量。我看到规范里有个 AbortSignal.timeout() 的提案,中止时给的错误类型是 TimeoutError,能直接和主动取消区分开,但目前主流浏览器还没有落地,只能先记着,暂时用不上。

请求层封装该收多深

无论用 Ajax 还是 Fetch,项目维护体验主要取决于有没有做好请求层封装——base URL、通用 headers、错误处理、token 注入、超时控制,这些散落在页面里,请求方式再现代也难维护。

页面里最好是这样:

1const result = await request('/api/articles');
2
3if (!result.ok) {
4  showToast(result.message);
5  return;
6}
7
8renderArticles(result.data);

而不是每个页面都写一遍 fetchresponse.okresponse.json()catch。统一错误处理还可以把常见状态码集中起来:

1function normalizeHttpError(status, message) {
2  if (status === 401) {
3    return '登录已过期,请重新登录';
4  }
5  if (status === 403) {
6    return '没有权限执行该操作';
7  }
8  if (status >= 500) {
9    return '服务暂时不可用,请稍后重试';
10  }
11  return message || '请求失败,请稍后重试';
12}

401 尤其值得单独拎出来处理,它通常意味着登录失效,正确动作往往是清掉本地 token、跳回登录页,登录后还得能跳回原来那个页面。这套逻辑只该在请求层写一次。我见过有项目把跳登录页的代码复制粘贴到几十个页面里,后来要加一个"跳转前记住当前路由"的需求,改起来很痛苦。现在的习惯是在请求层拦截 401,统一触发一个全局事件或调 auth 模块的方法,页面完全不感知这件事。

不过这里要防一个边界情况:如果登录接口本身返回 401(密码错了),就不该再触发跳登录页的逻辑,否则会陷入死循环。拦截时通常要排除掉登录、刷新 token 这几个特定接口。

GET、POST 之外,语义也要讲清楚

请求方式不只是后端的事情,前端也该明确它们的语义:读取用 GET,创建用 POST,整体更新用 PUT,局部更新用 PATCH,删除用 DELETE。实际项目未必每个接口都严格遵守,但前端代码里至少应该保持清晰的命名,loadArticlescreateArticledeleteArticlehandleRequest1 更能表达意图。

提交 JSON 时要记得设置请求头和序列化,headers 里显式带上 'Content-Type': 'application/json'bodyJSON.stringify(payload)。涉及表单上传就换成 FormData

1async function uploadAvatar(file) {
2  const formData = new FormData();
3  formData.append('file', file);
4
5  return request('/api/upload', {
6    method: 'POST',
7    body: formData,
8    // 注意:这里千万别手动设 Content-Type
9  });
10}

FormData 时绝对不要手动设置 Content-Typemultipart/form-data 后面跟着一段 boundary 分隔符,是浏览器根据内容随机生成的,只有让浏览器自己写这个头,boundary 才会和实际请求体对上。手动写了 'Content-Type': 'multipart/form-data',boundary 就丢了,后端解析直接报错或拿到空字段。帮人排查过好几次"上传一直失败",最后都是因为通用封装里无脑给所有请求加了 JSON 的 Content-Type,把 FormData 也一起带歪了。封装请求层时,如果 body 是 FormData 实例,要专门跳过 Content-Type 的设置。

GET 请求还有个点:参数拼到 URL 上一定要 encodeURIComponent。搜索关键词里出现 &#、空格、中文这些字符,不编码轻则参数被截断,重则被当成注入的入口。前面搜索那段代码里特意写了 encodeURIComponent(keyword),不是顺手,是真踩过参数里带 & 导致后面参数全丢的事故。

基础设施层面的一点背景

顺带说一句和这次主题不完全相关但确实在推进的事:Node 18 已经在 4 月发布了,我们内部工具链这几个月陆续在试,但生产环境的服务还没切,团队默认还是 Node 16——18 这个版本要等到月底才转成官方 LTS,现在切生产还早了点,顶多是本地开发环境先装上试试新特性。这倒是提醒我一件事:请求层封装最好也留一个探测层,等哪天 Node 原生 fetchundici 实现)稳定进入 LTS,服务端脚本里调接口也能少依赖一个 node-fetch,但这是往后才用得上的事,眼下先把浏览器这一侧的封装做扎实。

收尾

Fetch 相比 XMLHttpRequest 确实更贴近现代异步编程习惯,但它把"HTTP 状态码算不算失败"这个判断权交还给了调用者,这是它和 XMLHttpRequest、和大部分同步语言里请求库最不一样的地方,也是最容易被新写法的表面简洁骗过去的一点。这篇过了一遍状态码判断、解析失败、超时、取消、大小写、body 只能读一次这几个坑,说到底都是同一件事:把这些琐碎但容易出错的判断收在请求层这一处,让页面代码只需要关心 okdata。选手写 request 还是上 axios,我们组按项目性质分开决定,谁也没被淘汰。