前后端通信不是魔法,是 HTTP 协议、数据格式和浏览器安全机制共同演奏的交响曲

0 阅读7分钟

前后端通信不是魔法,是 HTTP 协议、数据格式和浏览器安全机制共同演奏的交响曲

你开发时遇到前端请求被拦截,控制台飘红 Access-Control-Allow-Origin——这就是同源策略在挡路。

它是浏览器安全的基石,也是前后端通信的第一道门槛。搞懂它,以及它背后的 HTTP 协议和数据格式,你就掌握了 Web 开发的核心骨架。


先看全貌:一次请求的 6 步旅程

浏览器                        网络                      服务器
  |                            |                         |
  |  ① 构造HTTP请求报文        |                         |
  |--------------------------->|------------------------>|
  |  (请求行+请求头+请求体)     |    DNS解析 → 路由转发    |
  |                            |                         |
  |                            |   ② 服务器解析请求       |
  |                            |    (路由匹配 → 读数据库)  |
  |                            |                         |
  |  ④ 浏览器接收响应          |   ③ 返回HTTP响应报文     |
  |<---------------------------|<------------------------|
  |  (状态码+响应头+响应体)     |                         |
  |                            |                         |
  |  ⑤ 检查同源策略/CORS       |                         |
  |  ⑥ JS解析响应体 → 更新DOM  |                         |

看起来简单,但 ①③⑤ 这三步藏着 Web 开发 80% 的"坑"。下面逐一拆解。


第一乐章:HTTP 报文——前后端的"通用语言"

请求报文(浏览器 → 服务器)

POST /api/login HTTP/1.1              ← 请求行:方法 + 路径 + 版本
Host: localhost:5000                  ← 以下为请求头(元数据)
Content-Type: application/json
Authorization: Bearer eyJhbGci...
Cookie: sessionId=abc123
Origin: http://localhost:3000

{"username":"张三","password":"123456"}  ← 请求体(实际数据)

四个关键请求头,记住它们:

请求头一句话解释
Content-Type"我发的数据是什么格式" — JSON / Form / 文件
Authorization"我是谁" — JWT Token / API Key
Cookie"这是我的会话凭证" — 浏览器自动携带
Origin"我从哪来" — 跨域判断的关键依据
🔍 你可能没想过的问题:fetch 里的 URL 去哪了?

当你写 fetch('https://api.example.com/data') 时,浏览器会自动拆分这个 URL:

完整 URL:https://api.example.com/data
         ├─ 协议 https://  → 用于建立 TLS 加密连接(不出现在报文里)
         ├─ 域名 api.example.com  → 放进请求头 Host:
         ├─ 路径 /data  → 放进请求行(Request Line)
         └─ 端口 443    → 用于 TCP 连接(默认端口不出现)

实际发出的 HTTP 报文:

GET /data HTTP/1.1                    ← 请求行:只有路径,没有域名!
Host: api.example.com                 ← 域名在这里
User-Agent: Mozilla/5.0 ...
Accept: application/json

就像寄快递:请求行只写门牌号(/data),Host 头写城市街道(api.example.com),协议是选哪种快递方式(普通/加密)。

带查询参数时,参数也属于请求行:

GET /users?id=1&page=2 HTTP/1.1       ← ?id=1&page=2 也在请求行里
Host: api.example.com

响应报文(服务器 → 浏览器)

HTTP/1.1 200 OK                       ← 状态行
Content-Type: application/json        ← 响应头
Access-Control-Allow-Origin: http://localhost:3000
Set-Cookie: sessionId=xyz789; HttpOnly

{"code":0,"data":{"name":"张三"}}      ← 响应体

状态码速记——面试和日常开发最常遇到的:

状态码含义你该怎么处理
200成功解析响应体
304资源没变,用缓存不用重新请求
400你发的数据格式错了检查请求体
401没登录 / Token 过期跳转登录页
403没权限提示用户
404路径写错了检查 URL
500服务器炸了等后端修

第二乐章:数据格式——JSON 为什么一统天下?

网络只能传二进制,对象不能直接飞

你以为 response.json() 只是"解析一下"?其实它在幕后做了两件大事

后端内存               序列化              编码                网络传输
{ name: '张三' }  ──JSON.stringify──> '{"name":"张三"}'  ──UTF-8编码──>  01001011 00110101...
   (对象)               (字符串)            (二进制流)          (电信号/光信号)

