从零搞懂 AJAX:手把手带你从 XMLHttpRequest 到 fetch,彻底理解前后端数据交互

3 阅读8分钟

从零搞懂 AJAX:手把手带你从 XMLHttpRequest 到 fetch,彻底理解前后端数据交互

🧑‍💻 本文适合有一点 JavaScript 基础、想搞明白"前端怎么跟后端要数据"的同学。我们将从一个完整的实战项目出发,把 AJAX 的核心概念拆碎了讲。


前言:你有没有想过,页面是怎么"偷偷"更新的?

刷微博的时候,新消息自动出现,页面并没有整个刷新;逛淘宝的时候,往下滚动商品自动加载,地址栏也没变化——这一切"丝滑"体验的背后,都绕不开一个核心技术:AJAX

AJAX(Asynchronous JavaScript and XML)翻译过来就是"异步的 JavaScript 和 XML"。虽然名字里带个 XML,但现在大家传的都是 JSON 了,名字却一直沿用下来。它的核心作用只有一句话:

让浏览器在不刷新页面的情况下,偷偷跟服务器要数据,然后动态更新页面。

这句话听起来简单,但背后涉及的知识点可不少。今天我们就用一个完整的实战项目,把 AJAX 从头到尾拆解一遍。


一、项目结构总览

先看看我们要用的项目长什么样:

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 的完整代码:

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 的模块引入

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 服务器

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

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 路由处理

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(),很多人觉得不就是把对象变成字符串嘛,有什么好讲的?还真有不少细节值得了解。

它的完整签名是:

JSON.stringify(value, replacer?, space?)

三个参数:

参数作用示例
value要序列化的值{name: 'Tom'}
replacer控制哪些属性参与序列化['name'] 只序列化 name
space控制缩进格式2 表示缩进 2 个空格

replacer 参数

replacer 可以是一个数组,也可以是一个函数:

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 参数

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 中的核心代码:

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 创建请求对象

const xhr = new XMLHttpRequest()

XMLHttpRequest(简称 XHR)是浏览器内置的对象,专门用来发 HTTP 请求。它是 AJAX 技术的核心。虽然现在有了更现代的 fetch,但理解 XHR 仍然非常重要,因为很多老项目还在用,而且面试经常考。

4.2 配置请求

xhr.open('GET', 'http://localhost:5000/todos', true)

open() 方法用来配置请求,三个参数:

  1. 请求方法: 'GET' —— 表示获取数据。常见的还有 POST(提交数据)、PUT(更新数据)、DELETE(删除数据)。
  2. 请求地址: 'http://localhost:5000/todos' —— 完整的 URL。
  3. 是否异步: true —— 这个参数太重要了!设为 true 表示异步请求,JavaScript 不会傻等服务器响应,而是继续往下执行。设为 false 就是同步请求,页面会卡住直到拿到数据,用户体验极差。

4.3 监听状态变化

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 发送请求和数据解析

xhr.send()

send() 就是一脚油门,把请求发出去。如果是 POST 请求,可以在 send() 里传入请求体数据。

拿到响应后:

const todos = JSON.parse(xhr.responseText)

服务器返回的是 JSON 字符串,我们需要用 JSON.parse() 把它解析回 JavaScript 对象。这跟后端的 JSON.stringify() 是一对儿——序列化和反序列化

4.5 DOM 渲染

Todos.innerHTML = todos.map(t => `<li>${t.title}</li>`).join('')

这行是前端的"点睛之笔":

  1. todos.map(t => '<li>' + t.title + '</li>') —— 把每个 todo 对象转换成 HTML 字符串。
  2. .join('') —— 把数组拼成一个完整的 HTML 字符串。
  3. Todos.innerHTML = ... —— 把 HTML 字符串塞进 <ul> 元素,页面就渲染出来了。

这就是 AJAX 的完整闭环:发请求 → 拿数据 → 解析 → 渲染页面。整个过程不需要刷新页面。


五、同步 vs 异步:用 console.log 体会"时间线"

index.html 中,有几行看似"多余"的 console.log

console.log('start')
// ... 中间的 XHR 代码 ...
console.log('end')

如果你打开浏览器控制台,会看到这样的输出顺序:

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)—— 最原始的方式

setTimeout(function() {
  console.log('1秒后执行')
}, 1000)

简单直接,但如果异步操作多了,就会出现著名的"回调地狱"(Callback Hell):

getData(function(a) {
  getMoreData(a, function(b) {
    getEvenMoreData(b, function(c) {
      // 嵌套到你怀疑人生...
    })
  })
})

② Promise + .then() —— 链式调用

fetch("http://localhost:5000/todos")
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.log(err))

Promise 把异步操作包装成一个"承诺",通过 .then() 链式调用,比回调地狱好看多了。

③ async/await —— 语法糖,推荐用法

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 中,还有一段被注释掉的代码:

// fetch("http://localhost:5000/todos")
//   .then(res => res.json())
//   .then(data => { console.log(data) })
//   .catch(err => console.log(err))

fetch 是浏览器提供的现代 HTTP 请求 API,用来替代 XMLHttpRequest。对比一下:

XMLHttpRequest 版本

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 版本

fetch("http://localhost:5000/todos")
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.log(err))

