☰
XHR、Ajax、Fetch区别与选型:编码、错误模型及实战避坑
2026/10/1 1:45:23 网站建设 项目流程

有人拿着一段老代码来问我,里面new XMLHttpRequest()是什么玩意,跟平时用的 axios、fetch 有什么区别;也有人问,为什么自己用 Fetch 发请求,后端明明返回 404,控制台却一点错误都不报,代码照样往下跑。这种问题我这些年被问过不下五十次,根子都在一个地方:XHR、Ajax、Fetch 这三个词被混着说了太久,很多人只会调 API,不知道每个 API 背后那套状态机、错误模型和编码规则到底是怎么定的。网络请求这件事看起来简单,fetch(url).then(r => r.json())一行就完事,但真正出问题的时候——跨域、乱码、上传失败、参数收不到——你会发现不了解底层根本查不下去。这篇我把这三样东西从概念、代码、编码规则到后端落地完整走一遍,中间会拆开一次从浏览器发出请求到 Spring Boot 接到请求的全过程。前端后端都能看,前端内容偏多一点,新手照着敲一遍能建立完整认知,有经验的人可以重点看编码格式和错误模型那几节。

1. 先把概念捋清楚:XHR、Ajax、Fetch 到底是三个什么关系

1.1 从"浏览器去要数据"这件事说起

在 Ajax 这个词被造出来之前,网页和后端交互的方式非常"笨重"。你在页面上点一个提交按钮,浏览器会带着表单数据跳转到一个新地址,然后整页重绘:白屏、滚动条回到顶部、刚才填的东西全没了。那时候想让页面"局部更新"只有两个办法,一是用 iframe 偷偷提交表单,把响应写进一个隐藏的框里;二是在服务端把整个页面重新渲染好再吐回来。前者体验还行但代码非常绕,后者等于把渲染压力全甩给服务器。

2005 年前后,几款重量级的 Web 应用开始用另一种思路:页面一次加载完成,之后所有数据都靠 JavaScript 在后台悄悄发请求拿回来,拿到之后自己操作 DOM 把内容填进去。用户感知就是"页面没动,数据变了"。这种模式后来被统称为Ajax,全称是Asynchronous JavaScript and XML。注意这个名字的每个词现在都有点过时了:Asynchronous 只是说"不阻塞页面",JavaScript 是手段,XML 早就被 JSON 取代了。但名字留下来了,成了这一类交互模式的代名词。

所以第一个关键认知:Ajax 是一种交互模式或者说编程思想,它不是一个具体的 API。你可以用 XHR 实现 Ajax,可以用 Fetch 实现 Ajax,可以用 axios 实现 Ajax,甚至用老掉牙的 jQuery$.ajax也是 Ajax。判断一个页面有没有用 Ajax,看的是"它有没有在不刷新整页的前提下异步取数据",而不是看它调了哪个对象。

1.2 Ajax 是思想,XHR 是第一个把它落地的工具

XMLHttpRequest就是第一个让这种思想能在浏览器里落地的具体工具。它最早由浏览器厂商以一个插件组件的形式引入,后来被纳入 W3C 标准,成为所有浏览器都内置的全局构造函数。它的核心能力非常朴素:让你在 JavaScript 里手动构造一个 HTTP 请求,指定方法、地址、请求头、请求体,然后监听它的状态变化,等响应回来了自己处理。

在很长一段时间里,XHR 就是"发 Ajax 请求"的唯一手段。所以很多老代码、老文档、老教材直接把两者混着说:"用 Ajax 发个请求"、"Ajax 返回 200 表示成功",其实说的是 XHR 对象。你要是完全没接触过这段历史,看 jQuery 的$.ajax()可能会以为它内部有什么黑魔法,实际上它浏览器端底层就是new XMLHttpRequest(),只是帮你把状态机、编码、超时、错误回调全包了一层。

XHR 有几个特征值得先记住,后面讲坑的时候全靠它:它是事件驱动的,靠readyState状态码和onreadystatechange、onload、onerror这类回调推进;它有两种模式,异步和同步,同步模式会直接冻结主线程;它能报告上传和下载进度,因为它内部暴露了upload对象和progress事件;它的默认Cookie 行为是"同源就带,跨域不带",跨域时要显式打开。