前端内存               解析                解码                浏览器接收
{ name: '张三' }  <──JSON.parse────  '{"name":"张三"}'  <──UTF-8解码──  01001011 00110101...
   (对象)               (字符串)            (二进制流)

完整的六步链路

阶段数据格式类型谁做的
① 后端内存{ name: '张三' }JS 对象你的代码
② 序列化'{"name":"张三"}'JSON 字符串JSON.stringify()
③ 编码7B 22 6E 61 6D 65...二进制 BufferBuffer.from(str, 'utf-8')
④ 网络传输电信号/光信号物理层网线/光纤
⑤ 解码'{"name":"张三"}'JSON 字符串TextDecoder.decode()
⑥ 前端内存{ name: '张三' }JS 对象JSON.parse()

response.json() 到底做了什么?

// 你以为的(一行代码)
const result = await response.json();

// 实际发生的(简化示意,非真实源码)
Response.prototype.json = async function() {
  // 第1步:解码 — 二进制流 → UTF-8 文本字符串
  const text = await this.text();  // 内部调用 TextDecoder.decode()

  // 第2步:解析 — JSON 字符串 → JS 对象
  return JSON.parse(text);
};

response.json() = 二进制解码(UTF-8)+ JSON 解析,一步到位。

你写的每一行代码对应哪一步?

// 发送时:对象 → 字符串 → 二进制
fetch('/api/user', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },  // 告诉后端格式
  body: JSON.stringify({ name: '张三', age: 25 })    // ② 序列化
  // 浏览器自动帮你做 ③ 编码
});

// 接收时:二进制 → 字符串 → 对象
const response = await fetch('/api/user');
const result = await response.json();  // ⑤解码 + ⑥解析,一步完成
console.log(result.data.name);         // '张三'

⚠️ fetch 的常见坑:它不会自动 throw

这是很多人不知道的:fetch 只在网络故障时才会 reject,HTTP 错误状态码(404、500)不会抛异常。

// ❌ 错误写法:404/500 不会进 catch
try {
  const response = await fetch('/api/user/999');
  const result = await response.json();  // 404 时这里可能解析失败
} catch (err) {
  // 只有网络断开、DNS失败等才会进这里
}

// ✅ 正确写法:手动检查 response.ok
try {
  const response = await fetch('/api/user/999');

  if (!response.ok) {  // ok = status 在 200-299 之间
    const error = await response.json();
    throw new Error(error.message || `HTTP ${response.status}`);
  }

  const result = await response.json();
  console.log(result.data.name);
} catch (err) {
  console.error('请求失败:', err.message);
}

记住:response.ok 是你的好朋友,永远别忘了检查它。

Content-Type 决定了"包裹里装的是什么"

场景Content-Type后端怎么拆包
提交 JSONapplication/jsonexpress.json()
提交表单application/x-www-form-urlencodedexpress.urlencoded()
上传文件multipart/form-datamulter 中间件

记住:前端发什么格式,后端就要配什么中间件来解析。配错了就是 req.bodyundefined

不同内容类型的解码差异

Content-Type解码方式前端用什么方法
application/jsonUTF-8 解码 + JSON 解析response.json()
text/plainUTF-8 解码response.text()
image/png不解码,保持二进制response.blob()
application/octet-stream不解码response.arrayBuffer()

第三乐章:HTTP 缓存——浏览器的"记忆系统"

每次请求都从服务器拿数据?太浪费了。浏览器有一套缓存机制,能让你秒开页面、省流量、减服务器压力

两种缓存策略

浏览器发起请求
     ↓
本地有没有缓存? ── 没有 → 正常请求服务器
     │
     有 ↓
缓存过期了吗? ── 没过期 → 直接用本地缓存(强缓存,0 请求!)
     │
     过期了 ↓
问服务器:资源变了吗? ── 没变 → 用本地缓存(协商缓存,省带宽)
     │
     变了 ↓
返回新资源(200 + 新数据)

强缓存:不问服务器,直接用本地

浏览器连请求都不发,直接从本地磁盘/内存读取。快到飞起。

响应头含义示例
Cache-Control: max-age=3600资源在 3600 秒内有效最常用,优先级最高
Expires: Thu, 01 Jan 2027 00:00:00 GMT过期的绝对时间老方案,有时钟偏差问题

