从零搞懂 Ajax:XHR、Fetch、Promise 与 async/await 的前世今生
前言
Ajax(Asynchronous JavaScript And XML)是 Web 前端开发中绕不开的核心技术。它让网页能够在不刷新整个页面的情况下,悄悄向服务器请求数据并动态更新页面------这正是 Web 2.0 时代繁荣的基石。
本文将通过一个完整的 Node.js 后端 + 原生前端 的 Demo,带你从最原始的 XHR 一路走到现代的 async/await,彻底搞懂 Ajax 及其背后的异步编程思想。
文中所涉及的完整代码均可在本地运行,建议边看边敲。
一、项目结构一览
bash
ajax/
├── backend/
│ ├── index.js # Node.js 后端服务
│ └── package.json # 后端配置
├── frontend/
│ └── index.html # 前端页面(Ajax 核心演示)
└── readme.md
麻雀虽小,五脏俱全。让我们先从后端开始。
二、后端:用 Node.js 搭建一个 RESTful API 服务 ⚙️
2.1 模块化往事:CommonJS vs ESM
在 Node.js 早期,JavaScript 语言本身并没有原生的模块化系统,前端只能靠 <script> 标签按顺序加载文件。Node.js 率先引入了 CommonJS 规范:
js
// CommonJS(Node.js 传统写法)
const http = require("http");
// ESM(现代标准,Node.js 13.2+ 也支持)
import http from "node:http";
- CommonJS :
require+module.exports,同步加载,服务端友好 - ESM :
import+export default,静态分析友好,支持 Tree Shaking
目前 Node.js 生态中两者共存,但 ESM 是未来趋势。
2.2 创建一个简单的 HTTP 服务
js
const http = require("http");
http.createServer((req, res) => {
// 模拟数据库中的待办事项数据
const todos = [
{ id: "1", title: "sleep", completed: false },
{ id: "2", title: "eat", completed: true }
];
// 根路径:返回纯文本
if (req.url === "/") {
res.end("hello world");
}
// /todos 路径:返回 JSON 数据
if (req.url === "/todos") {
// ① 设置 CORS 头,允许任意来源访问(解决跨域问题)
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, null, 2));
}
}).listen(3000, () => {
console.log("server is running on http://localhost:3000");
});
关键点解读:
| 代码 | 作用 |
|---|---|
Access-Control-Allow-Origin: * |
跨域资源共享(CORS),允许浏览器端跨域请求 |
Content-Type: application/json |
告知客户端返回的是 JSON 而非普通文本 |
JSON.stringify(todos, null, 2) |
将对象转为 JSON 字符串,第三个参数 2 表示缩进 2 个空格,便于阅读 |
2.3 JSON.stringify 参数详解
JSON.stringify(value, replacer?, space?) 是前后端通信中最常用的方法之一:
js
const obj = { name: "Alice", password: "123456", age: 25 };
// 1. 只传 value:全部序列化
JSON.stringify(obj);
// → '{"name":"Alice","password":"123456","age":25}'
// 2. replacer 为数组:只序列化指定 key(白名单,常用于脱敏)
JSON.stringify(obj, ["name", "age"]);
// → '{"name":"Alice","age":25}'
// 3. replacer 为 null:原样序列化所有属性
JSON.stringify(obj, null);
// → '{"name":"Alice","password":"123456","age":25}'
// 4. replacer 为函数:自定义转换逻辑
JSON.stringify(obj, (key, value) => {
if (key === "password") return undefined; // 返回 undefined 会删除该属性
return value;
});
// → '{"name":"Alice","age":25}'
// 5. space:控制缩进,提升可读性(常用于日志/调试)
JSON.stringify(obj, null, 2);
// 输出带 2 空格缩进的格式化 JSON
三、前端:Ajax 的三种实现方式
3.1 最经典的 XMLHttpRequest(XHR)
XMLHttpRequest 是 Ajax 的鼻祖,虽然名字里带 XML,但实际可以处理任意格式的数据。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ajax Demo</title>
</head>
<body>
<ul id="todos"></ul>
<button id="btn">点我</button>
<script>
console.log("start"); // ① 最先输出
// 事件注册:点击按钮时触发
document.getElementById("btn").addEventListener("click", () => {
console.log("点击按钮");
});
// ---- Ajax 核心逻辑 ----
const xhr = new XMLHttpRequest(); // ② 创建 XHR 实例
xhr.open("GET", "http://localhost:3000/todos", true); // ③ 配置请求(异步)
// ④ 注册回调:当 readyState 变化时触发
xhr.onreadystatechange = function () {
console.log("readyState:", xhr.readyState);
// readyState === 4 表示请求完成
// status === 200 表示 HTTP 响应成功
if (xhr.readyState === 4 && xhr.status === 200) {
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>
</html>
控制台输出顺序(关键!):
vbnet
start → 同步代码,立即执行
end → 同步代码,立即执行(此时请求还在路上)
readyState: 2 → 异步回调,请求已发送
readyState: 3 → 异步回调,正在接收数据
readyState: 4 → 异步回调,请求完成
XHR readyState 状态码全解
| readyState | 含义 | 说明 |
|---|---|---|
0 |
UNSENT | open() 尚未调用 |
1 |
OPENED | open() 已调用 |
2 |
HEADERS_RECEIVED | 已收到响应头 |
3 |
LOADING | 正在接收响应体 |
4 |
DONE | 请求完成(无论成功或失败) |
重要细节 :
xhr.send()必须放在onreadystatechange注册 之后 。因为send()一旦调用,网络请求就开始了------如果回调还没注册,当readyState快速变化时,你可能错过中间状态。原代码中的注释正确地指出了这一点。
3.2 更现代的 Fetch API
Fetch 是 XHR 的现代化替代方案,基于 Promise,语法更简洁:
js
// Fetch 写法(推荐)
fetch("http://localhost:3000/todos")
.then(res => res.json()) // 解析 JSON 响应体(也是异步的)
.then(data => {
console.log(data);
document.getElementById("todos").innerHTML = data
.map(todo => `<li>${todo.title}</li>`)
.join("");
})
.catch(err => console.error("请求失败:", err));
Fetch vs XHR 对比:
| 特性 | XHR | Fetch |
|---|---|---|
| 语法风格 | 回调(callback) | Promise(链式调用) |
| 错误处理 | onerror / 手动判断 status |
.catch() 统一捕获 |
| 请求/响应流 | 不支持 | 支持 ReadableStream |
| 超时设置 | xhr.timeout |
需配合 AbortController |
| Cookie(同源) | 默认携带 | 默认携带 |
| Cookie(跨域) | xhr.withCredentials = true |
credentials: 'include' |
| 浏览器兼容 | 所有浏览器 | IE 不支持(需 polyfill) |
3.3 终极方案:async/await
async/await 是 Promise 的语法糖,让异步代码看起来像同步代码,可读性大幅提升:
js
// async/await 写法------和同步代码一样直观!
async function loadTodos() {
try {
const res = await fetch("http://localhost:3000/todos");
const todos = await res.json();
document.getElementById("todos").innerHTML = todos
.map(todo => `<li>${todo.title}</li>`)
.join("");
} catch (err) {
console.error("请求失败:", err);
}
}
loadTodos();
四、JS 异步编程的底层逻辑
4.1 JavaScript 是单线程的
JavaScript 的设计初衷是操作 DOM,如果多线程同时操作同一个 DOM 节点,会产生不可预料的竞态问题。因此,JS 选择了单线程模型------同一时间只能做一件事。
4.2 Event Loop(事件循环)------ JS 的异步心脏
单线程如何实现异步?答案是 Event Loop:
scss
┌─────────────────────────────┐
│ 调用栈 (Call Stack) │ ← 同步代码在这里执行
├─────────────────────────────┤
│ Web APIs (浏览器提供) │ ← setTimeout/fetch/DOM事件等
├─────────────────────────────┤
│ 任务队列 (Task Queue) │ ← 回调函数在这里排队
├─────────────────────────────┤
│ 微任务队列 (Microtask Queue) │ ← Promise.then 优先级更高
└─────────────────────────────┘
执行流程:
- 同步代码直接进入调用栈执行
- 遇到异步任务(如
setTimeout、fetch、xhr.send()),交给 Web APIs 处理,主线程继续往下走 - 异步任务完成后,其回调函数被推入任务队列 (宏任务)或微任务队列
- 当调用栈清空后,Event Loop 先清空微任务队列 ,再取一个宏任务执行
- 周而复始
4.3 三种异步处理方式对比
js
// 方式一:回调函数(Callback)------ "回调地狱"
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
// 如果这里还要发下一个请求,嵌套就开始了...
}
};
// 方式二:Promise + .then() ------ 链式调用
fetch("/api/a")
.then(res => fetch("/api/b"))
.then(res => fetch("/api/c"))
.catch(err => console.error(err));
// 方式三:async/await ------ 最推荐
async function loadAll() {
const a = await fetch("/api/a");
const b = await fetch("/api/b");
const c = await fetch("/api/c");
}
推荐优先级:async/await > Promise.then() > 回调函数
五、运行这个 Demo
步骤 1:启动后端
bash
cd backend
node index.js
# 输出:server is running on http://localhost:3000
步骤 2:打开前端
直接用浏览器打开 frontend/index.html 即可(或使用 Live Server 等工具)。
你会看到页面上动态渲染出:
- sleep
- eat
打开控制台,观察 start → end → readyState: 2,3,4 的输出顺序,亲身感受 Event Loop 的魔力!
六、常见踩坑与最佳实践
❌ 坑 1:忘记设置 CORS 头
csharp
Access to XMLHttpRequest at '...' from origin 'null' has been blocked by CORS policy
解决 :后端必须设置 Access-Control-Allow-Origin 响应头,或使用代理。
❌ 坑 2:Content-Type 写错
错误写法:Content=Type、ContentType、content-type(虽然 HTTP 头不区分大小写,但连字符不能省略)
正确 :Content-Type
❌ 坑 3:xhr.send() 放在回调注册之前
js
// ❌ 错误顺序
xhr.send(); // 先发送
xhr.onreadystatechange = fn; // 后注册回调 → 可能漏掉状态变化!
// ✅ 正确顺序
xhr.onreadystatechange = fn; // 先注册回调
xhr.send(); // 再发送
❌ 坑 4:用 innerHTML 直接拼接用户数据
js
// ❌ 有 XSS 风险------如果 title 包含 <script>alert(1)</script> 就中招了
el.innerHTML = todos.map(t => `<li>${t.title}</li>`).join("");
// ✅ 方式一:使用 createElement + textContent(推荐,无需拼接 HTML)
todos.forEach(t => {
const li = document.createElement("li");
li.textContent = t.title; // textContent 自动转义,安全
el.appendChild(li);
});
// ✅ 方式二:转义后再用 innerHTML
function escapeHtml(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
el.innerHTML = todos.map(t => `<li>${escapeHtml(t.title)}</li>`).join("");
在 Demo 中数据是静态的所以没问题,但生产环境中务必对用户输入做转义处理。
总结 🎓
| 概念 | 一句话总结 |
|---|---|
| Ajax | 不刷新页面,异步请求数据并更新 DOM |
| XHR | 最原始的 Ajax 实现,基于回调 |
| Fetch | 现代化的 Ajax API,基于 Promise |
| async/await | Promise 的语法糖,代码像同步一样清晰 |
| Event Loop | JS 异步的底层机制:同步 → 微任务 → 宏任务 |
| JSON.stringify | 对象→JSON 字符串,参数 (value, replacer, space) |
| CORS | 跨域资源共享,后端设置 Access-Control-Allow-Origin |
从 XHR 的回调地狱,到 Promise 的链式调用,再到 async/await 的同步式写法------这不仅是一段 API 的进化史,更是 JavaScript 异步编程思想的演进。理解它们,你才能真正驾驭前端开发中无处不在的网络请求。