后端返回的 JSON 字符串,浏览器怎么"看懂"的?——Ajax 全链路拆解

从零搞懂 Ajax:从原生 XHR 到 Promise,前端数据请求进化史

前言

很多前端新手学 Vue/React 时,直接用 axios.get() 或者 fetch() 发请求,数据就回来了------"挺简单啊"。但问到下面这些问题,可能就卡住了:

  • "为什么 res.json() 返回的是一个 Promise?"
  • "回调地狱是什么?async/await 到底解决了什么问题?"
  • "JSON.stringify 和 JSON.parse 在请求里分别干嘛?"
  • "跨域是什么?为什么后端要加 Access-Control-Allow-Origin?"

本文从一个极简的后端 + 前端项目 出发,把这些基础概念串成一条线讲清楚。不堆术语,用最直白的话带你搞懂 Ajax 的来龙去脉。

📌 本文 Demo 结构 :一个 Node.js 后端(提供 /todos 接口)+ 一个纯 HTML 前端(用 XHR 请求数据),不到 80 行代码,把 Ajax 核心原理讲透。


一、先看项目结构

go 复制代码
ajax/
├── backend/
│   ├── index.js        ← Node 后端:起一个 HTTP 服务,返回 JSON 数据
│   └── package.json    ← 标记为 CommonJS 模块
├── frontend/
│   └── index.html      ← 前端:用 XHR 请求数据,动态渲染到页面
└── readme.md           ← 笔记

跑起来很简单:

  1. 后端:node backend/index.js → 启动在 localhost:3000
  2. 前端:直接用浏览器打开 frontend/index.html

二、后端:数据从哪里来?

先看后端代码,它负责"提供数据":

js 复制代码
// node 内置的 http 模块 --- 不需要 npm install
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');
    }
    else if (req.url === '/todos') {
        // ① 允许跨域
        res.setHeader('Access-Control-Allow-Origin', '*');
        // ② 告诉浏览器返回的是 JSON,编码是 UTF-8
        res.setHeader('Content-Type', 'application/json;charset=utf-8');
        // ③ 把 JS 对象序列化为 JSON 字符串,以二进制发出
        res.end(JSON.stringify(todos));
    }
}).listen(3000, () => {
    console.log('server is running on 3000 port');
});

📌 知识点 1:require vs import --- 两套模块化方案

js 复制代码
const http = require('http');   // CommonJS --- Node.js 原生方案
import http from 'http';        // ESM --- 现代标准方案
CommonJS ESM (ES Module)
语法 require() / module.exports import / export default
诞生 Node.js 早期,服务端先用 ES6 标准(2015),浏览器原生支持
加载时机 运行时动态加载 编译时静态分析
使用场景 老项目、Node 脚本 新项目、Vite/Webpack 打包、浏览器

一句话:CommonJS 是过去,ESM 是现在和未来。 本文后端用 require 是因为 Node 默认就是 CommonJS;如果你在 package.json 里加 "type": "module",Node 就会走 ESM。

📌 知识点 2:HTTP 响应头 --- 浏览器和服务器的"约定暗号"

js 复制代码
res.setHeader('Content-Type', 'application/json;charset=utf-8');
res.setHeader('Access-Control-Allow-Origin', '*');

Content-Type:告诉浏览器"我返回的数据是什么格式"。

  • application/json → JSON 格式
  • text/html → HTML 页面
  • text/plain → 纯文本
  • charset=utf-8 → 编码方式,保证中文不乱码

Access-Control-Allow-Origin:跨域资源共享(CORS)的关键。

什么是跨域? 浏览器的同源策略 规定:协议、域名、端口三者必须完全一致,才能互相访问。你本地 HTML 用 file:// 协议打开,去请求 http://localhost:3000,协议和端口都不同 → 浏览器直接拦截。后端加上这个头等于告诉浏览器:"我允许任何人来访问我"

不加这一行的后果:

csharp 复制代码
🚫 Access to fetch at 'http://localhost:3000/todos' from origin 'null' 
   has been blocked by CORS policy

📌 知识点 3:JSON.stringify() --- 对象怎么在网络里"跑"?

js 复制代码
res.end(JSON.stringify(todos));

这是本文第一个核心函数,搞懂它很重要。

为什么需要它?

JS 对象是存在内存里的,不能直接通过网络传输。网络传输的是二进制字节流。所以需要:

javascript 复制代码
JS 对象(内存) → JSON字符串(文本) → 二进制字节流(网络) → JSON字符串 → JS 对象(对方内存)
  序列化              编码发送              接收解码              反序列化

