Ajax 全栈实战:从 XMLHttpRequest 到 async/await,一文搞懂前端异步通信

0 阅读7分钟

本文以一个完整的 Todo 前后端分离项目为切入点,从 Node.js 原生 HTTP 服务搭建,到前端 XHR/Fetch 请求发起,再到 JSON 序列化、CORS 跨域、JS 异步演进三大范式(callback → Promise → async/await),手把手带你吃透 Ajax 全链路。


一、前言:Ajax 何以改变 Web?

在 Web 1.0 时代,用户与页面的每一次交互都意味着一次完整的页面刷新——点一个按钮,整个页面白屏、重载,用户体验非常割裂。2005 年,Jesse James Garrett 在一篇文章中正式提出 Ajax(Asynchronous JavaScript And XML) 这个概念,彻底改变了前端与服务器的通信方式。

Ajax 的核心思想很简单:让 JavaScript 在页面不刷新的前提下,主动发起 HTTP 请求,拿到数据后动态更新 DOM。正是这个能力,催生了 Web 2.0 时代的繁荣——Gmail、Google Maps、微博瀑布流,背后都是同一套机制在驱动。

今天,我们以一个极简但完整的 Todo 前后端分离项目 为蓝本,从零梳理 Ajax 全链路技术栈。项目结构如下:

ajax/
├── README.md          # 核心知识点笔记
├── backend/
│   └── index.js       # Node.js 原生 HTTP 服务端
└── frontend/
    └── index.html     # 前端页面(XHR + DOM 渲染)

麻雀虽小,五脏俱全。Let's dive in.


二、后端:Node.js 原生 HTTP 服务器

首先来看服务端 backend/index.js,这是一个用 Node.js 内置 http 模块搭建的极简 API 服务:

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 CommonJS 模块化

代码第一行 require('http') 使用的是 CommonJS 规范,这是 Node.js 早期的模块化方案。在 ES Module(import / export default)成为标准之前,CommonJS 几乎是 Node.js 生态的基石。理解两者的演进关系,对于阅读老项目源码非常有帮助:

规范导入导出
CommonJSrequire('module')module.exports = ...
ES Moduleimport ... from 'module'export default ...

2.2 JSON.stringify —— 对象的"网络形态"

JSON.stringify(todos) 这行代码看似简单,却是前后端通信的核心环节。它的作用是将 JavaScript 对象序列化为 JSON 字符串:

  • 为什么要序列化? 网络传输只能传递二进制/字符串,不能直接传递 JS 对象引用。
  • 三个参数JSON.stringify(value, replacer?, space?)
    • replacer:过滤器,传 null 表示原样序列化;也可以传数组指定要保留的 key。
    • space:缩进空格数,用于提升可读性——团队协作中统一这个参数是很实用的规范。

与之对应,前端拿到 JSON 字符串后需要用 JSON.parse() 反序列化还原为 JS 对象。

2.3 CORS 跨域头

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

前后端分离开发时,前端页面(通常跑在 localhost:5500file://)与后端接口(localhost:5000)属于不同源,浏览器会触发同源策略拦截。这行响应头就是告诉浏览器:"我允许任意来源访问我"。

⚠️ 生产环境中请将 * 替换为具体域名,避免安全风险。


三、前端:XMLHttpRequest —— Ajax 的"老炮儿"

前端代码 frontend/index.html 使用 XMLHttpRequest(XHR) 向后端发起请求:

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)
    Todos.innerHTML = todos.map(t => `<li>${t.title}</li>`).join('')
  }
}

xhr.send()

3.1 XHR 的生命周期

XHR 通过 readyState 属性暴露请求的当前状态,一共 5 个阶段:

readyState含义
0UNSENT —— 实例已创建,但 open() 尚未调用
1OPENED —— open() 已调用
2HEADERS_RECEIVED —— 收到响应头
3LOADING —— 正在接收响应体
4DONE —— 请求完成

只有在 readyState === 4status === 200 时,才表示一次成功的 HTTP 请求拿到了完整的响应数据。

3.2 open() 的第三个参数:异步开关

xhr.open('GET', url, true)  // true = 异步,false = 同步

第三个参数控制请求是异步还是同步。传 true(默认)时,XHR 不会阻塞 JS 主线程,请求发出后代码继续往下走;传 false 时,send() 会卡住整个页面直到响应返回——这在现代前端开发中几乎已被废弃,因为它会冻结 UI。

3.3 DOM 动态渲染

拿到数据后,我们用模板字符串拼接 HTML,再通过 innerHTML 一次性注入 DOM:

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

这就是 Ajax 最经典的范式:请求数据 → 解析数据 → 更新 DOM。Vue/React 等现代框架本质上也是在这个循环上做了声明式封装和虚拟 DOM 优化。


