从零搞懂 AJAX:手把手带你从 XMLHttpRequest 到 fetch,彻底理解前后端数据交互
🧑💻 本文适合有一点 JavaScript 基础、想搞明白"前端怎么跟后端要数据"的同学。我们将从一个完整的实战项目出发,把 AJAX 的核心概念拆碎了讲。
前言:你有没有想过,页面是怎么"偷偷"更新的?
刷微博的时候,新消息自动出现,页面并没有整个刷新;逛淘宝的时候,往下滚动商品自动加载,地址栏也没变化------这一切"丝滑"体验的背后,都绕不开一个核心技术:AJAX。
AJAX(Asynchronous JavaScript and XML)翻译过来就是"异步的 JavaScript 和 XML"。虽然名字里带个 XML,但现在大家传的都是 JSON 了,名字却一直沿用下来。它的核心作用只有一句话:
让浏览器在不刷新页面的情况下,偷偷跟服务器要数据,然后动态更新页面。
这句话听起来简单,但背后涉及的知识点可不少。今天我们就用一个完整的实战项目,把 AJAX 从头到尾拆解一遍。
一、项目结构总览
先看看我们要用的项目长什么样:
bash
ajax/
├── readme.md # 学习笔记
├── backend/
│ ├── package.json # Node.js 项目配置
│ └── index.js # 后端服务代码
└── frontend/
└── index.html # 前端页面代码
非常清晰的前后端分离结构:
backend/里是一个 Node.js 写的简易 HTTP 服务器,提供数据接口。frontend/里是一个 HTML 页面,负责发请求、拿数据、渲染页面。
接下来我们逐个拆解。
二、后端:用 Node.js 搭一个最简单的 API 服务
先看 backend/index.js 的完整代码:
javascript
const http = require('http')
http.createServer((req, res) => {
const todos = [{
id: 1,
title: '过四六级',
completed: false
}, {
id: 2,
title: '回家过节',
completed: false
}]
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Content-Type', 'application/json; charset=utf-8')
if (req.url === '/') {
res.end("hello world")
}
if (req.url === '/todos') {
res.end(JSON.stringify(todos))
}
}).listen(5000, () => {
console.log('server is running at http://localhost:5000')
})
代码不多,但信息量很大,我们一行一行看。
2.1 require('http') ------ Node.js 的模块引入
javascript
const http = require('http')
这行用的是 CommonJS 模块规范。在 Node.js 的世界里,require() 就像"搬运工",把别人写好的模块搬过来用。http 是 Node.js 内置的模块,不需要安装,直接就能用。
💡 小知识:JS 模块的演进
早期 JavaScript 没有模块系统,所有变量都挂在全局上,项目大了就乱成一锅粥。后来社区搞出了 CommonJS(
require+module.exports),专门给 Node.js 用。再后来浏览器端也想要模块,就出现了 ESM(import+export default)。现在前端项目基本都用 ESM 了,但 Node.js 里两种都支持。
2.2 createServer ------ 创建一个 HTTP 服务器
javascript
http.createServer((req, res) => {
// 处理请求的逻辑...
}).listen(5000, () => {
console.log('server is running at http://localhost:5000')
})
createServer 创建了一个 HTTP 服务,它接收一个回调函数,每次有请求进来时就会被调用。回调里有两个参数:
req(request):请求对象,包含客户端发来的所有信息,比如req.url就是请求的路径。res(response):响应对象,用来给客户端回数据。
.listen(5000) 表示服务跑在 5000 端口上。
2.3 响应头设置 ------ CORS 和 Content-Type
javascript
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Content-Type', 'application/json; charset=utf-8')
这两行非常关键:
Access-Control-Allow-Origin: * ------ 这是 CORS(跨域资源共享)的核心设置。浏览器有一个安全策略叫"同源策略",默认不允许前端页面(比如跑在 localhost:3000)去请求不同源的服务器(比如 localhost:5000)。加上这个头,就是告诉浏览器:"我这个服务器允许任何人来要数据,别拦着。"
⚠️ 注意: 生产环境千万不要写
*,要指定具体的域名,不然就等于把接口向全世界公开了。
Content-Type: application/json; charset=utf-8 ------ 告诉浏览器:"我返回的是 JSON 格式的数据,编码是 UTF-8。"这样浏览器就知道怎么解析了。
2.4 路由处理
javascript
if (req.url === '/') {
res.end("hello world")
}
if (req.url === '/todos') {
res.end(JSON.stringify(todos))
}
这里用最朴素的 if 判断来实现路由:
- 访问
/,返回纯文本"hello world"。 - 访问
/todos,返回 JSON 字符串。
注意 JSON.stringify(todos) 这一步。todos 是一个 JavaScript 数组,网络传输只能传字符串,所以必须用 JSON.stringify() 把它序列化成 JSON 字符串。
三、JSON.stringify() 详解:不只是"转字符串"那么简单
说到 JSON.stringify(),很多人觉得不就是把对象变成字符串嘛,有什么好讲的?还真有不少细节值得了解。
它的完整签名是:
javascript
JSON.stringify(value, replacer?, space?)
三个参数:
| 参数 | 作用 | 示例 |
|---|---|---|
value |
要序列化的值 | {name: 'Tom'} |
replacer |
控制哪些属性参与序列化 | ['name'] 只序列化 name |
space |
控制缩进格式 | 2 表示缩进 2 个空格 |
replacer 参数
replacer 可以是一个数组,也可以是一个函数:
javascript
const obj = { name: 'Tom', age: 18, password: '123456' }
// 数组形式:只保留指定的 key
JSON.stringify(obj, ['name', 'age'])
// '{"name":"Tom","age":18}'
// 函数形式:可以做更精细的控制
JSON.stringify(obj, (key, value) => {
if (key === 'password') return undefined // 过滤敏感字段
return value
})
// '{"name":"Tom","age":18}'
在实际项目中,这个参数常用来过滤敏感信息(比如密码、token),或者精简传输数据的大小。
space 参数
javascript
const obj = { name: 'Tom', age: 18 }
// 不格式化
JSON.stringify(obj)
// '{"name":"Tom","age":18}'
// 缩进 2 个空格
JSON.stringify(obj, null, 2)
// {
// "name": "Tom",
// "age": 18
// }
开发调试的时候,用 space 参数让输出可读性更好;生产环境就不用了,省带宽。
四、前端:XMLHttpRequest ------ AJAX 的"老祖宗"
终于到了重头戏。来看 frontend/index.html 中的核心代码:
javascript
const xhr = new XMLHttpRequest()
xhr.open('GET', 'http://localhost:5000/todos', true)
xhr.onreadystatechange = function() {
console.log(xhr.readyState)
if (xhr.status === 200 && xhr.readyState === 4) {
const todos = JSON.parse(xhr.responseText)
console.log(todos)
Todos.innerHTML = todos.map(t => `<li>${t.title}</li>`).join('')
}
}
xhr.send()
4.1 创建请求对象
javascript
const xhr = new XMLHttpRequest()
XMLHttpRequest(简称 XHR)是浏览器内置的对象,专门用来发 HTTP 请求。它是 AJAX 技术的核心。虽然现在有了更现代的 fetch,但理解 XHR 仍然非常重要,因为很多老项目还在用,而且面试经常考。
4.2 配置请求
javascript
xhr.open('GET', 'http://localhost:5000/todos', true)
open() 方法用来配置请求,三个参数:
- 请求方法:
'GET'------ 表示获取数据。常见的还有POST(提交数据)、PUT(更新数据)、DELETE(删除数据)。 - 请求地址:
'http://localhost:5000/todos'------ 完整的 URL。 - 是否异步:
true------ 这个参数太重要了!设为true表示异步请求,JavaScript 不会傻等服务器响应,而是继续往下执行。设为false就是同步请求,页面会卡住直到拿到数据,用户体验极差。
4.3 监听状态变化
javascript
xhr.onreadystatechange = function() {
if (xhr.status === 200 && xhr.readyState === 4) {
// 处理响应数据
}
}
这里是 XHR 的核心机制------事件监听 。onreadystatechange 会在请求状态每次变化时触发。我们需要关注两个属性:
-
readyState: 请求进行到哪一步了。它有 5 个状态:0--- UNSENT:请求还没调用open()1--- OPENED:open()已调用2--- HEADERS_RECEIVED:服务器已经收到请求,响应头也回来了3--- LOADING:正在接收响应体4--- DONE:请求完成,数据全部收到 ✅
-
status: HTTP 状态码。200表示成功,404表示找不到,500表示服务器错误。
所以我们必须同时判断 readyState === 4(请求完成)和 status === 200(请求成功),才能安全地使用数据。
4.4 发送请求和数据解析
javascript
xhr.send()
send() 就是一脚油门,把请求发出去。如果是 POST 请求,可以在 send() 里传入请求体数据。
拿到响应后:
javascript
const todos = JSON.parse(xhr.responseText)
服务器返回的是 JSON 字符串,我们需要用 JSON.parse() 把它解析回 JavaScript 对象。这跟后端的 JSON.stringify() 是一对儿------序列化和反序列化。
4.5 DOM 渲染
javascript
Todos.innerHTML = todos.map(t => `<li>${t.title}</li>`).join('')
这行是前端的"点睛之笔":
todos.map(t => '<li>' + t.title + '</li>')------ 把每个 todo 对象转换成 HTML 字符串。.join('')------ 把数组拼成一个完整的 HTML 字符串。Todos.innerHTML = ...------ 把 HTML 字符串塞进<ul>元素,页面就渲染出来了。
这就是 AJAX 的完整闭环:发请求 → 拿数据 → 解析 → 渲染页面。整个过程不需要刷新页面。
五、同步 vs 异步:用 console.log 体会"时间线"
在 index.html 中,有几行看似"多余"的 console.log:
javascript
console.log('start')
// ... 中间的 XHR 代码 ...
console.log('end')
如果你打开浏览器控制台,会看到这样的输出顺序:
sql
start
end
1 ← readyState 变为 1(OPENED)
2 ← readyState 变为 2(HEADERS_RECEIVED)
3 ← readyState 变为 3(LOADING)
4 ← readyState 变为 4(DONE)
[{...}] ← 解析后的数据
注意!end 在数据回来之前就打印了! 这就是异步的精髓:
JavaScript 是单线程的,遇到异步操作(比如网络请求)时,不会傻等,而是把回调函数扔到"任务队列"里,继续执行后面的代码。等异步操作完成、主线程空闲了,再把回调函数拉出来执行。
这就好比你去餐厅点了碗面,不会站在厨房门口等------你先去找座位、倒水、玩手机,面做好了服务员会叫你。
5.1 异步的三种写法
在 JavaScript 中,处理异步操作有三种方式,也是不断"进化"的过程:
① 回调函数(Callback)------ 最原始的方式
javascript
setTimeout(function() {
console.log('1秒后执行')
}, 1000)
简单直接,但如果异步操作多了,就会出现著名的"回调地狱"(Callback Hell):
javascript
getData(function(a) {
getMoreData(a, function(b) {
getEvenMoreData(b, function(c) {
// 嵌套到你怀疑人生...
})
})
})
② Promise + .then() ------ 链式调用
javascript
fetch("http://localhost:5000/todos")
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.log(err))
Promise 把异步操作包装成一个"承诺",通过 .then() 链式调用,比回调地狱好看多了。
③ async/await ------ 语法糖,推荐用法
javascript
async function getTodos() {
try {
const res = await fetch("http://localhost:5000/todos")
const data = await res.json()
console.log(data)
} catch (err) {
console.log(err)
}
}
async/await 是 Promise 的语法糖,让异步代码看起来像同步代码一样直观。这是目前最推荐的写法。
六、fetch:XMLHttpRequest 的"现代替代品"
在 index.html 中,还有一段被注释掉的代码:
javascript
// fetch("http://localhost:5000/todos")
// .then(res => res.json())
// .then(data => { console.log(data) })
// .catch(err => console.log(err))
fetch 是浏览器提供的现代 HTTP 请求 API,用来替代 XMLHttpRequest。对比一下:
XMLHttpRequest 版本
javascript
const xhr = new XMLHttpRequest()
xhr.open('GET', 'http://localhost:5000/todos', true)
xhr.onreadystatechange = function() {
if (xhr.status === 200 && xhr.readyState === 4) {
const todos = JSON.parse(xhr.responseText)
console.log(todos)
}
}
xhr.send()
fetch 版本
javascript
fetch("http://localhost:5000/todos")
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.log(err))
差距一目了然!fetch 的优势:
| 对比项 | XMLHttpRequest | fetch |
|---|---|---|
| 语法 | 繁琐,需要手动判断 readyState | 简洁,基于 Promise |
| 返回值 | 无,需要通过事件回调获取 | 返回 Promise 对象 |
| JSON 解析 | 手动 JSON.parse() |
内置 .json() 方法 |
| 错误处理 | 需要额外判断 status | .catch() 统一捕获 |
| 浏览器支持 | 所有浏览器 | 现代浏览器(IE 不支持) |
不过要注意,fetch 默认不会 reject HTTP 错误状态(比如 404、500),只有网络错误才会触发 .catch()。所以有时候需要手动判断:
javascript
fetch(url)
.then(res => {
if (!res.ok) {
throw new Error(`HTTP Error: ${res.status}`)
}
return res.json()
})
.then(data => console.log(data))
.catch(err => console.error(err))
七、CORS 跨域:为什么需要 Access-Control-Allow-Origin?
回到后端代码中的这一行:
javascript
res.setHeader('Access-Control-Allow-Origin', '*')
如果不加这行会怎样?前端会看到这样的报错:
csharp
Access to XMLHttpRequest at 'http://localhost:5000/todos'
from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
这就是 CORS(跨域资源共享) 问题。
什么是"同源策略"?
浏览器出于安全考虑,限制了一个源(origin)的脚本只能访问同源的资源。所谓"同源",就是 协议 + 域名 + 端口 三者完全一致:
| 前端地址 | 后端地址 | 是否同源 |
|---|---|---|
http://localhost:3000 |
http://localhost:3000/api |
✅ 同源 |
http://localhost:3000 |
http://localhost:5000/api |
❌ 不同源(端口不同) |
http://localhost:3000 |
https://localhost:3000/api |
❌ 不同源(协议不同) |
http://localhost:3000 |
http://api.example.com |
❌ 不同源(域名不同) |
CORS 的工作原理
当前端发跨域请求时,浏览器会自动在请求头里加上 Origin 字段,告诉服务器"我来自哪里"。服务器收到后,如果在响应头里返回了 Access-Control-Allow-Origin,且值包含了前端的源,浏览器就放行;否则就拦截。
对于简单请求(GET、POST 等),浏览器直接发出请求,只是在响应时检查 CORS 头。
对于"预检请求"(比如 PUT、DELETE,或带自定义头的请求),浏览器会先发一个 OPTIONS 请求问服务器:"我能不能这样请求?"服务器同意了,浏览器才发真正的请求。
八、Node.js 模块系统速览
在 package.json 中有一行:
json
"type": "commonjs"
这指定了项目使用 CommonJS 模块系统。Node.js 目前支持两种模块规范:
CommonJS(CJS)
javascript
// 导出
module.exports = { foo, bar }
// 导入
const { foo, bar } = require('./module')
require()是同步加载的- 导出的是值的拷贝
- 是 Node.js 的传统模块系统
ES Module(ESM)
javascript
// 导出
export default function foo() {}
export const bar = 123
// 导入
import foo, { bar } from './module.js'
import是异步加载的- 导出的是值的引用(可以 tree-shaking)
- 是 JavaScript 语言标准
现在的新项目基本都用 ESM 了,但理解 CommonJS 仍然重要,因为海量的 npm 包还是 CJS 格式。
九、完整流程回顾
让我们用一张图来串联整个 AJAX 的流程:
scss
┌─────────────────────────────────────────────────────┐
│ 浏览器 (frontend/index.html) │
│ │
│ 1. 用户打开页面 │
│ 2. JavaScript 创建 XMLHttpRequest │
│ 3. xhr.open('GET', 'http://localhost:5000/todos') │
│ 4. xhr.send() 发出请求 │
│ 5. JS 继续执行,不阻塞(打印 'end') │
│ │
│ ↓ HTTP 请求 ↓ │
│ │
├─────────────────────────────────────────────────────┤
│ 服务器 (backend/index.js) │
│ │
│ 6. 收到请求,匹配 /todos 路由 │
│ 7. 设置 CORS 头和 Content-Type │
│ 8. JSON.stringify(todos) 序列化数据 │
│ 9. res.end() 返回 JSON 字符串 │
│ │
│ ↓ HTTP 响应 ↓ │
│ │
├─────────────────────────────────────────────────────┤
│ 浏览器 (继续) │
│ │
│ 10. onreadystatechange 触发多次 │
│ 11. readyState 变为 4,status 为 200 │
│ 12. JSON.parse(xhr.responseText) 反序列化 │
│ 13. todos.map() 生成 HTML │
│ 14. innerHTML 渲染到页面 │
│ │
│ ✅ 用户看到 todo 列表,全程无需刷新页面 │
└─────────────────────────────────────────────────────┘
十、实战踩坑指南
在实际开发中,你大概率会遇到这些问题:
坑 1:中文乱码
后端必须设置 charset=utf-8:
javascript
res.setHeader('Content-Type', 'application/json; charset=utf-8')
不加这个,中文可能会变成乱码。
坑 2:请求发出去了,但数据没拿到
检查清单:
- 后端服务启动了吗?(
node index.js) - 端口对不对?(5000)
- 路径对不对?(
/todos不是/todo) - CORS 头加了吗?
坑 3:readyState 判断不完整
javascript
// ❌ 错误:只判断 status
if (xhr.status === 200) {
// 此时 readyState 可能还不是 4,数据还没接收完
}
// ✅ 正确:两个都判断
if (xhr.status === 200 && xhr.readyState === 4) {
// 数据完整且请求成功
}
坑 4:fetch 不会自动 reject HTTP 错误
javascript
// 即使服务器返回 404 或 500,也不会进 catch
fetch(url).catch(err => console.log(err)) // 只有网络错误才会触发
// 需要手动判断
fetch(url)
.then(res => {
if (!res.ok) throw new Error(res.status)
return res.json()
})
.catch(err => console.log(err))
十一、进阶方向
掌握了本文的内容后,你可以继续探索:
- axios ------ 一个基于 Promise 的 HTTP 客户端库,封装了 XHR,支持拦截器、取消请求、自动转换 JSON 等功能,是目前前端项目中最流行的网络请求库。
- 请求拦截与响应拦截 ------ 在请求发出前和响应到达后统一处理逻辑(比如带上 token、统一错误处理)。
- AbortController ------ 取消正在进行的 fetch 请求,避免重复请求和内存泄漏。
- WebSocket ------ 当你需要服务器主动推送数据时(比如聊天室、实时数据看板),AJAX 就不够用了,WebSocket 才是正解。
总结
回顾一下本文的核心知识点:
| 知识点 | 要点 |
|---|---|
| AJAX | 不刷新页面的情况下与服务器通信 |
| XMLHttpRequest | 老牌 API,通过 readyState + status 判断请求状态 |
| fetch | 现代 API,基于 Promise,语法更简洁 |
| JSON.stringify / parse | 序列化和反序列化,前后端数据交换的桥梁 |
| CORS | 跨域资源共享,通过响应头控制跨域访问权限 |
| 异步 | JS 单线程,遇到异步不等待,回调在主线程空闲时执行 |
| async/await | 最推荐的异步写法,代码可读性最好 |
AJAX 是前端开发的基石之一。从 jQuery 时代的 $.ajax(),到原生的 XMLHttpRequest,再到现代的 fetch 和 axios,工具在变,但核心原理没变:发请求、拿数据、更新页面。
把这个项目跑起来,打开控制台,观察 readyState 的变化、console.log 的打印顺序,你就能真正理解"异步"到底是怎么回事。
📌 本文项目源码结构:
- 后端:
node backend/index.js启动服务(端口 5000)- 前端:直接用浏览器打开
frontend/index.html- 确保后端先启动,再打开前端页面
如果觉得有帮助,欢迎点赞收藏 👍,有问题评论区见!