1.3 Fetch 是新标准,但它不只是"更好用的 XHR"

Fetch是后来由标准组织推出的新一代网络请求接口。它和 XHR 最大的区别有两个层面。

第一个层面是 API 风格。Fetch 基于 Promise,写起来是链式的,配合async/await后代码几乎和同步一样直观:

const res = await fetch('/api/user'); const data = await res.json();

而 XHR 你必须写回调或者手动包一层 Promise,否则嵌套几层就成了一条斜着的"回调金字塔"。

第二个层面更本质,也更少人注意:Fetch 不仅仅是一个"发请求的函数",它是一整套请求和响应的抽象模型,定义了Request、Response、Headers、Body这些对象,还规定了缓存策略、重定向跟随、跨域检查、流式读取该怎么表现。你平时用的 Service Worker,拦截请求靠的就是这套模型;Response.body是一个可读流,这个能力 XHR 根本没有。正因为它是"模型"而不是"工具",它的很多行为和 XHR 不一样,比如默认不带跨域 Cookie、HTTP 错误状态码不会让 Promise 变成 rejected、没有上传进度事件——这三点几乎每个人都踩过。

这里顺带澄清一个容易混淆的地方:很多命令行工具里的 "fetch" 跟浏览器的 Fetch API 完全是两码事。比如包管理器报cannot fetch index base url、could not fetch url https://.../simple/pip/,或者 CI 里报failed to fetch remote profile with status 403,这些是工具自己去拉远端资源失败了,通常是网络出口、证书或者权限问题,跟 JavaScript 里的fetch()没有半点关系。看到这类报错别往浏览器 API 上想,方向错了会浪费一整晚。

再补一句选型现实:现在用得最多的 axios,在浏览器端底层是XHR,不是 Fetch;在 Node 端才走 http 模块。所以你以为自己在用"新技术",其实跑的还是一套老的状态机,只是被封装得很舒服。

2. XHR 手写一遍:把请求过程拆成可观察的步骤

2.1 五步法:new、open、setRequestHeader、send、监听

不查文档手写一个 XHR,记住五个动作就够了。先看一段完整的 POST 示例:

const xhr = new XMLHttpRequest(); // 1. 初始化:方法、地址、是否异步 xhr.open('POST', '/api/user/save', true); // 2. 设置请求头(必须在 open 之后、send 之前) xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8'); // 3. 注册回调 xhr.onload = function () { if (xhr.status >= 200 && xhr.status < 300) { console.log('业务数据', JSON.parse(xhr.responseText)); } else { console.error('HTTP 错误', xhr.status, xhr.responseText); } }; xhr.onerror = function () { console.error('网络层错误,请求根本没发出去或连接被打断'); }; xhr.ontimeout = function () { console.error('超时'); }; // 4. 设置超时(毫秒),必须在 send 之前 xhr.timeout = 8000; // 5. 发送 xhr.send(JSON.stringify({ name: '张三', age: 28 }));

几个细节必须说清楚。open的第三个参数是async,传false就是同步请求。同步请求现在等于自残:它会锁死主线程,页面白屏不能点,直到响应回来为止;现代浏览器已经禁止在主线程(包括页面和大部分 Worker 外的场景)使用同步 XHR,会直接在控制台给你一条警告甚至抛异常。除非你在写某个极特殊的 Worker 场景,否则永远传true。

setRequestHeader的调用时机非常严格:必须在open之后、send之前。写早了会报InvalidStateError,写晚了(send 之后)会被直接忽略,而且不报错——这是最气人的一种失败,你发了半天请求发现后端拿不到自定义头,就是因为顺序写错了。

2.2 readyState 状态机:五个状态到底在说什么

XHR 的状态推进是靠readyState这个数字表达的,理解它比记住回调名字重要得多:

readyState常量名含义此时能读到什么
0UNSENT对象已创建,open 还没调用什么都读不到
1OPENEDopen 已调用,还没 send可以设置请求头
2HEADERS_RECEIVED响应头已收到,响应体还没开始status、getAllResponseHeaders()
3LOADING响应体正在下载responseText是不完整的
4DONE全部完成status、responseText完整可读

