前后端通信不是魔法,是 HTTP 协议、数据格式和浏览器安全机制共同演奏的交响曲
你开发时遇到前端请求被拦截,控制台飘红
Access-Control-Allow-Origin------这就是同源策略在挡路。它是浏览器安全的基石,也是前后端通信的第一道门槛。搞懂它,以及它背后的 HTTP 协议和数据格式,你就掌握了 Web 开发的核心骨架。
先看全貌:一次请求的 6 步旅程
scss
浏览器 网络 服务器
| | |
| ① 构造HTTP请求报文 | |
|--------------------------->|------------------------>|
| (请求行+请求头+请求体) | DNS解析 → 路由转发 |
| | |
| | ② 服务器解析请求 |
| | (路由匹配 → 读数据库) |
| | |
| ④ 浏览器接收响应 | ③ 返回HTTP响应报文 |
|<---------------------------|<------------------------|
| (状态码+响应头+响应体) | |
| | |
| ⑤ 检查同源策略/CORS | |
| ⑥ JS解析响应体 → 更新DOM | |
看起来简单,但 ①③⑤ 这三步藏着 Web 开发 80% 的"坑"。下面逐一拆解。
第一乐章:HTTP 报文------前后端的"通用语言"
请求报文(浏览器 → 服务器)
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:
kotlin
完整 URL:https://api.example.com/data
├─ 协议 https:// → 用于建立 TLS 加密连接(不出现在报文里)
├─ 域名 api.example.com → 放进请求头 Host:
├─ 路径 /data → 放进请求行(Request Line)
└─ 端口 443 → 用于 TCP 连接(默认端口不出现)
实际发出的 HTTP 报文:
http
GET /data HTTP/1.1 ← 请求行:只有路径,没有域名!
Host: api.example.com ← 域名在这里
User-Agent: Mozilla/5.0 ...
Accept: application/json
就像寄快递:请求行只写门牌号(
/data),Host 头写城市街道(api.example.com),协议是选哪种快递方式(普通/加密)。
带查询参数时,参数也属于请求行:
http
GET /users?id=1&page=2 HTTP/1.1 ← ?id=1&page=2 也在请求行里
Host: api.example.com
响应报文(服务器 → 浏览器)
http
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() 只是"解析一下"?其实它在幕后做了两件大事:
scss
后端内存 序列化 编码 网络传输
{ 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... |
二进制 Buffer | Buffer.from(str, 'utf-8') |
| ④ 网络传输 | 电信号/光信号 | 物理层 | 网线/光纤 |
| ⑤ 解码 | '{"name":"张三"}' |
JSON 字符串 | TextDecoder.decode() |
| ⑥ 前端内存 | { name: '张三' } |
JS 对象 | JSON.parse() |
response.json() 到底做了什么?
javascript
// 你以为的(一行代码)
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 解析,一步到位。
你写的每一行代码对应哪一步?
javascript
// 发送时:对象 → 字符串 → 二进制
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)不会抛异常。
javascript
// ❌ 错误写法: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 | 后端怎么拆包 |
|---|---|---|
| 提交 JSON | application/json |
express.json() |
| 提交表单 | application/x-www-form-urlencoded |
express.urlencoded() |
| 上传文件 | multipart/form-data |
multer 中间件 |
记住:前端发什么格式,后端就要配什么中间件来解析。配错了就是 req.body 是 undefined。
不同内容类型的解码差异
| Content-Type | 解码方式 | 前端用什么方法 |
|---|---|---|
application/json |
UTF-8 解码 + JSON 解析 | response.json() |
text/plain |
UTF-8 解码 | response.text() |
image/png |
不解码,保持二进制 | response.blob() |
application/octet-stream |
不解码 | response.arrayBuffer() |
第三乐章:HTTP 缓存------浏览器的"记忆系统"
每次请求都从服务器拿数据?太浪费了。浏览器有一套缓存机制,能让你秒开页面、省流量、减服务器压力。
两种缓存策略
markdown
浏览器发起请求
↓
本地有没有缓存? ── 没有 → 正常请求服务器
│
有 ↓
缓存过期了吗? ── 没过期 → 直接用本地缓存(强缓存,0 请求!)
│
过期了 ↓
问服务器:资源变了吗? ── 没变 → 用本地缓存(协商缓存,省带宽)
│
变了 ↓
返回新资源(200 + 新数据)
强缓存:不问服务器,直接用本地
浏览器连请求都不发,直接从本地磁盘/内存读取。快到飞起。
| 响应头 | 含义 | 示例 |
|---|---|---|
Cache-Control: max-age=3600 |
资源在 3600 秒内有效 | 最常用,优先级最高 |
Expires: Thu, 01 Jan 2027 00:00:00 GMT |
过期的绝对时间 | 老方案,有时钟偏差问题 |
DevTools 验证 :打开 F12 → Network,强缓存命中时,Size 列会显示
disk cache或memory cache,Status 显示200(不是 304)。
协商缓存:问一问服务器,资源变了没?
缓存过期后,浏览器带上"上次的凭证"去问服务器。服务器说"没变"就返回 304 Not Modified(不带响应体),浏览器继续用本地缓存。
| 流程 | 请求头 | 响应头 | 说明 |
|---|---|---|---|
| 第一次请求 | --- | Last-Modified: Wed, 10 Jul 2025 + ETag: "abc123" |
服务器告诉浏览器"资源的最后修改时间"和"内容指纹" |
| 第二次请求 | If-Modified-Since: Wed, 10 Jul 2025 |
304 Not Modified |
服务器比对时间,没变就不返回数据 |
| 或者 | If-None-Match: "abc123" |
304 Not Modified |
服务器比对指纹,没变就不返回数据 |
ETag vs Last-Modified:ETag(内容指纹)比 Last-Modified(修改时间)更精确------1 秒内改了两次,时间不变但内容变了,只有 ETag 能检测到。
实际开发怎么配?
javascript
// 后端:静态资源设置强缓存(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.js) |
Cache-Control: max-age=31536000(1年) |
文件名变了就是新资源,可以永久缓存 |
| HTML 入口文件 | Cache-Control: no-cache |
每次都协商,确保拿到最新的资源引用 |
| API 接口数据 | Cache-Control: no-store |
数据实时变化,不缓存 |
| 用户头像等不常变的资源 | Cache-Control: max-age=86400(1天) |
平衡新鲜度和性能 |
第四乐章:同源策略------浏览器的"安保系统"
什么叫同源?
三个维度全部一致才算同源:
yaml
协议://域名:端口
↓ ↓ ↓
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 的精确含义
makefile
Access-Control-Allow-Origin: *
* 表示允许任意来源(协议+域名+端口都无所谓)。
如果你想精确控制:
arduino
Access-Control-Allow-Origin: https://www.example.com:3000
浏览器会拿当前页面的源去跟这个值做字符串严格匹配:
| 响应头设置 | 当前页面源 | 结果 |
|---|---|---|
Allow-Origin: https://example.com:3000 |
https://example.com:3000 |
✅ 允许 |
Allow-Origin: https://example.com:3000 |
http://example.com:3000 |
❌ 拒绝(协议不同) |
Allow-Origin: https://example.com:3000 |
https://api.example.com:3000 |
❌ 拒绝(域名不同) |
Allow-Origin: https://example.com:3000 |
https://example.com:8080 |
❌ 拒绝(端口不同) |
浏览器实际执行的 CORS 检查流程
arduino
前端发起跨域请求
↓
浏览器自动在请求头加上 Origin: http://localhost:3000
↓
后端响应头返回 Access-Control-Allow-Origin: http://localhost:3000
↓
浏览器比对:请求的 Origin === 响应的 Allow-Origin?
↓
┌─ 匹配 → 放行,JS 可以读取响应
└─ 不匹配 → 浏览器拦截,JS 只能看到网络错误
三个关键细节
① 通配符 * 的限制 :如果请求带了凭证(Cookie / Authorization 头),* 会失效,必须指定具体源。
② OPTIONS 预检请求:复杂请求(PUT/DELETE/自定义 Header)会先发一个 OPTIONS 请求"探路",后端必须正确处理。
③ 同源策略只限制浏览器:服务器之间互相请求完全不受限------这就是"后端代理"方案的原理。
CORS 实战代码
javascript
// 后端: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------所有通信最终都服务于增删改查
任何管理系统(用户、商品、订单、文章)都离不开四件事:C reate、R ead、U pdate、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 |
删除数据 |
完整前后端代码
javascript
// ============ 前端: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);
}
javascript
// ============ 后端: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);
终章:串联一切------用户登录的完整链路
把前面五乐章的知识串起来,看一次登录请求从点击到页面跳转的完整过程:
javascript
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;
}
整个过程中,浏览器在幕后帮你做了这些事:
scss
fetch() 调用
↓
URL 拆分(域名→Host头,路径→请求行)
↓
构造 HTTP 请求报文(请求行+请求头+请求体)
↓
编码成二进制流 → TCP 传输 → 到达服务器
↓
服务器处理 → 返回 HTTP 响应报文
↓
浏览器接收二进制 → 检查同源策略 / CORS
↓
通过 → UTF-8解码 → JSON.parse() → JS 对象 → 交给你的代码
拦截 → 抛出网络错误,response 拿不到
一图总结
scss
┌──────────────────────────────────────────────────────────────────────┐
│ 前后端通信全链路 │
├────────────────┬──────────────────┬──────────────┬───────────────────┤
│ HTTP 协议 │ 数据格式 │ HTTP 缓存 │ 浏览器安全机制 │
│ │ │ │ │
│ 请求行(路径) │ JSON.stringify() │ 强缓存 │ 同源策略 │
│ 请求头(Host等) │ ↓ │ (直接用本地) │ (协议+域名+端口) │
│ 请求体(数据) │ 编码(UTF-8→二进制) │ 协商缓存 │ CORS │
│ ↕ │ ↓ │ (304省带宽) │ (响应头声明) │
│ 状态码(200/304) │ 网络传输(二进制流) │ Cache-Control│ OPTIONS预检 │
│ 响应头(CORS等) │ ↓ │ ETag │ ↓ │
│ 响应体(JSON) │ 解码+解析(自动) │ no-store │ 比对Origin→放行/拦截│
└────────────────┴──────────────────┴──────────────┴───────────────────┘
最后的话
前后端通信的本质就四句话:
- HTTP 协议定义了"怎么打包、怎么运输"------请求行、请求头、请求体,缺一不可
- JSON + 编解码 定义了"包裹里装什么、怎么序列化和反序列化"------
response.json()= UTF-8解码 + JSON解析 - HTTP 缓存定义了"哪些包裹不用重复寄"------强缓存直接用本地,协商缓存 304 省带宽
- 同源策略 + CORS定义了"谁能收这个包裹"------保安检查 Origin,后端用响应头放行
搞懂这四点,你再看到 fetch、axios、XMLHttpRequest,就知道它们不过是这几件事的不同封装。底层永远是:构造报文 → 编码 → 网络传输 → 缓存判断 → 安全检查 → 解码 → 解析响应。
不是魔法,是工程。
💬 如果这篇文章帮你理清了前后端通信的全貌,点个赞👍让更多人看到。
下一篇可以聊聊:Cookie / Session / JWT 的爱恨情仇,或者 WebSocket 如何打破 HTTP 的"一问一答"模式。想看哪个,评论区告诉我。