
前言
前后端分离的时代,Ajax 是前端工程师每天都要打交道的技术。但很多同学对 Ajax 的理解停留在"调个接口、拿个数据"的表面,对底层发生了什么一知半解。
这篇文章用一个最小的 Node.js 后端 + 原生前端 项目,把 Ajax 全链路串起来讲清楚------从服务端怎么响应请求,到客户端怎么发请求,再到 JS 异步编程的三种写法,以及 JSON 序列化的细节,全部覆盖。
读完你会对"前端请求一个接口"这件事,有从里到外的完整理解。
一、先跑起来:项目全貌

项目结构极其简单,两个文件:
bash
ajax/
├── backend/
│ ├── index.js # Node.js 后端服务
│ └── package.json
└── frontend/
└── index.html # 前端页面
后端 :用 Node.js 内置的 http 模块起一个服务,暴露 /todos 接口,返回一组待办事项的 JSON 数据。
前端:用原生 XMLHttpRequest 发异步请求拿数据,动态渲染到页面上。
我们先看后端代码。
二、后端:用 Node.js 原生 http 模块提供 API
bash
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");
}
if (req.url === '/todos') {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(todos));
}
}).listen(3000, () => {
console.log(`server is running on 3000 port`);
})
2.1 require vs import------JS 模块化的一小段历史
你可能会好奇为什么用 require 而不是 import。这背后是 JS 模块化演进的一个缩影:
- 早期前端没有模块化 :所有代码写在全局作用域,靠
<script>标签引入,变量冲突是家常便饭。 - Node.js 带来了 CommonJS :
require+module.exports,让 JS 第一次有了正经的模块系统。 - ESM(ES Modules)是升级版 :
import+export default,静态分析更友好,支持 tree-shaking,现在是标准写法。
这里用 require 是因为 package.json 里指定了 "type": "commonjs",保持早期 Node 项目的风格。实际生产中更推荐 ESM。
2.2 http.createServer 做了什么
http 是 Node.js 内置模块,不需要安装。createServer 接收一个回调函数,每个请求到来时都会调用这个函数。
req(IncomingMessage):包含请求信息------URL、请求头、请求方法等。res(ServerResponse):用来构造响应------设置响应头、写入响应体、结束响应。
2.3 关键的响应头设置

