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

从零搞懂 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() 方法用来配置请求,三个参数:

  1. 请求方法: 'GET' ------ 表示获取数据。常见的还有 POST(提交数据)、PUT(更新数据)、DELETE(删除数据)。
  2. 请求地址: 'http://localhost:5000/todos' ------ 完整的 URL。
  3. 是否异步: 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('')

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

  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

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))

十一、进阶方向

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

  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
  • 确保后端先启动,再打开前端页面

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

相关推荐
星河耀银海1 小时前
接口调用:HTML5前端调用AI接口的基础语法与示例
前端·人工智能·html5
HarvestHarvest1 小时前
【Copy Web独立开发者实战:我是如何用 AI 实现网页 UI 1:1 完美复刻的?】
前端·人工智能·ui
RuoyiOffice1 小时前
从 0 到 1 搭建 RuoyiOffice:30 分钟跑通后端+前端+移动端
前端·spring boot·uni-app·开源·oa·ruoyioffice·hrm
XovH1 小时前
Redis 从入门到精通:分片之道 —— Redis Cluster
后端
XovH1 小时前
Redis 从入门到精通:Redis Sentinel 哨兵
后端
昭昭颂桉a1 小时前
TypeScript 前端的必修课,从 JS 到 TS
开发语言·前端·javascript·typescript
用户938515635071 小时前
从零实现一个 Todos 应用:原生 Ajax + Node 服务,顺便吃透 JSON.stringify
前端·javascript·后端
霸道流氓气质1 小时前
Spring Boot 文件上传大小限制配置全解析
spring boot·后端·firefox
Java面试题总结1 小时前
SpringBoot API参数校验
java·spring boot·后端