DevTools 验证:打开 F12 → Network,强缓存命中时,Size 列会显示 disk cachememory cache,Status 显示 200(不是 304)。

协商缓存:问一问服务器,资源变了没?

缓存过期后,浏览器带上"上次的凭证"去问服务器。服务器说"没变"就返回 304 Not Modified(不带响应体),浏览器继续用本地缓存。

流程请求头响应头说明
第一次请求Last-Modified: Wed, 10 Jul 2025 + ETag: "abc123"服务器告诉浏览器"资源的最后修改时间"和"内容指纹"
第二次请求If-Modified-Since: Wed, 10 Jul 2025304 Not Modified服务器比对时间,没变就不返回数据
或者If-None-Match: "abc123"304 Not Modified服务器比对指纹,没变就不返回数据

ETag vs Last-Modified:ETag(内容指纹)比 Last-Modified(修改时间)更精确——1 秒内改了两次,时间不变但内容变了,只有 ETag 能检测到。

实际开发怎么配?

// 后端:静态资源设置强缓存(JS/CSS/图片)
app.use('/static', express.static('public', {
  maxAge: '1d',           // 强缓存 1 天
  etag: true,             // 开启 ETag(默认开启)
  lastModified: true      // 开启 Last-Modified(默认开启)
}));

// 后端:API 接口一般不缓存
app.get('/api/users', (req, res) => {
  res.set('Cache-Control', 'no-store');  // 告诉浏览器:别缓存,每次都来问
  res.json({ code: 0, data: users });
});

缓存策略速查

