从 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 再封装一层收起这些重复逻辑,把 readyState、status 的判断收在一个函数里,调用方只关心 onSuccess、onError 两个回调。这样的封装能省掉重复判断,但回调形式在有依赖关系的请求链里依然容易变乱:先拿用户信息,再用部门 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 遍历拿到按键名排序过的键值对,写统一日志或调试面板时比手动拼 XMLHttpRequest 的 getAllResponseHeaders() 字符串再解析省事得多。
手写请求层封装时有一点值得注意:如果调用方传进来的是一个普通对象 { '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 自己算"里说的那种下载侧方案——上传那一侧确实还得靠 XMLHttpRequest 的 xhr.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 是浏览器原生对象,
Response、Headers、ReadableStream这些能力天然可用,做流式读取、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);
而不是每个页面都写一遍 fetch、response.ok、response.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。实际项目未必每个接口都严格遵守,但前端代码里至少应该保持清晰的命名,loadArticles、createArticle、deleteArticle 比 handleRequest1 更能表达意图。
提交 JSON 时要记得设置请求头和序列化,headers 里显式带上 'Content-Type': 'application/json',body 用 JSON.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-Type。multipart/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 原生 fetch(undici 实现)稳定进入 LTS,服务端脚本里调接口也能少依赖一个 node-fetch,但这是往后才用得上的事,眼下先把浏览器这一侧的封装做扎实。
收尾
Fetch 相比 XMLHttpRequest 确实更贴近现代异步编程习惯,但它把"HTTP 状态码算不算失败"这个判断权交还给了调用者,这是它和 XMLHttpRequest、和大部分同步语言里请求库最不一样的地方,也是最容易被新写法的表面简洁骗过去的一点。这篇过了一遍状态码判断、解析失败、超时、取消、大小写、body 只能读一次这几个坑,说到底都是同一件事:把这些琐碎但容易出错的判断收在请求层这一处,让页面代码只需要关心 ok 和 data。选手写 request 还是上 axios,我们组按项目性质分开决定,谁也没被淘汰。