很多老代码写xhr.onreadystatechange = function () { if (xhr.readyState === 4) {...} },这没错,但啰嗦。现代写法直接用onload,它等价于"readyState 到达 4 且请求成功走完流程"。更重要的是别用readyState === 3去读responseText做业务处理,那时候数据是残缺的,只适合做流式的文本追加展示,比如日志滚动。

另一个必知项是responseType。默认是''(等同text),此时用xhr.responseText拿字符串。如果设成json,浏览器会帮你把响应体解析成对象,放进xhr.response,但如果响应体不是合法 JSON,xhr.response会直接是null且不抛错,这个静默失败害过不少人。其他取值还有blob(下载二进制文件)、arraybuffer(处理音视频切片)、document(解析 HTML)。responseType必须在send之前设置。

2.3 请求编码格式:乱码和"后端收不到参数"的根源

Content-Type到底填什么,是新手最容易翻车的地方。它决定了请求体用什么格式序列化,也决定了后端用哪个解析器去读。常见的有四种情况:

  • 表单编码:application/x-www-form-urlencoded;charset=UTF-8。形如a=1&b=2,中文必须encodeURIComponent。这是传统表单提交的默认格式,后端request.getParameter()能直接读到。
  • 多部分表单:multipart/form-data。有文件上传时必须用它。
  • JSON:application/json;charset=utf-8,请求体是 JSON 字符串,后端通常用@RequestBody接。
  • 纯文本:text/plain,用得少,主要出现在日志上报。

这里有个超级经典的坑:用FormData上传时,绝对不要手动设置Content-Type。

// 正确做法 const fd = new FormData(); fd.append('file', fileInput.files[0]); xhr.open('POST', '/api/upload'); xhr.send(fd); // 浏览器自动带上 multipart/form-data; boundary=----WebKitFormBoundaryXXXX

原因在于 multipart 格式的请求体需要用一个boundary分隔符把各个字段隔开,这个分隔符是浏览器随机生成并写在请求头里的。你要是手写了Content-Type: multipart/form-data,浏览器就不会再补 boundary,后端解析时找不到分隔符,结果就是"接口通了,但文件是 null"。我见过有人排查这个问题排查了一整天,最后发现是照抄了一段网上顺手加了请求头的代码。

charset这个参数也值得说道。它写在请求头里是告诉服务端"我这份请求体是按 UTF-8 编码的字节流";写在响应头里是告诉浏览器"你按 UTF-8 解码我"。如果两端不一致,就会出现经典的中文乱码:å¼ ä¸这种是 UTF-8 字节被当 ISO-8859-1 解了,??????是编码过程中字符被替换成了问号。还有一种情况是 GET 请求的中文参数,因为参数在 URL 里,必须encodeURIComponent,否则空格、&、#都会把 URL 拆坏。

2.4 上传进度、超时与中断:XHR 至今不可替代的地方

如果要做一个带进度条的批量上传,XHR 是当前最省事的选择,因为 Fetch 至今没有稳定的上传进度事件。

xhr.upload.onprogress = function (e) { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percent + '%'; } };

注意是xhr.upload.onprogress,不是xhr.onprogress。后者监听的是下载进度。这两个事件经常被写反,写反的结果就是进度条一直不动——因为下载往往很快,而你真正关心的是上传。

xhr.abort()用来中断请求,配合xhr.onabort处理。它在两个场景特别有用:一是用户点了"取消上传",二是搜索框输入联想——每敲一个字发一次请求会产生竞态,先发的请求可能后返回,把后发的结果覆盖掉。规范的解法是维护一个"当前请求令牌",新请求发出前把旧请求 abort 掉。

let current = null; function search(kw) { if (current) current.abort(); current = new XMLHttpRequest(); current.open('GET', '/api/search?kw=' + encodeURIComponent(kw)); current.onload = () => render(JSON.parse(current.responseText)); current.send(); }

踩过一次坑之后我总结出:凡是有"输入即请求"的地方,都要防竞态,否则用户会看到结果闪来闪去,最后停在错误的数据上。

提示:XHR 的timeout只覆盖从 send 到响应完成的总时长,不包含 DNS 解析和连接建立的某些阶段,在弱网环境下别指望它百分百准时。