差距一目了然!fetch 的优势:

对比项XMLHttpRequestfetch
语法繁琐,需要手动判断 readyState简洁,基于 Promise
返回值无,需要通过事件回调获取返回 Promise 对象
JSON 解析手动 JSON.parse()内置 .json() 方法
错误处理需要额外判断 status.catch() 统一捕获
浏览器支持所有浏览器现代浏览器(IE 不支持)

不过要注意,fetch 默认不会 reject HTTP 错误状态(比如 404、500),只有网络错误才会触发 .catch()。所以有时候需要手动判断:

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

回到后端代码中的这一行:

res.setHeader('Access-Control-Allow-Origin', '*')

如果不加这行会怎样?前端会看到这样的报错:

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:3000http://localhost:3000/api✅ 同源
http://localhost:3000http://localhost:5000/api❌ 不同源(端口不同)
http://localhost:3000https://localhost:3000/api❌ 不同源(协议不同)
http://localhost:3000http://api.example.com❌ 不同源(域名不同)

CORS 的工作原理

当前端发跨域请求时,浏览器会自动在请求头里加上 Origin 字段,告诉服务器"我来自哪里"。服务器收到后,如果在响应头里返回了 Access-Control-Allow-Origin,且值包含了前端的源,浏览器就放行;否则就拦截。

对于简单请求(GET、POST 等),浏览器直接发出请求,只是在响应时检查 CORS 头。

对于"预检请求"(比如 PUT、DELETE,或带自定义头的请求),浏览器会先发一个 OPTIONS 请求问服务器:"我能不能这样请求?"服务器同意了,浏览器才发真正的请求。


八、Node.js 模块系统速览

package.json 中有一行:

"type": "commonjs"

这指定了项目使用 CommonJS 模块系统。Node.js 目前支持两种模块规范:

CommonJS(CJS)

// 导出
module.exports = { foo, bar }

// 导入
const { foo, bar } = require('./module')
  • require() 是同步加载的
  • 导出的是值的拷贝
  • 是 Node.js 的传统模块系统

ES Module(ESM)

// 导出
export default function foo() {}
export const bar = 123

// 导入
import foo, { bar } from './module.js'
  • import 是异步加载的
  • 导出的是值的引用(可以 tree-shaking)
  • 是 JavaScript 语言标准

现在的新项目基本都用 ESM 了,但理解 CommonJS 仍然重要,因为海量的 npm 包还是 CJS 格式。


九、完整流程回顾

让我们用一张图来串联整个 AJAX 的流程:

┌─────────────────────────────────────────────────────┐
│  浏览器 (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

res.setHeader('Content-Type', 'application/json; charset=utf-8')

不加这个,中文可能会变成乱码。

坑 2:请求发出去了,但数据没拿到

检查清单:

  • 后端服务启动了吗?(node index.js
  • 端口对不对?(5000)
  • 路径对不对?(/todos 不是 /todo
  • CORS 头加了吗?

坑 3:readyState 判断不完整

// ❌ 错误:只判断 status
if (xhr.status === 200) {
  // 此时 readyState 可能还不是 4,数据还没接收完
}

// ✅ 正确:两个都判断
if (xhr.status === 200 && xhr.readyState === 4) {
  // 数据完整且请求成功
}

坑 4:fetch 不会自动 reject HTTP 错误

// 即使服务器返回 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))

十一、进阶方向

掌握了本文的内容后,你可以继续探索:

  1. axios —— 一个基于 Promise 的 HTTP 客户端库,封装了 XHR,支持拦截器、取消请求、自动转换 JSON 等功能,是目前前端项目中最流行的网络请求库。
  2. 请求拦截与响应拦截 —— 在请求发出前和响应到达后统一处理逻辑(比如带上 token、统一错误处理)。
  3. AbortController —— 取消正在进行的 fetch 请求,避免重复请求和内存泄漏。
  4. WebSocket —— 当你需要服务器主动推送数据时(比如聊天室、实时数据看板),AJAX 就不够用了,WebSocket 才是正解。

总结

回顾一下本文的核心知识点:

知识点要点
AJAX不刷新页面的情况下与服务器通信
XMLHttpRequest老牌 API,通过 readyState + status 判断请求状态
fetch现代 API,基于 Promise,语法更简洁
JSON.stringify / parse序列化和反序列化,前后端数据交换的桥梁
CORS跨域资源共享,通过响应头控制跨域访问权限
异步JS 单线程,遇到异步不等待,回调在主线程空闲时执行
async/await最推荐的异步写法,代码可读性最好

AJAX 是前端开发的基石之一。从 jQuery 时代的 $.ajax(),到原生的 XMLHttpRequest,再到现代的 fetchaxios,工具在变,但核心原理没变:发请求、拿数据、更新页面

把这个项目跑起来,打开控制台,观察 readyState 的变化、console.log 的打印顺序,你就能真正理解"异步"到底是怎么回事。


📌 本文项目源码结构:

  • 后端:node backend/index.js 启动服务(端口 5000)
  • 前端:直接用浏览器打开 frontend/index.html
  • 确保后端先启动,再打开前端页面

如果觉得有帮助,欢迎点赞收藏 👍,有问题评论区见!