bash
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Content-Type', 'application/json; charset=utf-8');
-
Access-Control-Allow-Origin: *:经典 CORS 头。浏览器默认禁止跨域请求,加上这个头告诉浏览器"这个接口允许任何来源的请求"。没有它,前端页面在本地打开时无法调通接口。 -
Content-Type: application/json; charset=utf-8:告诉客户端"我返回的是 JSON 格式,用 UTF-8 编码"。这样客户端才能正确解析。
2.4 JSON.stringify------对象到字符串的桥梁
这是本文第一个重点。网络传输只能传二进制/文本,不能传 JS 对象。JSON.stringify 负责把 JS 对象序列化为 JSON 字符串。
bash
res.end(JSON.stringify(todos));
它的完整签名是:
bash
JSON.stringify(value, replacer?, space?)
三个参数各有讲究:
| 参数 | 作用 | 常见用法 |
|---|---|---|
value |
要序列化的对象 | 必传,没啥好说 |
replacer |
控制哪些属性参与序列化 | 传 null 原样输出;传数组可以只序列化指定 key;传函数可以自定义转换逻辑 |
space |
缩进空格数 | 传 2 或 4 让输出格式化,适合调试和团队规范要求可读性的场景 |
举个简单例子感受 replacer 和 space 的效果:
bash
const obj = { name: "张三", age: 25, password: "123456" };
// replacer 传数组------只要 name 和 age,过滤掉敏感字段
JSON.stringify(obj, ["name", "age"], 2);
// 输出:
// {
// "name": "张三",
// "age": 25
// }
// replacer 传 null,space 传 2------原样序列化 + 格式化
JSON.stringify(obj, null, 2);
// 输出:
// {
// "name": "张三",
// "age": 25,
// "password": "123456"
// }
// 不传 space------压缩输出,适合生产环境减少带宽
JSON.stringify(obj);
// 输出:{"name":"张三","age":25,"password":"123456"}
总结 :replacer 做取舍(过滤字段),传 null 原样序列化。space 给缩进让输出可读,开发调试用得多,生产环境一般不传以节省带宽。
三、前端:XMLHttpRequest 发起异步请求
后端跑在 localhost:3000,现在看前端怎么调它:
bash
<ul id="todos"></ul>
<button id="btn">按钮</button>
<script>
console.log('start');
// 注册按钮点击事件
document.getElementById('btn')
.addEventListener('click', () => {
console.log('点击按钮');
})
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:3000/todos', true);
xhr.onreadystatechange = function() {
console.log(xhr.readyState);
if (xhr.status === 200 && xhr.readyState === 4) {
const todos = JSON.parse(xhr.responseText);
document.getElementById('todos').innerHTML =
todos.map(todo => `<li>${todo.title}</li>`).join('');
}
}
xhr.send();
console.log('end')
</script>
3.1 XHR 的生命周期
XHR(XMLHttpRequest)是 Ajax 的鼻祖,整个流程分四步:
new XMLHttpRequest():实例化一个 XHR 对象,这是发起 Ajax 请求的载体。xhr.open(method, url, async):打开一个 HTTP 通道。第三个参数true表示异步请求 ------这是 Ajax 的灵魂,如果是同步(false),浏览器会卡死直到响应返回。xhr.onreadystatechange:注册回调函数,监听请求状态变化。xhr.send():真正发送请求。
3.2 readyState 的五种状态
onreadystatechange 会在请求状态变化时被触发,readyState 有 5 个值:
| readyState | 含义 | 说明 |
|---|---|---|
| 0 | UNSENT | open() 还没调用 |
| 1 | OPENED | open() 已调用 |
| 2 | HEADERS_RECEIVED | 收到响应头 |
| 3 | LOADING | 正在接收响应体 |
| 4 | DONE | 请求完成 |
bash
// 只有 status === 200 且 readyState === 4 时,
// 才表示请求成功完成,可以安全地处理响应数据
if (xhr.status === 200 && xhr.readyState === 4) {
const todos = JSON.parse(xhr.responseText);
// 渲染数据到页面
}
3.3 JSON.parse------字符串回到对象
后端用 JSON.stringify 序列化,前端用 JSON.parse 反序列化,把 JSON 字符串还原为 JS 对象。这一对方法是前后端数据交换的基石。
3.4 Fetch------XHR 的现代替代
代码里还留了一段被注释掉的 fetch 写法:
bash
fetch('http://localhost:3000/todos')
.then(res => res.json())
.then(data => {
console.log(data);
})
fetch 是 XHR 的现代化升级:
- Promise 风格,告别回调地狱
- 语法更简洁,语义更清晰
- 但需要注意:
res.json()又返回一个 Promise,需要再.then()一次才能拿到数据
XHR 和 fetch 的底层本质是一样的:JS 主动发起 HTTP 请求,拿到数据后动态更新页面。这就是 Web 2.0 时代的核心------页面不用全部刷新,数据异步加载,用户体验飞跃。
四、JS 异步编程:从 Callback 到 async/await
这是本文第二个重点,也是面试高频考点。
4.1 为什么需要异步
JS 是单线程语言,同一时间只能做一件事。如果网络请求是同步的,发请求期间整个页面会冻结,用户什么都点不了。
JS 的解决方案:遇到异步任务(网络请求、定时器、事件监听)时,不等待它完成,而是把它丢到 Event Loop 里,继续往下执行。等异步任务有了结果,再从 Event Loop 里拿出来执行对应的回调。
看这段代码的执行顺序:
bash
console.log('start'); // ① 先打印
const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://localhost:3000/todos', true);
xhr.onreadystatechange = function() {
if (xhr.status === 200 && xhr.readyState === 4) {
console.log('数据回来了'); // ③ 最后打印(等网络请求完成)
}
}
xhr.send();
console.log('end'); // ② 紧接着打印,不会等 XHR 返回
输出顺序是:start → end → 数据回来了,而不是直觉上的 start → 数据回来了 → end。这就是异步的力量。
4.2 三种异步写法的演进
JS 异步编程经历了三代演进:
第一代:回调函数(Callback)
bash
xhr.onreadystatechange = function() {
if (xhr.status === 200 && xhr.readyState === 4) {
// 在这里处理数据
}
}
最早的写法,直接把"数据到了之后做什么"写成回调函数。问题也很明显------回调地狱:层层嵌套,代码向右无限延伸,可读性灾难。
第二代:Promise + then
bash
fetch('http://localhost:3000/todos')
.then(res => res.json())
.then(data => {
console.log(data);
})
Promise 把异步任务封装 成一个对象,通过 .then() 链式调用,解决了回调地狱。代码从左向右延伸变成从上向下延伸,可读性大幅提升。
第三代:async/await(最推荐)
bash
async function loadTodos() {
const res = await fetch('http://localhost:3000/todos');
const data = await res.json();
console.log(data);
}
async/await 是最优解:
- 写法跟同步代码几乎一模一样 ,没有回调嵌套,没有
.then()链条 - 错误处理可以直接用
try/catch,而 Promise 的.catch()需要单独写 - 调试友好,断点可以像同步代码一样逐行停住
建议 :新项目一律用 async/await,老项目逐步迁移。
五、全链路串联:一张图看懂 Ajax
把以上所有知识点串起来,一次完整的 Ajax 请求经历了这些步骤:
bash
[浏览器] [Node.js 服务器]
| |
| 1. xhr.open('GET', '/todos', true) |
| 2. xhr.send() ------------ HTTP 请求 ---------------------→ |
| |
| 3. http.createServer 回调触发
| 4. 根据 req.url 匹配路由
| 5. JSON.stringify(todos)
| 6. 设置 CORS + Content-Type 头
| |
| 7. onreadystatechange 回调触发 ←--------- 响应 ------------ |
| 8. readyState 从 0 → 1 → 2 → 3 → 4 |
| 9. JSON.parse(xhr.responseText) |
| 10. 动态渲染 DOM |
| |
用户看到最新数据
整个过程的本质就一句话:JS 单线程通过 Event Loop 机制,用异步非阻塞的方式发起 HTTP 请求,拿到 JSON 数据后动态更新 DOM,实现无刷新页面更新。
六、总结
这篇文章覆盖了 Ajax 全链路的关键知识点:
| 知识点 | 核心要点 |
|---|---|
| JSON.stringify / JSON.parse | 序列化与反序列化,replacer 过滤字段,space 控制格式 |
| XHR | Ajax 鼻祖,readyState 五种状态,open() + send() + 回调 |
| Fetch | XHR 的现代化替代,Promise 风格,链式调用 |
| JS 异步编程 | 单线程 + Event Loop → Callback → Promise → async/await |
| Node.js http 模块 | createServer,req/res,CORS 头,Content-Type |
| CORS | Access-Control-Allow-Origin 解决跨域 |
从最底层的 Event Loop 到最上层的 async/await,从服务端的 JSON.stringify 到客户端的 JSON.parse,从 Node.js 的 http 模块到浏览器的 XHR------当你把这些全部串起来,"前端请求一个接口"就不再是一个黑盒操作,而是一张清晰的蓝图。