3. Fetch 实战:Promise、Response 与那些反直觉的坑

3.1 最小可用写法与 Response 的三个"分身"

Fetch 的基础用法非常干净:

async function getUser(id) { const res = await fetch(`/api/user/${id}`, { method: 'GET', headers: { 'Accept': 'application/json' } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); }

但Response对象有几个特性必须提前知道,否则必踩坑。第一,响应体是一次性的流。你可以把它理解成一根水管,res.json()、res.text()、res.blob()都是从这根管子里接水,接完就没了。第二次调用会抛TypeError: Body has already been consumed。

第二,如果确实需要读两次,比如既要把原始文本记进日志又要解析成对象,用clone():

const res = await fetch('/api/data'); const copy = res.clone(); const text = await copy.text(); // 用于日志 const data = await res.json(); // 用于业务

clone()内部会做流的分叉,代价是内存里会多留一份数据,所以别对几百 MB 的响应无脑 clone。

第三,res.ok是status在 200–299 之间的快捷判断。它只是语法糖,但用它能显著减少"忘记判断状态码"的概率。

3.2 错误模型:为什么 404 不会进 catch

这是 Fetch 最反直觉、也最容易出事故的一点:Fetch 的 Promise 只会在网络层失败时 reject,HTTP 状态码 4xx、5xx 一律算"成功"。

try { const res = await fetch('/api/not-exist'); // 后端返回 404 console.log('走到这里了,res.ok =', res.ok); // true 分支不会抛错 } catch (e) { console.log('不会执行'); }

它这么设计是有道理的:从协议层面讲,服务器成功返回了一个"资源不存在"的响应,通信本身是成功的,业务失败应该由调用方判断。但落到实际开发中,这意味着你每次都必须自己写if (!res.ok),否则 500 错误会被当成正常数据往下传,然后在res.json()那里抛出一个莫名其妙的 JSON 解析错误,把真正的错误原因盖掉。

那什么情况下 Fetch 会 reject?主要是这几类:DNS 解析失败、断网、连接被重置、请求被AbortController中断、跨域被 CORS 拦截。注意最后一条——CORS 失败在控制台看不到具体的响应内容,只能看到一条 CORS 报错,状态码、响应头全都读不到,这是浏览器的安全设计,不是 bug。很多人在这里反复怀疑是不是后端没配好,其实后端配了,是前端请求里多带了个自定义头触发了预检。

3.3 超时与中断:AbortController 的标准姿势

Fetch 没有timeout选项,超时得自己实现。标准做法是AbortController:

async function fetchWithTimeout(url, options = {}, timeout = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const res = await fetch(url, { ...options, signal: controller.signal }); return res; } catch (e) { if (e.name === 'AbortError') { throw new Error(`请求超时(${timeout}ms)`); } throw e; } finally { clearTimeout(timer); // 一定要清,否则会留下悬空定时器 } }

e.name === 'AbortError'这个判断非常关键。超时被 abort 之后,抛出来的不是普通的Error,而是一个DOMException,message是 "The user aborted a request." 或者 "signal is aborted without reason",你要是按普通错误去匹配 message,永远匹配不上。新一点的运行环境提供了AbortSignal.timeout(8000)一步到位,但兼容性还不够全面,生产代码里我一般还是自己包一层。

还有一个同样重要的用法:把同一个 signal 传给多个请求,一次性全部取消。页面切换路由时把当前页所有在途请求掐掉,能避免大量"卸载后 setState"的告警。

3.4 上传进度、流式读取与 FormData 的注意事项

Fetch 传FormData和 XHR 一样简单,而且同样不要手动设 Content-Type:

const fd = new FormData(); fd.append('file', file); fd.append('bizType', 'avatar'); const res = await fetch('/api/upload', { method: 'POST', body: fd });

如果非要基于 Fetch 做上传进度,目前可行的路子只有一条:把 body 换成一个ReadableStream,手动分片读取并统计已发送字节,同时配合duplex: 'half'选项。这条路兼容性差、代码量大,所以我的实际建议是——需要上传进度就用 XHR,别硬凹 Fetch。这不是技术信仰问题,是成本问题。

Fetch 真正比 XHR 强的地方是流式读取响应。比如做打字机效果的 AI 对话、下载大文件并实时计算进度、解析 SSE:

const res = await fetch('/api/stream'); const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按行切分处理 }

注意decoder.decode(value, { stream: true })里的stream: true,它告诉解码器"后面还有数据,别把半截的多字节字符扔掉"。中文一个字符占 3 个字节,很可能被切在两个 chunk 中间,不加这个参数就会看到乱码方块。这是个特别隐蔽的坑,我第一次做流式输出时被它卡了半天。

3.5 credentials:跨域带 Cookie 的正确姿势

Fetch 默认的credentials是same-origin,也就是同源请求带 Cookie,跨域请求不带。这跟很多人的直觉相反——他们以为浏览器会自动带上所有 Cookie。XHR 的默认行为其实也类似,但历史上有过差异,导致迁移时出错。

跨域要带 Cookie,前后端都得改:

fetch('https://api.example.com/user', { credentials: 'include' // 前端 });
// 后端(Spring 示例) @CrossOrigin(origins = "https://www.example.com", allowCredentials = "true")

而且后端此时不能用Access-Control-Allow-Origin: *,必须是明确的具体域名,否则浏览器会直接拒绝。这条限制让无数人在联调环境里抓狂:测试环境域名一变就得同步改配置。

4. 从请求发出到 Spring Boot 落地:一次完整链路拆解

4.1 浏览器侧:CORS 预检是被什么触发的

很多人以为"跨域就一定会发 OPTIONS 预检",其实不是。浏览器把请求分成"简单请求"和"需要预检的请求"。满足下面全部条件的才算简单请求:

  • 方法是GET、HEAD、POST之一;
  • 请求头只用了安全列表内的字段,比如Accept、Accept-Language、Content-Language、Content-Type;
  • Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain三选一;
  • 没有自定义请求头,比如X-Token、Authorization。

看第三条就能明白:只要你的请求体是application/json,就一定会触发预检。所以现代前后端分离项目几乎每个非 GET 请求都会先发一个 OPTIONS。这个 OPTIONS 请求不带业务参数、不执行业务逻辑,服务端必须快速返回并带上正确的Access-Control-Allow-*头,否则真正的请求根本不会发出去。

预检失败的典型表现是:Network 面板里只有一个 OPTIONS 请求,红色,真正的 POST 压根没出现。这时候别去查你的 Controller,问题在跨域配置层。

4.2 服务端侧:请求是怎么一步步走到你的 Controller 的

以 Spring Boot 为例,一个 POST 请求进来,大致会走这么一串:

  1. 内嵌容器(Tomcat/Undertow)的 Connector:解析 TCP 流,把字节按 HTTP 协议拆成请求行、请求头、请求体,封装成Request对象。这一步决定了字符集,容器默认用 ISO-8859-1 解析 URL 和头部。
  2. Filter 链:编码过滤器、跨域过滤器、鉴权过滤器在这里依次执行。CharacterEncodingFilter是 Spring Boot 自动配的,它把请求和响应的编码强制设成 UTF-8,这也是为什么大多数项目不需要手动处理乱码。
  3. DispatcherServlet:核心调度器,拿着请求去找 Handler。
  4. HandlerMapping:根据 URL 和方法匹配到具体的@RequestMapping方法。
  5. HandlerAdapter + 参数解析器:这一步是"参数能不能收到"的关键。@RequestParam走RequestParamMethodArgumentResolver,从 URL 或表单里取值;@RequestBody走RequestResponseBodyMethodProcessor,再委托给MappingJackson2HttpMessageConverter,用 Jackson 把请求体反序列化成对象。
  6. 业务方法执行,返回对象。
  7. 消息转换器写响应:@ResponseBody会把返回对象交给 Jackson 序列化成 JSON,同时设置Content-Type: application/json。
  8. 响应经过 Filter 链往回走,最后由 Connector 写回 socket。

理解这条链路的最大价值在于:出问题的时候能立刻定位是哪一层错了。比如@RequestBody报HttpMessageNotReadableException,说明请求体要么是空的,要么不是合法 JSON,要么Content-Type不对;415 Unsupported Media Type说明Content-Type和转换器不匹配;400 Bad Request常见于 JSON 语法错误或者类型不匹配。

有一个特别容易忽略的点:@RequestBody只能读一次请求体。如果你在 Filter 或者拦截器里先读了一遍getInputStream(),后面的@RequestBody就会拿到空字符串。要读就得用包装类把流缓存起来再往下传。

4.3 常见报错速查表

下面这张表是我这些年攒下来的高频问题对照,遇到时可以直接查:

现象大概率原因排查方向
中文变成ä½ å¥½响应头 charset 与内容编码不一致检查Content-Type与过滤器编码
中文变成???某一环用了平台默认编码(GBK 等)全链路统一 UTF-8
@RequestBody收到 nullContent-Type不是application/json打印请求头确认
上传后MultipartFile为 null手写了Content-Type导致缺 boundary删掉手写的请求头
413 请求体过大上传文件/代码包超过限制调max-file-size、max-request-size
上传失败且提示超出大小限制同上,但发生在网关层网关和应用的阈值都要调
只有 OPTIONS 请求且失败跨域预检没过检查Access-Control-Allow-*
net::ERR_CONNECTION_RESET连接被中间环节掐断检查超时配置与代理层
状态 200 但前端进 catchFetch 判定为非 2xx 但res.ok没判断补if (!res.ok)
Body has already been consumed重复读取响应体用clone()
AbortError被当成普通错误没判断e.name显式判断AbortError
跨域 Cookie 不生效缺credentials: 'include'或后端用了通配符两端同时改

顺带说一句415 Unsupported Media Type和405 Method Not Allowed的区别,很多人搞混。前者是"我收到了你的请求体,但我不知道怎么解析它",是格式问题;后者是"我知道你想干什么,但这个地址不支持这个方法",是路由问题。前者改请求头,后者改后端注解。

5. 封装与选型:什么时候用哪个,怎么包才好维护

5.1 手写一个基于 Fetch 的轻量请求封装

真实项目里当然不会到处裸写fetch,但也不必一上来就上重型库。下面这个封装覆盖了超时、JSON 序列化、统一错误、以及res.ok判断,五十行左右,足够中小项目用:

const BASE = '/api'; async function request(path, { method = 'GET', data, headers = {}, timeout = 10000 } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const isForm = data instanceof FormData; const finalHeaders = { ...headers }; if (!isForm && data && !finalHeaders['Content-Type']) { finalHeaders['Content-Type'] = 'application/json;charset=utf-8'; } try { const res = await fetch(BASE + path, { method, headers: finalHeaders, body: isForm ? data : (data ? JSON.stringify(data) : undefined), credentials: 'same-origin', signal: controller.signal }); const text = await res.text(); let payload = null; try { payload = text ? JSON.parse(text) : null; } catch { payload = text; } if (!res.ok) { const err = new Error(`请求失败:${res.status}`); err.status = res.status; err.payload = payload; throw err; } return payload; } catch (e) { if (e.name === 'AbortError') throw new Error('请求超时,请稍后重试'); throw e; } finally { clearTimeout(timer); } }

几个设计取舍说明一下。为什么先res.text()再手动JSON.parse?因为后端有时候会返回空体(比如 204)或者返回一个纯字符串错误页,直接res.json()会抛解析异常,把真实的 HTTP 状态覆盖掉。先拿文本再尝试解析,能保证!res.ok分支永远拿到正确的状态码。为什么FormData要单独判断?前面说过了,不能让请求头带Content-Type。为什么finally里必须clearTimeout?因为请求正常返回后定时器还在跑,虽然 abort 一个已完成的请求无害,但页面停留久了会积累一堆定时器。

5.2 jQuery 的$.ajax、axios 与原生三者的位置

很多老项目里$.ajax和 ECharts 是绑定出现的:图表容器渲染好了,再用$.ajax拉数据填进去。这套组合在 jQuery 时代非常舒服,因为$.ajax返回的是一个 Deferred 对象,$.when能做并发聚合,写法比 XHR 回调清爽得多。

$.ajax({ url: '/api/chart/data', type: 'POST', contentType: 'application/json;charset=utf-8', // 注意 jQuery 里是 contentType data: JSON.stringify({ type: 'day' }), success(res) { chart.setOption(buildOption(res)); }, error(xhr, status) { console.warn(xhr.status, status); } });

注意 jQuery 里这个选项叫contentType(没有Request),跟 XHR 的setRequestHeader('Content-Type', ...)是同一件事,只是命名不同。还有一点:jQuery 在发 JSON 时,如果你传的是对象而不是字符串,它不会自动JSON.stringify,得自己转,否则后端拿到的会是[object Object]这种垃圾。

axios 的位置则是"现代项目的默认选项":它有拦截器、自动 JSON 序列化、超时配置、请求取消、并发 API,而且错误对象里带response、request、config三个完整上下文,排查起来比裸 Fetch 舒服太多。它浏览器端底层是 XHR,所以有上传进度支持。

5.3 选型对照表

维度XHRFetchaxiosjQuery.$.ajax
底层实现原生原生浏览器端为 XHRXHR
异步写法回调/状态机PromisePromiseDeferred
超时原生timeout需AbortController原生timeout原生timeout
上传进度原生支持基本不支持支持支持
流式响应不支持支持不支持不支持
HTTP 错误自动 reject自行判断否,需判断res.ok是否,走 error 回调
请求拦截无无有有(全局 ajaxSetup)
体积00约十几 KB依赖整个 jQuery
适合场景老项目、上传进度新项目、流式、SW中大型项目遗留项目

一句话结论:新项目用 axios 或自封装 Fetch,需要上传进度或维护老代码用 XHR,流式场景必须用 Fetch,jQuery 项目里继续用$.ajax别有心理负担。别为了"用新技术"把有进度条的批量上传改成 Fetch,那是给自己找麻烦。

5.4 几个我踩过的坑,写在这里给你省时间

第一个坑:mock 数据和真实联调的表现差异。开发阶段用本地 mock 接口时,一切正常;一旦切到真实后端,跨域、Cookie、编码问题全冒出来。原因是 mock 通常在同源路径下,绕过了 CORS 和 Cookie 限制。我的做法是本地开发就用一个反向代理把/api转发到测试环境,让请求条件尽量接近生产,早暴露早解决。

第二个坑:async/await里忘了 try/catch。Fetch 的 reject 只发生在网络层,但如果你在await链里手动抛了错(比如throw new Error或者res.json()解析失败),而外层没有 try/catch,在 Vue 或 React 的事件处理里会变成 unhandled rejection。这类错误在控制台里可能只是一行灰字,很容易被忽略。

第三个坑:Content-Type 大小写和多余空格。HTTP 头字段名是大小写不敏感的,但值不是。Application/JSON这种写法有的服务端框架能容忍,有的直接返回 415。老老实实写小写。

第四个坑:重复提交。用户手快点了两次按钮,两个相同的 POST 都发出去了,后端建了两条订单。前端能做的是提交时禁用按钮,但更稳的是让后端做幂等——前端生成一个请求 ID 放在头里,后端用它去重。前端能做的防重复只是体验层,真正的防线在服务端。

第五个坑:日志里打印整个响应。有次为了排查问题,把几百 KB 的响应体console.log出来,结果浏览器直接卡死十几秒。大响应体只打前 500 个字符,用res.text()拿到后slice(0, 500)就够了。

第六个坑,也是我最想强调的一条:别在生产环境用credentials: 'include'配通配符。有些人在联调时为了图快,后端把Access-Control-Allow-Origin设成*、Allow-Credentials设成 true,浏览器会直接拒绝这种组合,而且这个错误信息很含糊。更麻烦的是,一旦上线前忘了改回来,就是实打实的安全隐患。搞跨域 Cookie 就老老实实写具体域名,一个环境一个配置。

最后一个不算坑但值得提:如果你在做的是接口联调而不是页面开发,直接开浏览器的 Network 面板比任何工具都直观。把请求头、cookie、请求体、响应头、响应体五项对照着看一遍,九成的"接口不通"都能当场定位。我见过太多人跳过这一步,直接跑去后端翻代码,最后发现问题只是请求头里少写了一个charset。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询