JSON.stringify(value, replacer?, space?) 的三个参数:

参数 作用 示例
value 要序列化的对象 {name: '小明', age: 18}
replacer 过滤器:选哪些字段,或替换值 ['name'] 只保留 name;null 全部保留
space 缩进空格数,提高可读性 2 → 两个空格缩进
js 复制代码
const obj = { name: '小明', password: '123456', age: 18 };

// replacer: null → 全部序列化
JSON.stringify(obj, null, 2);
// 输出:
// {
//   "name": "小明",
//   "password": "123456",
//   "age": 18
// }

// replacer: 数组 → 只要 name 和 age,password 被过滤
JSON.stringify(obj, ['name', 'age'], 2);
// 输出:
// {
//   "name": "小明",
//   "age": 18
// }

💡 space 参数看似小细节,但在团队协作里很有用------后端接口返回的 JSON 带缩进,前端调试时一眼就能看清结构。


三、前端:数据怎么"拿"回来?

html 复制代码
<body>
    <ul id="todos"></ul>
    <script>
        // ① 创建 XHR 对象 --- Ajax 的核心
        const xhr = new XMLHttpRequest();

        // ② 打开一个 HTTP 请求(第三个参数 true = 异步)
        xhr.open("GET", "http://localhost:3000/todos", false);

        console.log('start');

        // ③ 注册回调函数 --- 当请求状态变化时自动调用
        xhr.onreadystatechange = function() {
            console.log(xhr.readyState);
            if (xhr.status === 200 && xhr.readyState === 4) {
                // ④ JSON.parse --- 把 JSON 字符串还原为 JS 对象
                const todos = JSON.parse(xhr.responseText);
                // ⑤ 动态渲染到页面
                document.getElementById('todos').innerHTML = todos.map(
                    todo => `<li>${todo.title}</li>`
                ).join('');
            }
        }

        // ⑥ 发送请求
        xhr.send();

        console.log('end');
    </script>
</body>

逐行拆解:

📌 知识点 4:XHR(XMLHttpRequest)--- Ajax 的"祖师爷"

XMLHttpRequest 是浏览器提供的原生 API,让 JS 能主动发送 HTTP 请求。

一段历史 :在 Ajax 出现之前,网页要拿新数据必须整页刷新 。2005 年 Google 用 XMLHttpRequest 实现了 Gmail 和 Google Maps 的"无刷新更新",Web 2.0 时代从此开启。"Ajax" 这个名字就是 A synchronous J avaScript A nd XML 的缩写,虽然现在大家都用 JSON 而不是 XML 了。

📌 知识点 5:readyState --- XHR 的"心跳"

readyState 含义
0 UNSENT --- 还没调用 open()
1 OPENED --- 已调用 open()
2 HEADERS_RECEIVED --- 已收到响应头
3 LOADING --- 正在接收响应体(数据一点点来)
4 DONE --- 请求完成,数据全部拿到
js 复制代码
xhr.onreadystatechange = function() {
    // 这个函数会被调用多次(0→1→2→3→4)
    // 只有 readyState === 4 且 status === 200 才算真正成功
    if (xhr.status === 200 && xhr.readyState === 4) {
        // 安全地使用数据
    }
}

💡 这就是**回调函数(Callback)**模式:把"数据到了之后要做什么"写成一个函数,交给 XHR,XHR 在合适的时机自动调用它。你不需要一直盯着------异步的精髓就在这

📌 知识点 6:JSON.parse() --- 字符串"复活"为对象

js 复制代码
const todos = JSON.parse(xhr.responseText);
// xhr.responseText 是字符串:'[{"id":1,"title":"过四六级","completed":false}]'
// JSON.parse 之后变成真正的 JS 数组:[{id:1, title:"过四六级", completed:false}]

JSON.stringifyJSON.parse 是一对逆操作:

javascript 复制代码
JS 对象 ←→ JSON 字符串
   JSON.stringify(对象) → 字符串
   JSON.parse(字符串)   → 对象

四、JS 异步处理:从回调地狱到 async/await

这是本文最核心的知识线。JS 处理异步经历了三代进化:

javascript 复制代码
回调函数(Callback) → Promise + .then() → async/await
    第一代               第二代              第三代

4.1 为什么 JS 需要异步?

JS 是单线程语言------同一时间只能做一件事。如果发一个网络请求要等 2 秒,这 2 秒里页面就完全卡死。

JS 的解决方案是 Event Loop(事件循环)

arduino 复制代码
同步代码立即执行
     ↓
遇到异步任务(网络请求、定时器...)→ 扔到"等待区",继续往下走
     ↓
