从零搞懂 Ajax:从原生 XHR 到 Promise,前端数据请求进化史
前言
很多前端新手学 Vue/React 时,直接用 axios.get() 或者 fetch() 发请求,数据就回来了------"挺简单啊"。但问到下面这些问题,可能就卡住了:
- "为什么
res.json()返回的是一个 Promise?" - "回调地狱是什么?async/await 到底解决了什么问题?"
- "JSON.stringify 和 JSON.parse 在请求里分别干嘛?"
- "跨域是什么?为什么后端要加
Access-Control-Allow-Origin?"
本文从一个极简的后端 + 前端项目 出发,把这些基础概念串成一条线讲清楚。不堆术语,用最直白的话带你搞懂 Ajax 的来龙去脉。
📌 本文 Demo 结构 :一个 Node.js 后端(提供
/todos接口)+ 一个纯 HTML 前端(用 XHR 请求数据),不到 80 行代码,把 Ajax 核心原理讲透。
一、先看项目结构
go
ajax/
├── backend/
│ ├── index.js ← Node 后端:起一个 HTTP 服务,返回 JSON 数据
│ └── package.json ← 标记为 CommonJS 模块
├── frontend/
│ └── index.html ← 前端:用 XHR 请求数据,动态渲染到页面
└── readme.md ← 笔记
跑起来很简单:
- 后端:
node backend/index.js→ 启动在localhost:3000 - 前端:直接用浏览器打开
frontend/index.html
二、后端:数据从哪里来?
先看后端代码,它负责"提供数据":
js
// node 内置的 http 模块 --- 不需要 npm install
const http = require('http');
http.createServer((req, res) => {
// 准备数据(实际项目从数据库查)
const todos = [
{ id: 1, title: '过四六级', completed: false },
{ id: 2, title: '回家', completed: false }
];
if (req.url === '/') {
res.end('hello world');
}
else if (req.url === '/todos') {
// ① 允许跨域
res.setHeader('Access-Control-Allow-Origin', '*');
// ② 告诉浏览器返回的是 JSON,编码是 UTF-8
res.setHeader('Content-Type', 'application/json;charset=utf-8');
// ③ 把 JS 对象序列化为 JSON 字符串,以二进制发出
res.end(JSON.stringify(todos));
}
}).listen(3000, () => {
console.log('server is running on 3000 port');
});
📌 知识点 1:require vs import --- 两套模块化方案
js
const http = require('http'); // CommonJS --- Node.js 原生方案
import http from 'http'; // ESM --- 现代标准方案
| CommonJS | ESM (ES Module) | |
|---|---|---|
| 语法 | require() / module.exports |
import / export default |
| 诞生 | Node.js 早期,服务端先用 | ES6 标准(2015),浏览器原生支持 |
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 使用场景 | 老项目、Node 脚本 | 新项目、Vite/Webpack 打包、浏览器 |
一句话:CommonJS 是过去,ESM 是现在和未来。 本文后端用
require是因为 Node 默认就是 CommonJS;如果你在package.json里加"type": "module",Node 就会走 ESM。
📌 知识点 2:HTTP 响应头 --- 浏览器和服务器的"约定暗号"
js
res.setHeader('Content-Type', 'application/json;charset=utf-8');
res.setHeader('Access-Control-Allow-Origin', '*');
Content-Type:告诉浏览器"我返回的数据是什么格式"。
application/json→ JSON 格式text/html→ HTML 页面text/plain→ 纯文本charset=utf-8→ 编码方式,保证中文不乱码
Access-Control-Allow-Origin:跨域资源共享(CORS)的关键。
什么是跨域? 浏览器的同源策略 规定:协议、域名、端口三者必须完全一致,才能互相访问。你本地 HTML 用
file://协议打开,去请求http://localhost:3000,协议和端口都不同 → 浏览器直接拦截。后端加上这个头等于告诉浏览器:"我允许任何人来访问我"。
不加这一行的后果:
csharp
🚫 Access to fetch at 'http://localhost:3000/todos' from origin 'null'
has been blocked by CORS policy
📌 知识点 3:JSON.stringify() --- 对象怎么在网络里"跑"?
js
res.end(JSON.stringify(todos));
这是本文第一个核心函数,搞懂它很重要。
为什么需要它?
JS 对象是存在内存里的,不能直接通过网络传输。网络传输的是二进制字节流。所以需要:
javascript
JS 对象(内存) → JSON字符串(文本) → 二进制字节流(网络) → JSON字符串 → JS 对象(对方内存)
序列化 编码发送 接收解码 反序列化
JSON.stringify(value, replacer?, space?) 的三个参数:
| 参数 | 作用 | 示例 |
|---|---|---|
value |
要序列化的对象 | {name: '小明', age: 18} |
replacer |
过滤器:选哪些字段,或替换值 | ['name'] 只保留 name;null 全部保留 |
space |
缩进空格数,提高可读性 | 2 → 两个空格缩进 |
js
const obj = { name: '小明', password: '123456', age: 18 };
// replacer: null → 全部序列化
JSON.stringify(obj, null, 2);
// 输出:
// {
// "name": "小明",
// "password": "123456",
// "age": 18
// }
// replacer: 数组 → 只要 name 和 age,password 被过滤
JSON.stringify(obj, ['name', 'age'], 2);
// 输出:
// {
// "name": "小明",
// "age": 18
// }
💡
space参数看似小细节,但在团队协作里很有用------后端接口返回的 JSON 带缩进,前端调试时一眼就能看清结构。
三、前端:数据怎么"拿"回来?
html
<body>
<ul id="todos"></ul>
<script>
// ① 创建 XHR 对象 --- Ajax 的核心
const xhr = new XMLHttpRequest();
// ② 打开一个 HTTP 请求(第三个参数 true = 异步)
xhr.open("GET", "http://localhost:3000/todos", false);
console.log('start');
// ③ 注册回调函数 --- 当请求状态变化时自动调用
xhr.onreadystatechange = function() {
console.log(xhr.readyState);
if (xhr.status === 200 && xhr.readyState === 4) {
// ④ JSON.parse --- 把 JSON 字符串还原为 JS 对象
const todos = JSON.parse(xhr.responseText);
// ⑤ 动态渲染到页面
document.getElementById('todos').innerHTML = todos.map(
todo => `<li>${todo.title}</li>`
).join('');
}
}
// ⑥ 发送请求
xhr.send();
console.log('end');
</script>
</body>
逐行拆解:
📌 知识点 4:XHR(XMLHttpRequest)--- Ajax 的"祖师爷"
XMLHttpRequest 是浏览器提供的原生 API,让 JS 能主动发送 HTTP 请求。
一段历史 :在 Ajax 出现之前,网页要拿新数据必须整页刷新 。2005 年 Google 用
XMLHttpRequest实现了 Gmail 和 Google Maps 的"无刷新更新",Web 2.0 时代从此开启。"Ajax" 这个名字就是 A synchronous J avaScript A nd XML 的缩写,虽然现在大家都用 JSON 而不是 XML 了。
📌 知识点 5:readyState --- XHR 的"心跳"
| readyState | 含义 |
|---|---|
| 0 | UNSENT --- 还没调用 open() |
| 1 | OPENED --- 已调用 open() |
| 2 | HEADERS_RECEIVED --- 已收到响应头 |
| 3 | LOADING --- 正在接收响应体(数据一点点来) |
| 4 | DONE --- 请求完成,数据全部拿到 |
js
xhr.onreadystatechange = function() {
// 这个函数会被调用多次(0→1→2→3→4)
// 只有 readyState === 4 且 status === 200 才算真正成功
if (xhr.status === 200 && xhr.readyState === 4) {
// 安全地使用数据
}
}
💡 这就是**回调函数(Callback)**模式:把"数据到了之后要做什么"写成一个函数,交给 XHR,XHR 在合适的时机自动调用它。你不需要一直盯着------异步的精髓就在这。
📌 知识点 6:JSON.parse() --- 字符串"复活"为对象
js
const todos = JSON.parse(xhr.responseText);
// xhr.responseText 是字符串:'[{"id":1,"title":"过四六级","completed":false}]'
// JSON.parse 之后变成真正的 JS 数组:[{id:1, title:"过四六级", completed:false}]
JSON.stringify 和 JSON.parse 是一对逆操作:
javascript
JS 对象 ←→ JSON 字符串
JSON.stringify(对象) → 字符串
JSON.parse(字符串) → 对象
四、JS 异步处理:从回调地狱到 async/await
这是本文最核心的知识线。JS 处理异步经历了三代进化:
javascript
回调函数(Callback) → Promise + .then() → async/await
第一代 第二代 第三代
4.1 为什么 JS 需要异步?
JS 是单线程语言------同一时间只能做一件事。如果发一个网络请求要等 2 秒,这 2 秒里页面就完全卡死。
JS 的解决方案是 Event Loop(事件循环):
arduino
同步代码立即执行
↓
遇到异步任务(网络请求、定时器...)→ 扔到"等待区",继续往下走
↓
同步代码全部跑完
↓
到"等待区"看谁准备好了,拿出回调函数执行
↓
执行完再回去看"等待区"...循环往复
来看代码里的证据:
js
console.log('start'); // ① 同步,立即执行
xhr.onreadystatechange = function() { // ③ 异步回调,等请求完成才执行
console.log(xhr.readyState);
}
xhr.send(); // ② 发送请求(异步操作)
console.log('end'); // ④ 同步,立即执行
// 实际输出顺序:start → end → 2 → 3 → 4
// 而不是:start → 2 → 3 → 4 → end
🔑 关键认知 :
xhr.send()发出去之后,JS 不会站在原地干等,而是直接往下执行console.log('end')。等网络请求的数据到了,才会去执行onreadystatechange里的回调。
4.2 第一代:回调函数 --- 好用但有坑
js
// 回调:把"拿到数据后要干嘛"包成函数传进去
xhr.onreadystatechange = function() {
const data = JSON.parse(xhr.responseText);
// 用 data 干点什么...
}
问题 :如果需要多个请求按顺序执行(下一个请求依赖上一个的结果),就会变成"回调地狱":
js
// 😱 回调地狱 --- 层层嵌套,读都读不懂
getUser(userId, function(user) {
getOrders(user.id, function(orders) {
getOrderDetail(orders[0].id, function(detail) {
getProduct(detail.productId, function(product) {
// 终于拿到想要的数据了...😫
});
});
});
});
4.3 第二代:Promise + then --- 把嵌套拍平
js
// ✅ Promise:链式调用,不嵌套
fetch("http://localhost:3000/todos")
.then(res => res.json()) // 第一步:解析 JSON
.then(data => { // 第二步:用数据
console.log(data);
})
.catch(err => { // 统一错误处理
console.error('出错了', err);
});
知识点 :
fetch()返回的是Promise对象。Promise是 ES6 引入的------它把"未来的某个值"包装成一个对象。当异步操作完成时,Promise 从 "pending(等待中)" 变为 "fulfilled(已完成)" 或 "rejected(失败)"。Promise 有三种状态:
pending→fulfilled或rejected,且状态一旦改变就不会再变。
为什么 .then(res => res.json()) 也是一个 Promise?
res.json() 本身是异步的------响应体可能很大,解析 JSON 需要时间。所以它返回一个 Promise,你得再 .then() 一次才能拿到真正的数据。
4.4 第三代:async/await --- 让异步代码"看起来像同步"
js
// 🌟 async/await:最优雅的写法
const main = async () => {
try {
const res = await fetch("http://localhost:3000/todos");
const data = await res.json();
console.log(data);
} catch (err) {
console.error('出错了', err);
}
}
main();
async/await 好在哪?
| 写法 | 嵌套层数 | 错误处理 | 可读性 |
|---|---|---|---|
| 回调 | 可能无限嵌套 | 每层单独处理 | 😫 |
| Promise + then | 链式,不嵌套 | .catch() 统一处理 |
🙂 |
| async/await | 完全拍平 | try/catch 统一处理 | 😍 |
🔑 理解要点 :
async/await是 Promise 的语法糖 ------底层还是 Promise,但写法上和同步代码一模一样。await就是告诉 JS:"这里等一下,拿到结果再往下走"。但它不会阻塞 JS 主线程,只是这个async函数内部在"原地等待"。
4.5 三代对比速查表
| Callback | Promise | async/await | |
|---|---|---|---|
| 时代 | ES5 之前 | ES6 (2015) | ES8 (2017) |
| 核心 | 函数当参数传 | Promise 对象 | async 函数 + await |
| 嵌套 | 层层嵌套(地狱) | 链式调用(拍平) | 完全拍平(像同步) |
| 错误处理 | 每层自己处理 | .catch() |
try/catch |
| 本质 | 最原始的模式 | 状态机封装 | Promise 的语法糖 |
五、补充:注释里藏着的前端进化史
注意到 index.html 里的注释了吗?它们本身就是一部微型进化史:
html
<script>
// fetch 的前辈
// 输入 url 点击 a 标签
// 底层本质是 js 可以主动去发送接口 http 请求
// fetch/xhr 请求接口,动态的更新页面 web 2.0 时代 繁荣
const xhr = new XMLHttpRequest();
// 对比:
// fetch("http://localhost:3000/todos")
// .then(res => res.json())
// .then(data => { console.log(data); })
</script>
这条注释串起了三个阶段:
bash
Web 1.0:点击 a 标签 → 整页刷新 → 看到新内容(被动浏览)
Web 2.0:JS 主动发 XHR/fetch → 拿到数据 → 局部更新页面(动态交互)
现代前端:fetch + async/await → 像写同步代码一样写异步逻辑
六、一张图串起全部概念
javascript
┌─────────────────────────────────────────────────────────────────┐
│ 后端 (Node.js) │
│ │
│ JS 对象 {id:1, title:'过四六级'} │
│ │ │
│ │ JSON.stringify(obj) ← 序列化:对象 → JSON 字符串 │
│ ▼ │
│ JSON 字符串 '{"id":1,"title":"过四六级"}' │
│ │ │
│ │ res.end() ← 二进制字节流,通过网络传输 │
│ ▼ │
├─────────────────────────────────────────────────────────────────┤
│ HTTP 网络 │
│ headers: Content-Type, Access-Control-Allow-Origin │
├─────────────────────────────────────────────────────────────────┤
│ 前端 (浏览器) │
│ │ │
│ │ xhr/fetch ← JS 主动发起 HTTP 请求(Web 2.0 核心) │
│ ▼ │
│ JSON 字符串 '{"id":1,"title":"过四六级"}' │
│ │ │
│ │ JSON.parse(str) ← 反序列化:JSON 字符串 → JS 对象 │
│ ▼ │
│ JS 对象 {id:1, title:'过四六级'} │
│ │ │
│ │ DOM 操作(innerHTML) │
│ ▼ │
│ 页面渲染 <li>过四六级</li> │
│ │
│ 异步模式进化:Callback → Promise.then → async/await │
│ 全靠 Event Loop 在背后调度 │
└─────────────────────────────────────────────────────────────────┘
七、知识速查表
数据序列化
| 函数 | 方向 | 输入 | 输出 |
|---|---|---|---|
JSON.stringify() |
对象 → 字符串 | JS 对象 | JSON 字符串 |
JSON.parse() |
字符串 → 对象 | JSON 字符串 | JS 对象 |
模块化
| CommonJS | ESM | |
|---|---|---|
| 语法 | require / module.exports |
import / export |
| 代表 | Node.js 原生 | 现代浏览器、Vite/Webpack |
| 加载 | 运行时 | 编译时 |
异步进化
| Callback | Promise | async/await | |
|---|---|---|---|
| 可读性 | ⭐ | ⭐⭐ | ⭐⭐⭐ |
| 错误处理 | 各自为战 | .catch() 统一 | try/catch |
| 嵌套 | 回调地狱 | 链式平铺 | 完全拍平 |
HTTP 关键头
| 响应头 | 作用 |
|---|---|
Content-Type |
告诉浏览器返回数据的格式和编码 |
Access-Control-Allow-Origin |
允许跨域访问(* = 任何人都行) |
写在最后
回到开头那几个问题,现在你应该能答出来了:
res.json()为什么返回 Promise? → 因为解析 JSON 是异步操作,返回 Promise 才能链式.then()- 回调地狱是什么?async/await 解决了什么? → 回调层层嵌套 → async/await 把异步写成同步的样子
- JSON.stringify 和 JSON.parse 分别干嘛? → stringify 序列化(对象→字符串),parse 反序列化(字符串→对象)
- 跨域为什么后端要加头? → 浏览器的同源策略会拦截跨域请求,后端加
Access-Control-Allow-Origin明确放行
这些知识看起来"基础",但它们是前端开发每天都碰到的底层逻辑。搞懂了,看任何 HTTP 请求库(axios、fetch、ofetch...)都是一样的套路。
🌟 最后叮嘱 :如果你是初学者,强烈建议亲手把这个 demo 敲一遍。从
node backend/index.js到打开 HTML 看到数据,那种"通了"的感觉,比看十篇文章都管用。
觉得有帮助的话,欢迎点赞、收藏、评论交流! 🎉