如果你带过前端新人,下面这段对话大概率不陌生:
新人:老师,现在 axios 都封装好了,为什么还要学 AJAX?
我:那如果项目里不让你引第三方库,让你自己写请求层,你怎么办?
新人:直接把 axios 引入项目不就行了吗……
这个对话我经历过很多次。很多刚入门前端的人觉得“原生JS手写AJAX请求”是老掉牙的内容,实际上一旦你开始面试、开始排查线上问题、开始读懂 axios 源码,就会明白这块基础反而最值钱。AJAX 不是某个框架的特性,它是浏览器提供的 HTTP 通信能力,是所有前端网络请求的地基。
这篇文章我会从一个实际带人的角度,把原生 XHR 从原理到实战完整拆一遍。内容包括:最简请求怎么写、GET 和 POST 的参数怎么组装、Content-Type 和中文编码的坑、超时中断缓存竞态这些边界场景、以及最后封装一个 Promise 版的 request 函数。没有框架包装,没有语法糖,全部用原生 JS 实现。
如果你是把“会用 axios”当成“会网络请求”的初学者,或者正准备前端面试想补一轮基础,这篇文章应该能帮到你。看完之后你再去用 axios,看到的不再是一个黑盒,而是一层封装里面的设计思路。
1. 为什么框架满地走,新人还是要亲手把 AJAX 写一遍
1.1 你学的不是 API,而是浏览器的 HTTP 能力
AJAX 的全称是 Asynchronous JavaScript And XML,中文叫“异步 JavaScript 和 XML”。名字听起来像一门新技术,但它并不是,它只是描述了一种能力:页面不刷新,就能通过 JavaScript 向服务器发请求、拿响应、更新局部内容。
这套能力在浏览器里的载体,主要就是 XMLHttpRequest 对象,也就是大家常说的 XHR。虽然它名字里带 XML,但现在传输 JSON 才是常态。这个对象从 IE 时代就存在,后来被标准化,一直到今天仍然是浏览器网络请求的核心 API 之一。
新人不理解这一点,就容易把 AJAX 当成语法去背:new XMLHttpRequest()、open、send、onreadystatechange……背完就忘。一旦你真理解了它背后在做什么——你是在页面里通过 JS 发起一次 HTTP 请求,然后等浏览器把服务端的响应交还给你——你就能看懂所有网络请求库的底层逻辑。
1.2 你不用 XHR,不代表 XHR 不重要
很多人会问:我现在写项目都用 axios,为什么还要手写 XHR?
答案很简单:axios 在浏览器端并不是魔法,它内部封装的就是 XHR。不同环境它选不同的适配器,浏览器环境里它的核心请求逻辑就是围绕 XHR 封装出来的。我们常用的 get、post、interceptors、timeout,本质上都是对 XHR 行为的上层加工。
当年我带新人排查过一个上传进度条的问题。上传文件要显示进度,UI 组件库没有直接暴露这个能力,有人翻了一晚上文档找不到配置项,其实就是对 XHR 的 upload.onprogress 事件做监听。你只会在 axios 的 onUploadProgress 里传回调,却不知道它包装的是什么,那一旦封装的库不支持某个底层能力,就会卡住。
手写 XHR 还有一个直接价值:面试。前端面试题里“用原生 JS 实现一个 AJAX 请求”是高频题,很多候选人写得出 axios.get,却写不出一个干净的 XHR 实例。2026 年了,这个题我依然在问,因为它能快速判断一个人是背了框架,还是真的理解请求链路。
1.3 完整请求生命周期:一张图能说明白的事
在进入代码之前,先建立整体认知。一次完整 XHR 请求大致是这样一个流程:
- 创建 XHR 对象
- 调用
open方法,确定请求方式、请求地址、是否异步 - 注册事件回调,处理响应、错误、超时
- 调用
send方法,发送请求 - 浏览器在后台与服务端交换数据
- 响应回来后触发回调,你在回调里读取状态码和响应体
- 根据状态码判断业务成功还是失败,再去做后续处理
这个流程,我会在下文通过代码一层一层拆开。你应该感觉到它和“页面整体刷新”的传统模式完全不同,核心区别就在“异步”两个字上。页面刷新的流程是:用户操作、浏览器重新请求整个 HTML、白屏、再渲染。而 XHR 的流程是:用户操作、JS 发一个请求、页面保持原样、数据回来后只更新需要变化的那部分 DOM。
这也是 AJAX 当年颠覆用户体验的根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从最小请求开始:XHR 对象的完整运行链路拆解
2.1 先跑通一个最小可用示例
理论说太多容易飘,直接上一个最简例子。下面的代码会创建一个按钮,点击后向某个接口发请求,然后把返回的文本渲染到页面上,整个过程不刷新页面。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>最小 AJAX 请求</title>
</head>
<body>
<button id="btn">点击加载数据</button>
<pre id="result">还没有数据</pre>
<script>
var btn = document.getElementById('btn');
var result = document.getElementById('result');
btn.addEventListener('click', function () {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users', true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300) {
result.textContent = xhr.responseText;
} else {
result.textContent = '请求失败,状态码:' + xhr.status;
}
}
};
xhr.send();
});
</script>
</body>
</html>
把它放到一个本地静态服务里,/api/users 换成你自己后端的真实接口,点击按钮就能看到效果。第一次跑通后,你可以打开浏览器控制台的 Network 面板,再点一次按钮,观察那条请求的网络耗时、请求头、响应体。你会看到一个最重要的现象:页面没有闪一下,数据却已经更新了。这就是异步请求的直观感受。
2.2 open 方法:告诉 XHR 你打算怎么请求
xhr.open(method, url, async) 这行代码不会真正发请求,它只是在“准备”一次请求。三个参数分别代表:
method:HTTP 方法,字符串,比如'GET'、'POST'url:请求地址async:是否异步,布尔值,传true或省略是异步
第三个参数有一个大坑:如果你传 false,请求就会变成同步阻塞模式。代码会卡在 send 那一行,直到网络响应回来才继续往下走。页面在这个期间可能失去响应,用户体验极差,而且现代浏览器已经在主线程上不推荐甚至警告这种用法。初学者最容易犯的错误,就是把别人代码里的 true 改成 false,试图“让代码等一等结果再继续”,结果把页面搞卡死。
在异步模式下,send 调用之后,后面的代码立即继续执行,响应什么时候回来由浏览器事件机制通知你。这就是 JS 里“非阻塞”的表现。
2.3 readyState 的 0 到 4:XHR 的状态机
把网络请求比喻成“寄快递”:你先打电话叫快递员(创建对象),填好寄件信息(open),把包裹交出去(send),然后快递员在路上,每到一个节点就告诉你一声(触发事件),最后送到收件人手里(请求完成)。
XHR 对象内部有 5 个状态,通过 readyState 属性暴露出来:
| readyState | 常量名 | 含义 |
|---|---|---|
| 0 | UNSENT | XHR 已创建,open 尚未调用 |
| 1 | OPENED | open 已调用,请求已准备好 |
| 2 | HEADERS_RECEIVED | 已收到响应头 |
| 3 | LOADING | 响应体正在加载中 |
| 4 | DONE | 响应体加载完成,请求结束 |
onreadystatechange 会在状态变化时被调用,也就是说它不一定只触发一次。从状态 1 到状态 4,中间每跳一次都会回调一次。
写代码时,我们只关心最终状态,也就是 readyState === 4。在这个状态里,响应已经完整到达,可以安全地读取 status、responseText 这些属性了。这也是经典代码里一定要先判断 readyState === 4 的原因。
2.4 onreadystatechange 和 onload 怎么选
现代浏览器还支持 xhr.onload,它在请求成功完成时触发一次,比 onreadystatechange 更简洁:
js复制xhr.onload = function () {
console.log(xhr.status, xhr.responseText);
};
于是有一个经典问题:既然有 onload,为什么老代码都用 onreadystatechange?
两个原因。第一,历史兼容。早期浏览器对 onload 的支持不如现在完整,老代码为了保证兼容性,统一用 onreadystatechange 判断状态。第二,onload 只在请求完全结束时触发,不够灵活。如果你想在收到响应头时就做点什么,比如读取 getResponseHeader 提前判断内容类型,那就只能在 readyState === 2 时处理。虽然大多数业务只需要结束态,但理解状态机仍然必要。
我个人写业务代码时更偏好 onload + onerror,代码更短,分工更清晰。但如果问题明确要求“兼容 IE”,那你还是得回到 onreadystatechange 的老写法。
3. GET 和 POST 实战:参数拼接、请求头与中文编码,把最容易翻车的细节说透
3.1 GET 参数怎么拼:健壮查询字符串的写法
GET 请求的参数一般放在 URL 的查询字符串里。最直白的方式是拼字符串:
js复制xhr.open('GET', '/api/user/list?page=1&size=10', true);
但真实项目中参数往往是动态的,你不可能手写死。一个可复用的做法是把 JS 对象转换成查询字符串:
js复制function objToQueryString(params) {
var arr = [];
for (var key in params) {
if (params.hasOwnProperty(key)) {
arr.push(encodeURIComponent(key) + '=' + encodeURIComponent(params[key]));
}
}
return arr.join('&');
}
// 使用
var params = objToQueryString({ page: 1, size: 10, keyword: '前端' });
xhr.open('GET', '/api/user/list?' + params, true);
这段代码里最关键的函数是 encodeURIComponent。它有两个作用:
一是把中文变成符合 URL 规范的百分号编码。keyword=前端 会被转成类似 keyword=%E5%89%8D%E7%AB%AF 的形式,避免浏览器和服务端解析混乱。
二是把 &、=、? 这些特殊字符转义。假设用户输入的搜索词是 a&b,如果不编码直接拼进 URL,服务端会把它解析成两个参数:keyword=a 和 b,数据就错了。
还要注意,encodeURIComponent 和 encodeURI 不一样。encodeURI 适合编码整个 URL,它不会转义 :、/、?、& 这些 URL 结构字符;而 encodeURIComponent 适合编码单个参数名或参数值,它会转义得更彻底。拼查询字符串时,请务必对参数名和参数值分别调用 encodeURIComponent,这是团队里常见的“隐形 bug 源”。
3.2 POST 表单式传参:最容易被误解的编码格式
POST 请求的参数不是放在 URL 里,而是放在请求体中,也就是 send 方法里。
如果后端接口接收的是传统表单格式,那么请求头要设置成 application/x-www-form-urlencoded,请求体是一段类似查询字符串的内容:
js复制var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/login', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
var body = 'username=' + encodeURIComponent('张三') + '&password=' + encodeURIComponent('123456');
xhr.send(body);
为什么一定要显式设置这个 Content-Type?
因为不设置的话,有些后端框架不知道你请求体里的数据是什么格式,可能直接把它当成普通文本,解析不出来 username 和 password。反过来,你也不能对着一个接收 JSON 的接口设置 application/x-www-form-urlencoded,那样后端按 JSON 解析你的字符串会直接报错。
请求体本身也要遵循格式:多个键值对之间用 & 连接,键和值都要用 encodeURIComponent 编码。这就是“给 ajax 请求参数赋值”时的标准姿势。
3.3 POST 传 JSON:现代项目里最常用的一种
如今前后端分离的项目,大多数接口设计成接收 JSON 字符串。此时需要把 Content-Type 设成 application/json,然后通过 JSON.stringify 把对象序列化:
js复制var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/user', true);
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(xhr.responseText);
}
};
var body = JSON.stringify({ username: '张三', age: 18 });
xhr.send(body);
这里有一个很容易翻车的地方:新手会把对象直接塞进 send,比如 xhr.send({ username: '张三' })。但 XHR 的 send 方法并不能自动序列化对象,它接收的是字符串、FormData、Blob、ArrayBuffer 这些类型。直接传对象时,浏览器的行为通常是把它转成字符串 [object Object],后端拿到的就是一个废数据。
所以“传 JSON”这个动作其实是两步:先 JSON.stringify 序列化,再 send。相应地,后端接口如果已经接收 JSON,那就不要再手动拼成 a=1&b=2 的格式,二者只能选一种,取决于后端怎么解析。
3.4 请求头必须在 open 之后、send 之前设置
setRequestHeader 的调用时机是一个细节题,但很多新人受过伤。
代码顺序必须是:
js复制xhr.open('POST', '/api/login', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=UTF-8');
xhr.send('username=zhangsan');
为什么不能放在 open 之前?因为 XHR 规范里,setRequestHeader 只有在 open 调用之后才会把请求头信息写入当前请求的头部集合。你在 open 之前调用,它会抛出一个 InvalidStateError 异常,因为此时请求还没有“打开”,浏览器不知道要把这个头挂到哪个请求上去。
为什么必须在 send 之前?因为 send 表示请求已经发出去了,头部信息已经固化到网络层,你再设置也来不及改变这次请求了。所以如果你是动态给请求加头,一定要在 send 前完成所有 setRequestHeader 调用。
3.5 中文乱码的根源到底在哪里
聊到编码,很多人第一反应是“在 URL 后面加 charset”,其实乱码问题要从一整条链路来看:浏览器页面编码、请求头编码、服务端解析编码、响应返回编码、数据库编码,任何一环不统一都可能乱码。
对前端来说,能做到的控制点主要是三个:
- 保证页面本身通过
<meta charset="UTF-8">声明 UTF-8 - GET 参数和 POST 表单体里的中文,都用
encodeURIComponent做 UTF-8 百分号编码 - POST 请求头显式声明
charset=UTF-8
服务端接收后,用 UTF-8 去解码 URL 和请求体,基本不会乱码。
如果你遇到乱码,不要急着在后端改编码。先在 Network 面板看“实际发出去的请求是什么”,比如请求体里的中文是不是已经变成了 %E5%BC%A0%E4%B8%89。如果已经编码了,但仍乱码,问题大概率在服务端用错了字符集去解析;如果根本没有编码,原始中文直接被塞进请求体,那才是前端的问题。
4. 不只是 200:响应数据、超时中断、缓存与竞态这些边界问题
4.1 后端返回的数据长什么样
响应回来以后,我们需要从 XHR 对象上读取结果。最常用的是 responseText,它是一个字符串。如果后端返回的是 JSON 字符串,你需要手动解析:
js复制var data = JSON.parse(xhr.responseText);
网上很多代码直接 JSON.parse,看起来没问题,但实际接口一旦断电、超时、返回空字符串、返回一段 HTML,解析就会抛异常,甚至把整个逻辑中断掉。更稳妥的写法是包一层 try/catch,解析失败时保留原始字符串。
除了 responseText,XHR 还有两个相关属性:
responseXML:当响应类型是 XML 时返回文档对象responseType:如果设置为'json',现代浏览器会帮你自动解析 JSON,xhr.response直接就是对象
对普通业务来说,responseType = 'json' 很方便:
js复制var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users', true);
xhr.responseType = 'json';
xhr.onload = function () {
if (xhr.status === 200) {
console.log(xhr.response); // 已经是对象
}
};
xhr.send();
但需要注意兼容性,极老浏览器对 responseType = 'json' 支持不好。如果做内部系统,可以放心用;如果要做偏公共的页面,建议手动 JSON.parse 并做异常兜底。
4.2 状态码判断:什么算成功
我们的示例代码里常用 xhr.status === 200,但真实项目里 HTTP 状态码远不止 200。
简单归纳:
| 状态码 | 含义 | 前端常见处理 |
|---|---|---|
| 200 | 请求成功 | 正常处理响应 |
| 201 | 资源创建成功 | POST 提交成功,可跳转或提示 |
| 204 | 无内容 | 删除类接口常见,响应体为空也属于成功 |
| 304 | 命中缓存 | 浏览器可能直接使用本地缓存 |
| 400 | 请求参数有误 | 提示用户检查输入 |
| 401 | 未认证 | 跳转登录或刷新令牌 |
| 403 | 已被认证但无权限 | 提示无权限 |
| 404 | 资源不存在 | 提示接口或页面不存在 |
| 500 | 服务器内部错误 | 统一错误提示 |
| 502/503 | 网关或服务不可用 | 提示稍后重试 |
所以判断成功的代码,不要只写 xhr.status === 200。更健壮的方式是判断 xhr.status >= 200 && xhr.status < 300,也就是所有 2xx 都算成功。如果你对接过老系统,可能还会遇到 304,老代码里常见的写法是:
js复制if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
// 成功
}
}
4.3 网络错误、超时和主动取消
HTTP 状态码只能代表“请求到达了服务端,服务端给了响应”。但前端还会遇到另一种情况:请求根本没到达服务端,或者服务端迟迟不回。
这时候要用到三个事件:
xhr.onerror:网络异常、DNS 失败、连接被重置等场景触发xhr.ontimeout:超过设定时间没有完成请求时触发xhr.onabort:调用xhr.abort()主动取消请求时触发
我们可以这样设置超时和错误处理:
js复制var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users', true);
xhr.timeout = 10000; // 10 秒超时
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('成功', xhr.responseText);
} else {
console.log('HTTP 错误', xhr.status);
}
};
xhr.onerror = function () {
console.log('网络请求出错');
};
xhr.ontimeout = function () {
console.log('请求超时');
};
xhr.onabort = function () {
console.log('请求已取消');
};
xhr.send();
有一个知识点值得新人记住:HTTP 404 这种错误不会触发 onerror,它只是 status 不是 2xx。onerror 触发的是网络层面的问题,两者要分开处理。很多新人的代码把 HTTP 错误和网络错误混在一起,导致排查问题时不知道从哪下手。
4.4 GET 请求的缓存问题,如何规避
开发的本地环境中,GET 请求的缓存问题不是那么容易遇到,因为一般都会关闭缓存。但部署到线上后,某些 GET 接口可能因为浏览器缓存策略,导致你改了数据,页面刷新后拿到的还是旧结果。
要绕过缓存,常见做法是在 URL 后面加一个随机参数:
js复制var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/users?_t=' + Date.now(), true);
xhr.send();
这样每次请求的 URL 都不相同,浏览器不会命中旧缓存。既然要写随机参数,就直接用时间戳。如果你已经用了 objToQueryString 这样的函数来传参,可以把 _t 也作为一个固定字段加进去。
这种处理手段尤其适合查询类接口。注意它并不优雅,只是前端兜底方案。真正合理的做法是在后端响应头里配置 Cache-Control: no-cache 或合适的缓存策略,前端大量加时间戳反而会影响 CDN 和浏览器缓存发挥性能优化作用。
4.5 竞态问题:请求结果顺序错乱
假设页面上有一个输入框,用户每输入一个字就发一次搜索请求。如果用户输入速度快,上一次请求还没返回,下一次请求已经发出去了。网络情况不稳定时,先发的请求可能后返回,后发的请求反而先返回,最终页面显示的结果可能和最后一次输入不匹配。这就是“竞态问题”。
最简单的解法是用一个自增序号标记请求,只有最新一次请求的响应才允许更新页面:
js复制var requestId = 0;
input.addEventListener('input', function () {
var currentId = ++requestId;
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/search?keyword=' + encodeURIComponent(input.value), true);
xhr.onload = function () {
if (currentId !== requestId) {
return; // 说明已经有更新的请求发出,丢弃这次结果
}
renderResult(xhr.responseText);
};
xhr.send();
});
如果你想更节省资源,还可以在发新请求前把旧请求 abort() 掉。不过 abort 会触发 onabort,代码要多做一层防护。实际项目中“请求序号 + abort 结合”也是常见的做法。这些边界情况,面试官喜欢拿来考验候选人是否真的处理过复杂前端场景。
5. 封装一个 Promise 化的 request 函数:从能用变成好用
5.1 为什么要做 Promise 封装
原生 XHR 的写法是事件回调式。一次请求需要处理成功、失败、超时等多个事件的回调,代码一多就会显得乱。尤其是当你需要在一个接口成功后接着请求另一个接口时,如果继续用回调嵌套,会出现经典的“回调地狱”。
Promise 可以让异步代码的书写方式更接近同步思维。把 XHR 包在一个 Promise 里,成功时 resolve,失败时 reject,调用方就能用 .then().catch() 的方式来组织逻辑,也可以用 async/await 来写。
封装请求函数,也是一次很好的抽象练习。你把自己业务里重复的逻辑收拢到一个函数里,暴露简洁的配置项,这和 axios 设计思路是同构的。
5.2 一个够用的 request 实现
下面这个函数,是我在新人训练时常用的一份示例。它支持 GET 和 POST、支持查询参数、支持自定义请求头、支持超时、支持对象自动 JSON 序列化,并对响应做了一层 JSON 解析容错。代码量不大,但足以看清封装思路:
js复制function request(options) {
var url = options.url;
var method = options.method || 'GET';
var params = options.params || null;
var data = options.data || null;
var headers = options.headers || {};
var timeout = options.timeout || 10000;
// 处理 GET 查询参数
if (params) {
var queryString = Object.keys(params)
.map(function (key) {
return encodeURIComponent(key) + '=' + encodeURIComponent(params[key]);
})
.join('&');
url += (url.indexOf('?') > -1 ? '&' : '?') + queryString;
}
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url, true);
// 设置自定义请求头
Object.keys(headers).forEach(function (name) {
xhr.setRequestHeader(name, headers[name]);
});
xhr.timeout = timeout;
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) {
return;
}
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
var response = xhr.responseText;
try {
response = JSON.parse(response);
} catch (e) {
// 解析失败就保留字符串
}
resolve(response);
} else {
reject(new Error('HTTP ' + xhr.status + ': ' + xhr.responseText));
}
};
xhr.onerror = function () {
reject(new Error('网络连接异常'));
};
xhr.ontimeout = function () {
reject(new Error('请求超时'));
};
xhr.onabort = function () {
reject(new Error('请求已取消'));
};
// 处理请求体
var body = null;
if (data) {
if (typeof data === 'object' && !(data instanceof FormData)) {
// 普通对象默认按 JSON 发送
if (!headers['Content-Type']) {
xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
}
body = JSON.stringify(data);
} else if (typeof data === 'string') {
// 字符串数据按原样发送,调用方需要自行设置 Content-Type
body = data;
} else {
// FormData、Blob 等直接发送
body = data;
}
}
xhr.send(body);
});
}
