前两天面试了一个工作三年的前端,聊到网络请求这块,他说自己平时用axios很顺手,封装过好几版请求工具。我问了一句:“如果不依赖axios,让你用原生XMLHttpRequest写一个请求,你打算怎么设计?”对面明显愣了一下,然后开始背open、send、onreadystatechange这几个方法名,但问到请求头什么时候能设置、readyState从0变到4中间到底发生了什么、为什么有些场景必须用xhr而不是fetch,就答不上来了。
这不是个例。现在很多新人入行就直接上手axios,API简单、文档齐全、开箱即用,这本身是好事。但问题在于,axios本质上只是对XMLHttpRequest的一层封装,它解决的是“书写体验”问题,而不是“网络请求”本身。底层那些机制,比如HTTP请求生命周期、状态码流转、请求头与Content-Type的匹配规则、浏览器同源策略的边界,全部被封装隐藏了。一旦遇到跨域报错、后端收不到参数、上传进度无法获取这类实战问题,没有底层认知的人排查起来就像在迷宫里转圈。
这篇内容就是想把这层窗户纸捅破。我会从HTTP请求的基本模型讲起,把XMLHttpRequest对象的核心成员、readyState状态机、HTTP状态码的区别讲清楚,然后带着你一步步手写一个支持Promise、超时、参数序列化、请求取消的通用AJAX请求函数,再用真实业务场景里的坑给你做排查演练。内容整体偏实战,适合刚入门前端、或者打算系统补一遍网络请求基础的同学。文章里所有代码你都可以直接复制到本地跑,跟着敲一遍,比看十篇笔记都管用。
1. AJAX是什么:先弄懂它诞生的原因
1.1 一个表单提交引发的“全页刷新”
要理解AJAX,得先回到它的对立面:在AJAX出现之前,网页前后端交互是“整页提交”模式。你在一个表单里填完内容,点提交按钮,浏览器把整个表单数据打包发给服务器,服务器处理完返回一张新的HTML页面,浏览器整个刷新。这个过程看着没毛病,但用户体验非常割裂——页面闪一下白屏、滚动位置丢失、用户填到一半的其他表单数据全部清空。
我当年做第一个JavaWeb项目时就是这个体验,那时候还没流行前后端分离,JSP页面里嵌套Java代码,表单一提交整个页面重载,开发调试改一点样式都得等页面重新加载。那时候大家觉得网页就是这样,也意识不到有什么更好的办法。直到XMLHttpRequest被广泛支持,前端才第一次拥有了“在不离开当前页面的情况下,向服务器发请求并拿回数据”的能力。
1.2 AJAX不止是“不用刷新页面”这么简单
很多人把AJAX简单理解成“异步请求”,或者“局部刷新”,这其实是结果,不是本质。AJAX的全称是Asynchronous JavaScript and XML,核心在于三个能力:
- 浏览器可以在后台向服务器发起HTTP请求,不需要用户手动跳转或刷新页面。
- 请求是异步的,发送之后浏览器不会被阻塞,用户可以继续操作页面。
- 拿到响应数据后,前端可以用JavaScript操作DOM,把数据渲染进页面,实现“局部更新”。
这个机制直接改变了Web应用的交互模式。以前一个操作对应一次整页刷新,现在一个页面可以有多个独立的网络请求,各自更新自己的区域,互不干扰。搜索引擎的搜索建议、社交网站的无限滚动、电商平台的购物车局部更新,底层都是这个逻辑。
1.3 xhr和fetch、axios的关系
现在前端发请求有三条路:原生XMLHttpRequest、浏览器原生fetch、第三方库axios。很多人搞不清它们的定位,我用一句话说明白:XMLHttpRequest是浏览器底层的“基础设施”,fetch是后来官方提供的“更现代的替代品”,axios是社区基于XMLHttpRequest封装的“开箱即用工具”。
三者对比看下面这张表:
| 能力 | XMLHttpRequest | fetch | axios |
|---|---|---|---|
| 底层请求能力 | 浏览器原生提供 | 浏览器原生提供 | 基于XMLHttpRequest封装 |
| Promise支持 | 需要手动封装 | 原生返回Promise | 原生返回Promise |
| 请求取消 | 支持abort() | 支持AbortController | 支持CancelToken/AbortSignal |
| 上传进度 | upload.onprogress | 原生不支持,需额外处理 | 支持onUploadProgress |
| 超时设置 | timeout属性 | 需结合AbortController | timeout选项 |
| 拦截器 | 无 | 无 | 有请求/响应拦截器 |
| 防御CSRF | 无 | 无 | 有xsrf相关配置 |
为什么要手动封装一次XMLHttpRequest?不是为了在生产环境里抛弃axios,而是为了让你真正理解axios那些“魔法”是怎么实现的。它的拦截器本质上就是在open前和getResponseHeader后插入钩子,它的超时控制就是xhr.timeout属性,它的params序列化就是把对象拼成URL查询字符串。把这些底层看完,你再回头看axios的源码,会发现每一行都眼熟。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拆解XMLHttpRequest:原理都在这个对象里
2.1 xhr对象的核心成员一览
先把这个对象从头到尾过一遍。创建XMLHttpRequest实例很简单,一行代码:
javascript复制const xhr = new XMLHttpRequest();
这个实例上挂着一堆方法和属性,但真正常用的就那么几个。方法层面,你需要掌握这四个:
open(method, url, async):初始化一个请求。可以在这里指定HTTP方法、请求地址、是否异步。注意,open只是“初始化”,此时请求还没有真正发出去。setRequestHeader(header, value):设置请求头。这个方法必须在open之后、send之前调用,否则会抛异常。send(body):真正发送请求。GET请求一般传null,POST请求可以传入字符串、FormData、Blob等。abort():终止当前请求。
属性层面,核心的是这几个:
readyState:请求当前处于哪个阶段,数值从0到4。status:HTTP响应状态码,比如200、404、500。注意它在请求未完成时是0。statusText:状态码对应的文本描述,比如“OK”、“Not Found”。responseText:响应体文本,仅在请求完成之后才有值。responseType:期望的响应类型,默认是“text”,可以改为“json”、“blob”、“arraybuffer”等。改了之后,response属性会按指定类型解析。timeout:超时时间,单位毫秒。超过这个时间还没收到响应,会触发ontimeout事件。upload:返回一个XMLHttpRequestUpload对象,用于监听上传进度。
还有一个容易被忽略的属性是withCredentials。当你要在跨域请求中携带Cookie时,需要把它设为true,同时服务端必须返回Access-Control-Allow-Credentials: true,否则浏览器会拒绝写入Cookie。这个细节做单点登录、跨域取用户状态时经常踩。
2.2 readyState和status:两个必须分清的状态
新手最容易搞混的就是readyState和status。一句话区分:readyState描述的是“请求本身走到哪一步了”,status描述的是“服务器最后给我的HTTP反馈是什么”。
readyState一共五个值:
| 值 | 含义 | 什么时候触发 |
|---|---|---|
| 0 | UNSENT | 刚创建xhr对象,open还没调用 |
| 1 | OPENED | open已调用,send还没调用 |
| 2 | HEADERS_RECEIVED | send已调用,已收到响应头 |
| 3 | LOADING | 响应体正在下载中 |
| 4 | DONE | 请求完成,响应体已完全接收 |
status则是标准HTTP状态码,常见的有200表示成功,301表示永久重定向,304表示命中缓存,400表示请求参数有误,401表示未认证,403表示禁止访问,404表示资源不存在,500表示服务器内部错误。
这里有个很多人不知道的细节:readyState变成4并不代表请求一定成功了。它只代表“请求这个过程走完了”,但结果可能是404、500,也可能是网络错误。所以判断请求是否成功,必须看status,一般是2xx或304视为成功。
2.3 第一个原生GET请求:从代码看完整生命周期
光看概念不写代码等于白看。下面是最原始、最基础的一个GET请求,没有任何封装,用最直白的方式展示请求的完整生命周期:
javascript复制const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/users?id=123', true);
xhr.onreadystatechange = function () {
console.log('当前readyState:', xhr.readyState);
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log('请求成功,响应内容:', xhr.responseText);
} else {
console.error('请求失败,状态码:', xhr.status);
}
}
};
xhr.onerror = function () {
console.error('网络异常,请求未送达或响应未接收');
};
xhr.send(null);
你打开浏览器控制台跑一下这段代码,会看到readyState依次打印出1、2、3、4,这就是一次请求从初始化到完成的完整流转。这个过程中,浏览器在后台帮你做了DNS解析、TCP连接、发送HTTP报文、接收响应报文等一堆工作,而JavaScript能感知到的就是这四个状态变化。
onerror事件很多人会漏掉。要知道,请求发出去了不一定代表能收到响应,断网、DNS解析失败、跨域被拦截等情况都会触发error。所以完整写法必须同时处理onreadystatechange和onerror,前者负责处理“响应回来了,但可能报错”的情况,后者负责处理“压根没响应”的情况。
3. 手写一个自己的AJAX封装:一步步进化
3.1 第一版:能用的回调版
有了上面的基础,就可以开始封装了。第一版不做任何花哨的功能,只解决“怎么把参数传进open和send”以及“怎么把结果回传给调用方”这两个基本问题。
javascript复制function ajax(options) {
const xhr = new XMLHttpRequest();
const method = (options.method || 'GET').toUpperCase();
let url = options.url;
let data = options.data || null;
xhr.open(method, url, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
options.success && options.success(xhr.responseText);
} else {
options.error && options.error(xhr.status, xhr.statusText);
}
}
};
xhr.onerror = function () {
options.error && options.error(-1, '网络异常');
};
if (method === 'GET') {
// GET请求参数拼在URL上
const params = [];
for (let key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
params.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
}
}
if (params.length > 0) {
url += (url.includes('?') ? '&' : '?') + params.join('&');
}
xhr.open(method, url, true); // 参数化之后重新open
xhr.send(null);
} else {
// 非GET请求,默认按表单格式发送
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
const params = [];
for (let key in data) {
if (Object.prototype.hasOwnProperty.call(data, key)) {
params.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
}
}
xhr.send(params.join('&'));
}
}
ajax({
method: 'GET',
url: 'https://api.example.com/users',
data: { page: 1, size: 20 },
success: function (res) {
console.log('成功:', res);
},
error: function (status, msg) {
console.error('失败:', status, msg);
}
});
这个版本核心功能已经能用了,但它有个很明显的问题:代码写得很笨重,字符串拼接逻辑重复,而且很容易出错。比如GET请求在第一次open之后,还在参数拼接的过程中就调用了setRequestHeader的话,会直接报错。为了避免这个问题,我上面选择先拼接完再重新open,但这种写法又显得很绕。
真正的问题在于,回调式的写法一旦遇到多个请求之间有依赖关系,就很容易陷入“嵌套地狱”:
javascript复制ajax({
url: '/api/user',
success: function (user) {
ajax({
url: '/api/orders?userId=' + JSON.parse(user).id,
success: function (orders) {
ajax({
url: '/api/orderDetail?orderId=' + JSON.parse(orders)[0].id,
success: function (detail) {
console.log(detail);
}
});
}
});
}
});
这种代码写起来难受,维护起来更难受。所以下一步的进化方向很明确:Promise化。
3.2 第二版:Promise化并支持超时和取消
Promise化之后,调用方的体验会有质的提升。写法从“传回调和错误函数”变成“链式调用then/catch”:
javascript复制function request(options) {
return new Promise(function (resolve, reject) {
const xhr = new XMLHttpRequest();
const method = (options.method || 'GET').toUpperCase();
let url = options.url;
let data = options.data || null;
// 处理GET参数
if (method === 'GET' && data) {
const params = serialize(data);
if (params) {
url += (url.includes('?') ? '&' : '?') + params;
}
data = null;
}
xhr.open(method, url, true);
// 设置超时
if (options.timeout) {
xhr.timeout = options.timeout;
xhr.ontimeout = function () {
reject(new Error('请求超时'));
};
}
// 设置请求头
if (options.headers) {
Object.keys(options.headers).forEach(function (key) {
xhr.setRequestHeader(key, options.headers[key]);
});
}
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
resolve(xhr.responseText);
} else {
reject(new Error('请求失败,状态码:' + xhr.status));
}
}
};
xhr.onerror = function () {
reject(new Error('网络异常'));
};
// 非GET请求默认按JSON发送
if (method !== 'GET') {
if (xhr.getRequestHeader) {
// getRequestHeader不存在,这里只是防止误解,实际用options判断
}
if (!options.headers || !options.headers['Content-Type']) {
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
}
if (data && typeof data === 'object') {
data = JSON.stringify(data);
}
}
xhr.send(data);
});
}
function serialize(data) {
const params = [];
for (let key in data) {
if (Object.prototype.hasOwnProperty.call(data, key) && data[key] !== null && data[key] !== undefined) {
params.push(encodeURIComponent(key) + '=' + encodeURIComponent(data[key]));
}
}
return params.join('&');
}
这个版本解决了两大痛点:一是调用链从“嵌套回调”变成“链式调用”,二是引入超时机制,避免请求挂死。
超时这块值得多说一句。xhr.timeout是浏览器原生提供的超时能力,但很多人不知道它有几个边界情况:
- 超时时间是从
send()调用之后开始计算的,不包括open到send之间的时间。 - 如果请求已经收到部分响应再超时,事件仍然会触发,此时响应数据不完整,不能当成功处理。
timeout设为0表示不启用超时,这是默认值。
Promise化之后的调用体验变成了这样:
javascript复制request({
method: 'GET',
url: 'https://api.example.com/users',
data: { page: 1 },
timeout: 5000
})
.then(function (res) {
console.log('成功:', res);
})
.catch(function (err) {
console.error('失败:', err.message);
});
是不是清爽多了?
3.3 第三版:完整的参数序列化与请求头处理
第二版已经够用了,但作为一篇“从原理到实战”的文章,我想再把几个关键的边界情况补全,让封装更健壮。第三版要解决的是三个容易被忽略的问题:
第一个问题:参数序列化时,数组和嵌套对象怎么处理?上面写的serialize函数只能处理一层,遇到{ tags: ['a', 'b'] }会变成tags=a%2Cb,后端收到的不是数组。常见做法是把数组展开成多个同名参数:tags=a&tags=b。更好一点的做法是支持类似jQuery的$.param那种深度序列化,但这会让代码复杂度上升不少。我建议在封装层约定好:复杂结构统一在post的body里走JSON,GET只传简单扁平参数。实际项目中这个约定很常用。
第二个问题:响应类型怎么处理?光拿responseText,拿回来还得自己JSON.parse。可以在封装里加一个responseType选项,让调用方指定期望的数据格式。
第三个问题:请求取消怎么支持?这在“搜索框输入防抖”和“列表切换自动取消上个请求”的场景里非常有用。实现思路有两种:一种是通过xhr.abort()主动中断,另一种是不真正中断请求,只是在回调里忽略结果。前者省流量,后者实现简单。
第三版代码我会把上面三点都融进去。不过这里不贴完整代码了,因为接下来实战环节会把这个封装放到实际场景里继续演进,避免重复。
3.4 封装时最容易忽略的三个细节
这段是我自己写封装踩过坑之后最想提醒新人的地方,也是和axios源码对得上号的地方。
第一个细节:GET请求不要设置Content-Type请求头。有新人会给GET请求也加上application/json,这本身不报错,但很多后端框架在解析GET请求时压根不读请求体,一个空请求体配一个JSON的Content-Type,纯属多余,个别Java后端框架还会因此认为是非法请求。
第二个细节:xhr.setRequestHeader必须在open之后、send之前调用。这个顺序错了会直接抛异常。所以封装函数里,open、setRequestHeader、send这三个调用的先后顺序绝对不能乱。
第三个细节:JSON序列化之后再传,很多人喜欢直接传一个对象给send,浏览器会帮你调toString,结果就是发出去一个[object Object]。这也是为什么封装里要显式判断,如果data是对象就JSON.stringify。
4. 实战里的高频请求场景:GET、POST、上传、取消
4.1 不同类型请求的参数怎么处理
先明确一个基本规则:GET请求没有请求体,参数必须放在URL查询字符串里,也就是?key=value&key2=value2这种形式。POST、PUT、PATCH请求可以有请求体,参数既可以放URL上,也可以放body里,具体看你后端接口怎么定义。
实际操作里最常见的GET传参有两种方式。一种是把参数直接拼在URL上,比如请求用户列表时/api/users?page=1&size=10。另一种是先把参数对象序列化成查询字符串,再拼到URL上。两者最终效果一样,但后者更灵活,搭配封装的serialize函数使用很顺手。
POST传参更讲究,因为请求体格式直接由Content-Type决定。这里我整理了一份对应表:
| Content-Type | 请求体格式 | 后端解码方式 |
|---|---|---|
| application/x-www-form-urlencoded | key=value&key2=value2 | req.body / @RequestParam |
| application/json | @RequestBody / request.getInputStream() | |
| multipart/form-data | 二进制分块,带boundary分隔符 | 文件上传专用,req.files / @RequestParam |
这里面最核心的一点就是:Content-Type告诉后端“你该用什么方式解析我的请求体”。如果前端设置的Content-Type是application/json,但发的body是按urlencoded格式拼的字符串,后端用@RequestBody去解析就会直接报错,或者拿到一堆空字段。
4.2 Content-Type决定后端怎么读你的参数
这个坑我见过太多次了,单独拎出来说。最常见的翻车现场就是用axios发POST,默认给的是JSON格式的Content-Type,但后端接口是用Spring Boot的@RequestParam来接收参数的。
@RequestParam解析的是urlencoded格式的参数,它从请求体里按key=value的格式去解析,遇到JSON格式的请求体会直接解析失败。反过来也一样,后端用@RequestBody接收JSON对象,前端却用urlencoded格式发,后端同样拿不到。
所以写代码前一定要先跟后端确认接口收的是哪种格式。我自己通常用一个简单判断:如果是Java后端且方法签名带@RequestBody,前端就发JSON;如果带@RequestParam或HttpServletRequest,前端就发urlencoded格式。
在原生xhr里,发JSON和发urlencoded的区别就两个地方:Content-Type设置不同,body拼接格式不同。
javascript复制// 发送JSON格式
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.send(JSON.stringify({ name: '张三', age: 18 }));
// 发送urlencoded格式
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
xhr.send('name=' + encodeURIComponent('张三') + '&age=18');
4.3 文件上传与上传进度
用原生xhr做文件上传,最大的优势就是能拿到上传进度,这个能力fetch到现在都支持得不好。思路是通过xhr.upload.onprogress事件监听上传过程中的进度变化。
javascript复制const fileInput = document.querySelector('#fileInput');
const progressBar = document.querySelector('#progressBar');
const file = fileInput.files[0];
if (!file) return;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload', true);
xhr.upload.onprogress = function (e) {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percent + '%';
progressBar.textContent = percent + '%';
}
};
xhr.onload = function () {
if (xhr.status === 200) {
console.log('上传成功:', xhr.responseText);
} else {
console.error('上传失败:', xhr.status);
}
};
xhr.onerror = function () {
console.error('网络错误,上传中断');
};
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
// 不需要手动设置Content-Type,浏览器会自动带上multipart/form-data和boundary
xhr.send(formData);
这里有个细节新手容易踩坑:用FormData上传文件时,不要手动设置Content-Type。一旦你手动设置成multipart/form-data,浏览器不会自动帮你补充boundary参数,后端解析的时候会因为缺少分隔符而报错。正确做法是让浏览器自动生成Content-Type,它会带上完整的boundary。
4.4 请求取消、防重复提交
搜索框做“输入防抖+请求取消”是前端面试里经常出现的场景。防抖解决的是“减少请求次数”的问题,请求取消解决的是“过期响应不能覆盖新响应”的问题。
先看防抖和取消怎么配合:
javascript复制let xhr = null;
let timer = null;
function search(keyword) {
// 先取消上一次未完成的请求
if (xhr && xhr.readyState !== 4) {
xhr.abort();
}
// 防抖:500ms内没有新输入才发请求
clearTimeout(timer);
timer = setTimeout(function () {
xhr = new XMLHttpRequest();
xhr.open('GET', '/api/search?keyword=' + encodeURIComponent(keyword), true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log('搜索结果:', xhr.responseText);
}
};
xhr.send(null);
}, 500);
}
xhr.abort()会将请求终止,并且触发onabort事件。注意,abort()之后readyState会变为0,所以上面的判断要先看readyState !== 4,否则请求已经完成时再去abort,会白白多一次调用。
还有一种场景是防重复提交:用户狂点提交按钮,应该只允许第一次请求发出,后续点击直接忽略。这个可以用一个标志位解决:
javascript复制let isSubmitting = false;
function submitForm() {
if (isSubmitting) {
console.warn('请求正在提交中,请勿重复操作');
return;
}
isSubmitting = true;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/submit', true);
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
isSubmitting = false;
if (xhr.status === 200) {
console.log('提交成功');
} else {
console.error('提交失败');
}
}
};
xhr.onerror = function () {
isSubmitting = false;
console.error('网络异常');
};
xhr.send(JSON.stringify(formData));
}
这个场景比防抖简单,但特别实用。很多支付按钮、提交订单按钮,如果没有这层保护,用户连点两下就会发出两个重复订单,后端如果没做幂等处理,就会产生脏数据。
5. 踩坑实录与常见问题排查
5.1 前端侧最经典的三个坑
第一个坑是URL编码问题。参数里带中文、带&符号、带空格,直接拼在URL上,后端收到的数据是乱的。正确做法是用encodeURIComponent对每个key和value编码:
javascript复制// 错误示例:直接把中文拼在URL上
const url = '/api/search?keyword=' + keyword;
// 正确示例:对参数值编码
const url = '/api/search?keyword=' + encodeURIComponent(keyword);
第二个坑是响应头缓存问题。GET请求默认会被浏览器缓存,这在开发环境尤其烦人:你改了后端接口的返回值,但前端刷新页面拿到的还是旧数据。有几种处理方式,最简单粗暴的是在URL后面加一个时间戳:
javascript复制const url = '/api/data?timestamp=' + Date.now();
后端配合的方式是在响应头里设置Cache-Control: no-cache。我个人的习惯是开发环境用时间戳,生产环境按接口语义决定,GET类查询接口一般也建议禁用缓存,避免用户看到过期数据。
第三个坑是请求头大小写问题。协议层面请求头字段名是不区分大小写的,Content-type和content-type等价。但有些代理服务器和后端框架对大小写敏感,所以写代码时保持一致最好,统一用标准写法Content-Type。
5.2 后端“接收不到参数”的排查思路
标题的热搜词里反复出现“spring boot无法通过ajax的参数”和“后台已取得数据”这类问题,这基本是前后端联调时最高频的报错类型。很多时候前端明明把参数发出去了,后端却收到一堆null或空字符串,两边各有各的理。
我把这类问题的排查顺序整理成一个清单,按这个顺序查,绝大多数情况都能解决:
第一步:打开浏览器开发者工具的Network面板,点开那条请求,看“请求头”里的Content-Type到底是什么。这一步能直接判断前端发的是什么格式。
第二步:看“负载”或“请求体”里的原始内容。如果是JSON字符串,说明前端确实把对象序列化了;如果是key=value&key2=value2,说明走的是urlencoded格式。
第三步:对照后端接口接收方式。用@RequestBody接收的,请求体必须是JSON;用@RequestParam接收的,请求体必须是urlencoded格式。前后端格式对不上,就一定会收不到参数。
第四步:如果格式正确但仍收不到,检查参数名是否一致。Java后端常出现驼峰和下划线命名不一致的情况,前端传userName,后端用user_name接收,自然对不上。
第五步:检查是否启用了@RequestBody(required = true),前端如果没有传body,或传了空body,后端会直接报400。
有一次我们前后端联调,前端说“接口通了但是参数全是null”,我打开Network一看,Content-Type是text/plain,body是JSON字符串。后端用@RequestBody去解析,但Spring看到text/plain不会走JSON解析器,自然解析失败。解决方案是前端把Content-Type改成application/json,问题立刻消失。
5.3 跨域问题怎么快速定位
跨域是另一类高频问题,表现形式很典型:请求发出去了,Network里能看到请求记录,但响应是红色的,Console里报CORS policy相关的错误。
需要澄清一个常见误解:跨域不是浏览器拦了你的请求,而是浏览器拦了你的响应。请求本身已经发出去了,服务器也处理并返回了结果,但浏览器检查到响应头里没有Access-Control-Allow-Origin,或者该字段的值不匹配当前页面域名,就拒绝把响应交给JavaScript。
定位方法很简单:
- 看Network里请求是否显示
(failed) net::ERR_FAILED,如果是,说明CORS被拦截。 - 点开该请求,查看响应头的
Access-Control-Allow-Origin字段。如果压根没有这个字段,就是后端没配置CORS。 - 如果字段存在但和当前页面域名不一致,就是配置范围不对。
常见的解决方案有三个。一是后端开启CORS,加Access-Control-Allow-Origin: *或者指定精确域名。二是在开发环境用代理转发,比如前端开发服务器配置proxy,请求先打到同源的前端服务,再由它转发到后端,绕开浏览器的同源策略。三是用JSONP,但JSONP只支持GET请求,现在用得越来越少。
这里多说一句:开发环境遇到CORS问题,最推荐的是用代理方案,而不是让后端开通CORS。因为生产环境大概率也是前后端分离,如果只图开发方便全开*,生产环境的跨域问题还会再出现一次,而且*本身就意味着任何网站都能往你的接口发请求,有安全隐患。
5.4 面试高频追问与新人学习路线建议
手写AJAX在面试里基本是必考题,面试官通常会从浅到深问三个层面。
第一个层面:API记忆。open、send、setRequestHeader、onreadystatechange是干什么的,readyState有哪几个值,status和readyState什么区别。这个层面只要用过基本能答。
第二个层面:请求发起逻辑。代码的执行顺序是什么?为什么setRequestHeader必须放在open和send之间?send的参数在GET和POST里有什么区别?如果能答出open只是初始化、send才真正发出请求,说明理解到了位。
第三个层面:封装思路和边界情况。怎么做Promise封装?超时怎么处理?请求取消怎么实现?如果面试官问“如果服务端返回了500,你的Promise应该resolve还是reject”,这其实是在考你知道HTTP状态码和业务状态码的区别。我建议的答案是:HTTP层面非2xx统一reject,业务层面的错误码(比如返回{ code: 1, msg: '失败' }但HTTP状态码是200)交给调用方决定,不要在封装层硬编码业务规则。
给新人一个学习路径建议:先按本文第2节的代码把原生GET和POST跑通,再自己独立实现一遍Promise封装,然后对比一下axios源码,最后把请求取消和上传进度这两个场景实现一遍。这个路径走下来,AJAX相关的知识基本就没死角了。遇到面试不会的题目,比如同步请求为什么不推荐(会阻塞主线程、页面卡死)、overload重载(同一接口不同解析方式)、请求串号(多个异步请求结果错乱),都有底层知识能兜底,不会慌。
写在最后
回头看,手写原生AJAX这件事,代码量并不大,难点在于把HTTP请求的完整生命周期装进脑子里。我在实际开发中见过太多同事,接口报错第一反应是“是不是后端的问题”,第二反应是“是不是axios的bug”,很少有人愿意打开Network面板,先看请求头、再看请求体、接着看响应,一步步推理出问题在哪。其实很多请求相关的疑难杂症,光靠浏览器开发者工具就能定位个八九不离十。
最后再分享一个小技巧:遇到ajax相关的奇怪问题,先别急着改代码,先把那条请求从Network面板里右键“Copy as fetch”导出来。导出来的代码是浏览器最原始的请求格式,你在控制台里执行一遍,如果成功,说明浏览器层面请求没问题,问题一定出在你封装的代码上;如果失败,说明请求本身的某个环节就有问题,对照导出的格式一步步检查,很快就能找到症结。这个方法我用了好几年,帮我排掉过无数个看着像“玄学”的线上Bug。