同步代码全部跑完
     ↓
到"等待区"看谁准备好了,拿出回调函数执行
     ↓
执行完再回去看"等待区"...循环往复

来看代码里的证据:

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

xhr.onreadystatechange = function() {  // ③ 异步回调,等请求完成才执行
    console.log(xhr.readyState);
}

xhr.send();              // ② 发送请求(异步操作)

console.log('end');      // ④ 同步,立即执行

// 实际输出顺序:start → end → 2 → 3 → 4
// 而不是:start → 2 → 3 → 4 → end

🔑 关键认知xhr.send() 发出去之后,JS 不会站在原地干等,而是直接往下执行 console.log('end')。等网络请求的数据到了,才会去执行 onreadystatechange 里的回调。

4.2 第一代:回调函数 --- 好用但有坑

js 复制代码
// 回调:把"拿到数据后要干嘛"包成函数传进去
xhr.onreadystatechange = function() {
    const data = JSON.parse(xhr.responseText);
    // 用 data 干点什么...
}

问题 :如果需要多个请求按顺序执行(下一个请求依赖上一个的结果),就会变成"回调地狱":

js 复制代码
// 😱 回调地狱 --- 层层嵌套,读都读不懂
getUser(userId, function(user) {
    getOrders(user.id, function(orders) {
        getOrderDetail(orders[0].id, function(detail) {
            getProduct(detail.productId, function(product) {
                // 终于拿到想要的数据了...😫
            });
        });
    });
});

4.3 第二代:Promise + then --- 把嵌套拍平

js 复制代码
// ✅ Promise:链式调用,不嵌套
fetch("http://localhost:3000/todos")
    .then(res => res.json())      // 第一步:解析 JSON
    .then(data => {               // 第二步:用数据
        console.log(data);
    })
    .catch(err => {               // 统一错误处理
        console.error('出错了', err);
    });

知识点fetch() 返回的是 Promise 对象。Promise 是 ES6 引入的------它把"未来的某个值"包装成一个对象。当异步操作完成时,Promise 从 "pending(等待中)" 变为 "fulfilled(已完成)" 或 "rejected(失败)"。

Promise 有三种状态:pendingfulfilledrejected,且状态一旦改变就不会再变。

为什么 .then(res => res.json()) 也是一个 Promise?

res.json() 本身是异步的------响应体可能很大,解析 JSON 需要时间。所以它返回一个 Promise,你得再 .then() 一次才能拿到真正的数据。

4.4 第三代:async/await --- 让异步代码"看起来像同步"

js 复制代码
// 🌟 async/await:最优雅的写法
const main = async () => {
    try {
        const res = await fetch("http://localhost:3000/todos");
        const data = await res.json();
        console.log(data);
    } catch (err) {
        console.error('出错了', err);
    }
}
main();

async/await 好在哪?

写法 嵌套层数 错误处理 可读性
回调 可能无限嵌套 每层单独处理 😫
Promise + then 链式,不嵌套 .catch() 统一处理 🙂
async/await 完全拍平 try/catch 统一处理 😍

🔑 理解要点async/await 是 Promise 的语法糖 ------底层还是 Promise,但写法上和同步代码一模一样。await 就是告诉 JS:"这里等一下,拿到结果再往下走"。但它不会阻塞 JS 主线程,只是这个 async 函数内部在"原地等待"。

4.5 三代对比速查表

Callback Promise async/await
时代 ES5 之前 ES6 (2015) ES8 (2017)
核心 函数当参数传 Promise 对象 async 函数 + await
嵌套 层层嵌套(地狱) 链式调用(拍平) 完全拍平(像同步)
错误处理 每层自己处理 .catch() try/catch
本质 最原始的模式 状态机封装 Promise 的语法糖

五、补充:注释里藏着的前端进化史

注意到 index.html 里的注释了吗?它们本身就是一部微型进化史:

html 复制代码
<script>
    // fetch 的前辈
    // 输入 url 点击 a 标签
    // 底层本质是 js 可以主动去发送接口 http 请求
    // fetch/xhr 请求接口,动态的更新页面 web 2.0 时代 繁荣
    
    const xhr = new XMLHttpRequest();
    // 对比:
    // fetch("http://localhost:3000/todos")
    // .then(res => res.json())
    // .then(data => { console.log(data); })
</script>

这条注释串起了三个阶段:

bash 复制代码
Web 1.0:点击 a 标签 → 整页刷新 → 看到新内容(被动浏览)
Web 2.0:JS 主动发 XHR/fetch → 拿到数据 → 局部更新页面(动态交互)
现代前端:fetch + async/await → 像写同步代码一样写异步逻辑

