Ajax 异步编程全攻略:从 XHR 到 async/await

从零搞懂 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";
  • CommonJSrequire + module.exports,同步加载,服务端友好
  • ESMimport + 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 优先级更高
└─────────────────────────────┘

执行流程:

  1. 同步代码直接进入调用栈执行
  2. 遇到异步任务(如 setTimeoutfetchxhr.send()),交给 Web APIs 处理,主线程继续往下走
  3. 异步任务完成后,其回调函数被推入任务队列 (宏任务)或微任务队列
  4. 当调用栈清空后,Event Loop 先清空微任务队列 ,再取一个宏任务执行
  5. 周而复始

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=TypeContentTypecontent-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 异步编程思想的演进。理解它们,你才能真正驾驭前端开发中无处不在的网络请求。

相关推荐
spmcor1 小时前
JavaScript 日期限制的“三个月陷阱”:从边界溢出到稳健实现
javascript
橘子星2 小时前
深入理解 AJAX 中的 JSON 序列化与 JS 异步处理
前端·javascript·后端
夏幻灵2 小时前
深度解析 JavaScript 异步编程:从回调地狱到 Promise 的重构
开发语言·javascript·重构
Cobyte2 小时前
20.Vue Vapor 的应用初始化
前端·javascript·vue.js
HYCS2 小时前
用pixi.js实现fabric.js(七):框选、ActiveObject和控制点
前端·javascript·canvas
云浪2 小时前
手把手教你用 fetch 读取 SSE 流,给 AI 聊天加上打字机效果
前端·javascript·vue.js
DJ斯特拉3 小时前
Tlias智能学习辅助系统(前端部分)
前端·javascript·学习
武清伯MVP13 小时前
前端跨域方案大合集
前端·javascript