四、Fetch API —— XHR 的现代化继任者

代码中被注释掉的这段,展示了 XHR 的现代化替代方案:

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

4.1 Fetch vs XHR 对比

维度XMLHttpRequestFetch
API 风格事件驱动(onreadystatechangePromise 链式调用
语法简洁度较繁琐简洁直观
错误处理仅网络层错误需手动检查 res.ok
请求/响应流支持 upload.progress暂不支持原生上传进度
中断请求xhr.abort()AbortController
浏览器兼容性全兼容IE 不支持

Fetch 基于 Promise,天然支持链式调用,代码可读性明显优于 XHR 的回调嵌套。但在需要上传进度监听或兼容 IE 的场景下,XHR 依然是不可替代的选择。


五、JavaScript 异步处理的三次进化

README 中用精炼的语言总结了 JS 异步编程的三次范式跃迁,这也是理解 Ajax 的核心前置知识。

5.1 第一代:回调函数(Callback)

xhr.onreadystatechange = function () { /* ... */ }

JavaScript 是单线程语言,遇到异步任务(如网络请求、定时器)时,并不会原地等待,而是将其放入 Event Loop(事件循环) 中暂存,继续执行后续同步代码。等到异步任务有了结果,Event Loop 再从中取出对应的回调函数执行。

回调函数是最朴素的异步处理方式,但缺陷也很明显——当多个异步任务存在依赖关系时,会形成臭名昭著的回调地狱(Callback Hell)

5.2 第二代:Promise + .then()

fetch(url)
  .then(res => res.json())
  .then(data => { /* 处理数据 */ })
  .catch(err => { /* 统一错误处理 */ })

Promise 用链式调用替代回调嵌套,将"横向缩进"变成"纵向串联",并通过 .catch() 实现统一的错误捕获,极大提升了代码的可维护性。

5.3 第三代: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.error(err)
  }
}

async/await 是 Promise 的语法糖,它让异步代码看起来像同步代码一样直观。执行逻辑一目了然,错误处理可以直接用 try...catch,不再需要跳来跳去读 .then() 链。

最佳实践:在新项目中优先使用 async/await。


六、执行顺序实验:理解 Event Loop

前端代码中有一处巧妙的设计——三处 console.log 的输出顺序:

console.log('start')             // ① 同步代码,立即执行

xhr.open('GET', url, true)
xhr.onreadystatechange = ...     // ③ 异步回调,等请求完成才执行
xhr.send()

console.log('end')               // ② 同步代码,xhr.send() 后立即执行

实际输出:

start
end
(请求完成后...)
234   // readyState 的变化过程

这个实验直观地验证了:异步回调不会阻塞同步代码的执行。理解这一点,就理解了 JS 单线程异步模型的核心。


七、实战延伸:用 async/await 重构项目

作为练习,我们可以将项目中的 XHR 调用用 async/await + fetch 改写一遍:

document.getElementById('btn').addEventListener('click', async () => {
  try {
    const res = await fetch('http://localhost:5000/todos')
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    const todos = await res.json()
    document.getElementById('Todos').innerHTML =
      todos.map(t => `<li>${t.title}</li>`).join('')
  } catch (err) {
    console.error('请求失败:', err.message)
  }
})

对比原版 XHR 代码,行数减少近一半,逻辑线更清晰,错误处理也更健壮。这就是技术演进带来的实实在在的工程收益。


八、总结

回顾整个项目,我们用不到 100 行代码串联起了 Ajax 全链路的核心知识点:

  1. Node.js HTTP 模块 — 搭建简易 RESTful 服务,理解服务端请求-响应模型
  2. JSON 序列化/反序列化JSON.stringifyJSON.parse 是前后端数据交换的通用语言
  3. CORS 跨域 — 前后端分离开发的必备配置
  4. XMLHttpRequest — Ajax 的经典实现,理解 readyState 生命周期和异步模式
  5. Fetch API — 基于 Promise 的现代化请求方案
  6. JS 异步演进 — Callback → Promise → async/await,理解每一代的痛点与解决方案
  7. Event Loop — 理解单线程 JS 如何通过事件循环实现非阻塞异步

技术的演进从来不是空中楼阁。XHR 虽然古老,但它的设计思想——通过 readyState 暴露请求生命周期、通过事件回调解耦请求与响应——至今仍然影响着后续所有网络请求 API 的设计。理解它的底层原理,再去使用 Fetch、axios 等上层封装,你会发现自己不再是"调包侠",而是真正理解每一行代码在做什么的工程师


📦 项目源码位于 backend/ajax/ 目录,包含完整的后端服务和前端页面,可直接运行体验。
🔧 运行方式:node backend/index.js 启动服务端,再用 Live Server 打开 frontend/index.html 即可看到效果。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论,也欢迎在掘金关注我,一起交流前端技术!