六、一张图串起全部概念

javascript 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                         后端 (Node.js)                            │
│                                                                  │
│  JS 对象 {id:1, title:'过四六级'}                                │
│       │                                                          │
│       │ JSON.stringify(obj)  ← 序列化:对象 → JSON 字符串         │
│       ▼                                                          │
│  JSON 字符串 '{"id":1,"title":"过四六级"}'                        │
│       │                                                          │
│       │ res.end()  ← 二进制字节流,通过网络传输                    │
│       ▼                                                          │
├─────────────────────────────────────────────────────────────────┤
│                         HTTP 网络                                  │
│  headers: Content-Type, Access-Control-Allow-Origin              │
├─────────────────────────────────────────────────────────────────┤
│                         前端 (浏览器)                              │
│       │                                                          │
│       │ xhr/fetch  ← JS 主动发起 HTTP 请求(Web 2.0 核心)        │
│       ▼                                                          │
│  JSON 字符串 '{"id":1,"title":"过四六级"}'                        │
│       │                                                          │
│       │ JSON.parse(str)  ← 反序列化:JSON 字符串 → JS 对象        │
│       ▼                                                          │
│  JS 对象 {id:1, title:'过四六级'}                                 │
│       │                                                          │
│       │ DOM 操作(innerHTML)                                     │
│       ▼                                                          │
│  页面渲染 <li>过四六级</li>                                        │
│                                                                  │
│  异步模式进化:Callback → Promise.then → async/await             │
│  全靠 Event Loop 在背后调度                                       │
└─────────────────────────────────────────────────────────────────┘

七、知识速查表

数据序列化

函数 方向 输入 输出
JSON.stringify() 对象 → 字符串 JS 对象 JSON 字符串
JSON.parse() 字符串 → 对象 JSON 字符串 JS 对象

模块化

CommonJS ESM
语法 require / module.exports import / export
代表 Node.js 原生 现代浏览器、Vite/Webpack
加载 运行时 编译时

异步进化

Callback Promise async/await
可读性 ⭐⭐ ⭐⭐⭐
错误处理 各自为战 .catch() 统一 try/catch
嵌套 回调地狱 链式平铺 完全拍平

HTTP 关键头

响应头 作用
Content-Type 告诉浏览器返回数据的格式和编码
Access-Control-Allow-Origin 允许跨域访问(* = 任何人都行)

写在最后

回到开头那几个问题,现在你应该能答出来了:

  • res.json() 为什么返回 Promise? → 因为解析 JSON 是异步操作,返回 Promise 才能链式 .then()
  • 回调地狱是什么?async/await 解决了什么? → 回调层层嵌套 → async/await 把异步写成同步的样子
  • JSON.stringify 和 JSON.parse 分别干嘛? → stringify 序列化(对象→字符串),parse 反序列化(字符串→对象)
  • 跨域为什么后端要加头? → 浏览器的同源策略会拦截跨域请求,后端加 Access-Control-Allow-Origin 明确放行

这些知识看起来"基础",但它们是前端开发每天都碰到的底层逻辑。搞懂了,看任何 HTTP 请求库(axios、fetch、ofetch...)都是一样的套路。

🌟 最后叮嘱 :如果你是初学者,强烈建议亲手把这个 demo 敲一遍。从 node backend/index.js 到打开 HTML 看到数据,那种"通了"的感觉,比看十篇文章都管用。


觉得有帮助的话,欢迎点赞、收藏、评论交流! 🎉

相关推荐
半个落月2 小时前
一个新手用 Bun + Axios 调通 DeepSeek API 的实践记录
javascript
不好听6132 小时前
深入理解链表:线性数据结构的另一面
javascript·数据结构
林希_Rachel_傻希希2 小时前
学React治好了我的焦虑症,1小时速通React 前20分钟。
前端·javascript·面试
小林ixn2 小时前
从 Ajax 到异步编程:JSON 序列化、Event Loop 与 XHR 请求完全解析
javascript
丷丩3 小时前
MapLibre GL JS第47课:添加动画图标
javascript·gis·动画·mapbox·maplibre
快乐的哈士奇4 小时前
【Next.js实战①】Gmail API 按柜号检索邮件:OAuth 双 Cookie 与搜索 Fallback
开发语言·javascript·ecmascript
云水一下4 小时前
Vue.js从零到精通系列(五):全局状态管理——Pinia 核心与实践
前端·javascript·vue.js
kmblack15 小时前
javascript计算年龄
开发语言·javascript·ecmascript
Dick5075 小时前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人