资源类型推荐策略原因
带 hash 的 JS/CSS(如 app.a1b2c3.jsCache-Control: max-age=31536000(1年)文件名变了就是新资源,可以永久缓存
HTML 入口文件Cache-Control: no-cache每次都协商,确保拿到最新的资源引用
API 接口数据Cache-Control: no-store数据实时变化,不缓存
用户头像等不常变的资源Cache-Control: max-age=86400(1天)平衡新鲜度和性能

第四乐章:同源策略——浏览器的"安保系统"

什么叫同源?

三个维度全部一致才算同源:

协议://域名:端口
            
http  localhost  3000    你的页面
http  localhost  5000    你的API   端口不同,跨域!
https localhost  3000    协议不同,也跨域!

为什么要有这个限制?

一句话:防止恶意网站偷走你在其他网站的数据。

想象你登录了银行网站(bank.com),浏览器存了你的 Cookie。如果你不小心打开了一个恶意网站(evil.com),没有同源策略的话,evil.com 的 JS 就能直接请求 bank.com/transfer,带着你的 Cookie 把钱转走。

同源策略就是那个铁面无私的保安:不同源的请求?响应数据一律不给看。

但保安也有"放行条"——CORS

跨域资源共享(CORS)是后端告诉浏览器"这个人是被允许的"。

CORS 的精确含义
Access-Control-Allow-Origin: *

* 表示允许任意来源(协议+域名+端口都无所谓)。

如果你想精确控制:

Access-Control-Allow-Origin: https://www.example.com:3000

浏览器会拿当前页面的源去跟这个值做字符串严格匹配

响应头设置当前页面源结果
Allow-Origin: https://example.com:3000https://example.com:3000✅ 允许
Allow-Origin: https://example.com:3000http://example.com:3000❌ 拒绝(协议不同)
Allow-Origin: https://example.com:3000https://api.example.com:3000❌ 拒绝(域名不同)
Allow-Origin: https://example.com:3000https://example.com:8080❌ 拒绝(端口不同)
浏览器实际执行的 CORS 检查流程
前端发起跨域请求
       ↓
浏览器自动在请求头加上 Origin: http://localhost:3000
       ↓
后端响应头返回 Access-Control-Allow-Origin: http://localhost:3000
       ↓
浏览器比对:请求的 Origin === 响应的 Allow-Origin?
       ↓
   ┌─ 匹配 → 放行,JS 可以读取响应
   └─ 不匹配 → 浏览器拦截,JS 只能看到网络错误
三个关键细节

① 通配符 * 的限制:如果请求带了凭证(Cookie / Authorization 头),* 会失效,必须指定具体源。

② OPTIONS 预检请求:复杂请求(PUT/DELETE/自定义 Header)会先发一个 OPTIONS 请求"探路",后端必须正确处理。

③ 同源策略只限制浏览器:服务器之间互相请求完全不受限——这就是"后端代理"方案的原理。

CORS 实战代码
// 后端:Node.js + Express
const express = require('express');
const cors = require('cors');
const app = express();

// ✅ 开发环境:允许所有人
app.use(cors());

// ✅ 生产环境:白名单动态判断
const whiteList = ['https://my-app.com', 'https://admin.my-app.com'];
app.use(cors({
  origin: function (origin, callback) {
    if (whiteList.includes(origin) || !origin) {
      callback(null, true);         // 放行
    } else {
      callback(new Error('不允许的源'));  // 拦截
    }
  },
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true,    // 允许携带 Cookie
  maxAge: 86400         // OPTIONS 预检结果缓存 24 小时
}));

// ✅ 手动设置(不用 cors 库)
app.use((req, res, next) => {
  const origin = req.headers.origin;
  if (whiteList.includes(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);  // 动态返回,不写死 *
  }
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.setHeader('Access-Control-Allow-Credentials', 'true');

  // 处理 OPTIONS 预检请求
  if (req.method === 'OPTIONS') {
    return res.sendStatus(200);
  }
  next();
});

跨域解决方案速查

方案适用场景原理
后端 CORS 配置生产环境响应头声明允许的源
Vite/Webpack proxy开发环境开发服务器帮你转发请求
Nginx 反向代理生产环境让前后端同源
后端代理调第三方 API服务器去请求,不受同源限制

第五乐章:CRUD——所有通信最终都服务于增删改查

任何管理系统(用户、商品、订单、文章)都离不开四件事:Create、Read、Update、Delete。

RESTful 映射

操作HTTP 方法URL说明
创建POST/api/users新增一条数据
查询列表GET/api/users获取所有用户
查询单个GET/api/users/1获取 ID=1 的用户
完整更新PUT/api/users/1替换整个资源
部分更新PATCH/api/users/1只修改部分字段
删除DELETE/api/users/1删除数据

完整前后端代码

// ============ 前端:API 封装(带错误处理) ============
// 生产环境用相对路径 '/api/users'(同源),开发环境才需要完整 URL
const API = {
  baseURL: '/api',  // 生产环境:同源相对路径;开发环境代理到后端

  // 统一请求方法,内置错误处理
  async request(url, options = {}) {
    try {
      const res = await fetch(`${this.baseURL}${url}`, {
        headers: { 'Content-Type': 'application/json' },
        ...options
      });

      if (!res.ok) {
        const error = await res.json().catch(() => ({}));
        throw new Error(error.message || `HTTP ${res.status}`);
      }

      return await res.json();
    } catch (err) {
      console.error(`API 请求失败:${url}`, err.message);
      throw err;  // 重新抛出让调用方处理
    }
  },

  // Create - 创建用户
  createUser(data) {
    return this.request('/users', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  },

  // Read - 查询列表
  getUsers() {
    return this.request('/users');
  },

  // Update - 修改用户
  updateUser(id, data) {
    return this.request(`/users/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    });
  },

  // Delete - 删除用户
  deleteUser(id) {
    return this.request(`/users/${id}`, { method: 'DELETE' });
  }
};

// 使用示例
try {
  const { data } = await API.getUsers();
  console.log('用户列表:', data);
} catch (err) {
  alert('加载失败:' + err.message);
}
// ============ 后端:Express 路由 ============
const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors());
app.use(express.json());

let users = [
  { id: 1, name: '张三', age: 30 },
  { id: 2, name: '王五', age: 28 }
];

// Create
app.post('/api/users', (req, res) => {
  const { name, age } = req.body;
  const newUser = { id: users.length + 1, name, age };
  users.push(newUser);
  res.json({ code: 0, data: newUser, message: '创建成功' });
});

// Read
app.get('/api/users', (req, res) => {
  res.json({ code: 0, data: users, total: users.length });
});

// Update
app.put('/api/users/:id', (req, res) => {
  const user = users.find(u => u.id === Number(req.params.id));
  if (!user) return res.status(404).json({ code: 404, message: '用户不存在' });
  Object.assign(user, req.body);
  res.json({ code: 0, data: user, message: '更新成功' });
});

// Delete
app.delete('/api/users/:id', (req, res) => {
  users = users.filter(u => u.id !== Number(req.params.id));
  res.json({ code: 0, message: '删除成功' });
});

app.listen(5000);

终章:串联一切——用户登录的完整链路

把前面五乐章的知识串起来,看一次登录请求从点击到页面跳转的完整过程:

try {
  // ① 前端构造请求(第一乐章:HTTP报文)
  const response = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },   // 数据格式
    credentials: 'include',                              // 携带Cookie
    body: JSON.stringify({ username: '张三', password: '123456' })  // 序列化
  });
  // 浏览器自动:URL拆分 → 域名放Host头 → 路径放请求行 → 编码成二进制

  // ② 浏览器自动执行(第四乐章:安全检查)
  //    - 检查 Origin 是否同源
  //    - 如果跨域,发 OPTIONS 预检
  //    - 比对响应头的 CORS 声明
  //    - 通过才把响应交给 JS

  // ③ 检查状态码(fetch 不会自动 throw 4xx/5xx)
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || `HTTP ${response.status}`);
  }

  // ④ 解析响应(第二乐章:数据格式)
  const result = await response.json();
  // 浏览器内部:接收二进制 → UTF-8解码 → JSON.parse() → JS对象

  // ⑤ 业务逻辑(第五乐章:CRUD思维)
  localStorage.setItem('token', result.token);  // 存凭证
  window.location.href = '/dashboard';           // 跳转

} catch (err) {
  // 网络错误 + HTTP 错误 + JSON 解析错误,全部在这里处理
  document.getElementById('errorMsg').textContent = err.message;
}

整个过程中,浏览器在幕后帮你做了这些事

fetch() 调用
    ↓
URL 拆分(域名→Host头,路径→请求行)
    ↓
构造 HTTP 请求报文(请求行+请求头+请求体)
    ↓
编码成二进制流 → TCP 传输 → 到达服务器
    ↓
服务器处理 → 返回 HTTP 响应报文
    ↓
浏览器接收二进制 → 检查同源策略 / CORS
    ↓
通过 → UTF-8解码 → JSON.parse() → JS 对象 → 交给你的代码
拦截 → 抛出网络错误,response 拿不到

一图总结

┌──────────────────────────────────────────────────────────────────────┐
│                       前后端通信全链路                                  │
├────────────────┬──────────────────┬──────────────┬───────────────────┤
│   HTTP 协议     │   数据格式        │  HTTP 缓存    │  浏览器安全机制     │
│                │                  │              │                   │
│ 请求行(路径)    │ JSON.stringify() │ 强缓存        │ 同源策略           │
│ 请求头(Host等)  │        ↓         │ (直接用本地)  │ (协议+域名+端口)   │
│ 请求体(数据)    │ 编码(UTF-8→二进制) │ 协商缓存      │ CORS              │
│       ↕        │        ↓         │ (304省带宽)   │ (响应头声明)       │
│ 状态码(200/304) │ 网络传输(二进制流)  │ Cache-Control│ OPTIONS预检       │
│ 响应头(CORS等)  │        ↓         │ ETag         │        ↓          │
│ 响应体(JSON)    │ 解码+解析(自动)    │ no-store     │ 比对Origin→放行/拦截│
└────────────────┴──────────────────┴──────────────┴───────────────────┘

最后的话

前后端通信的本质就四句话:

  1. HTTP 协议定义了"怎么打包、怎么运输"——请求行、请求头、请求体,缺一不可
  2. JSON + 编解码定义了"包裹里装什么、怎么序列化和反序列化"——response.json() = UTF-8解码 + JSON解析
  3. HTTP 缓存定义了"哪些包裹不用重复寄"——强缓存直接用本地,协商缓存 304 省带宽
  4. 同源策略 + CORS定义了"谁能收这个包裹"——保安检查 Origin,后端用响应头放行

搞懂这四点,你再看到 fetchaxiosXMLHttpRequest,就知道它们不过是这几件事的不同封装。底层永远是:构造报文 → 编码 → 网络传输 → 缓存判断 → 安全检查 → 解码 → 解析响应

不是魔法,是工程。


💬 如果这篇文章帮你理清了前后端通信的全貌,点个赞👍让更多人看到。

下一篇可以聊聊:Cookie / Session / JWT 的爱恨情仇,或者 WebSocket 如何打破 HTTP 的"一问一答"模式。想看哪个,评论区告诉我。