实战一:本地前后端分离开发
本章搭建一个常见环境:Vite 前端运行在 http://localhost:5173,Express 5 API 运行在 http://localhost:8080。端口不同就是不同源,因此浏览器需要 CORS 授权。
1. 安装与基本结构
npm install express cors helmet
npm install -D @types/express @types/cors typescript ts-node
// server.ts
import express from "express";
import cors, { type CorsOptions } from "cors";
import helmet from "helmet";
import { randomUUID } from "node:crypto";
const app = express();
const port = 8080;
const corsOptions: CorsOptions = {
origin: "http://localhost:5173",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["Content-Disposition", "X-Request-Id"],
};
app.use(helmet());
app.use(cors(corsOptions));
app.use(express.json());
app.get("/api/profile", (_req, res) => {
res.setHeader("X-Request-Id", randomUUID());
res.json({ code: 0, data: { name: "Ada" } });
});
app.post("/api/articles", (req, res) => {
res.json({ code: 0, data: req.body });
});
app.listen(port, () => {
console.log(`API listening at http://localhost:${port}`);
});
cors() 应放在路由和认证之前,使成功响应、业务错误以及预检响应都能得到 CORS Header。Helmet 与 CORS 的职责不同,可以同时全局启用。
2. Bearer Token 请求
Authorization 不是 CORS 简单请求允许的 Header,因此浏览器通常先发 OPTIONS 预检。
app.get("/api/orders", (req, res) => {
const authorization = req.get("Authorization");
if (!authorization?.startsWith("Bearer ")) {
res.status(401).json({ code: 40101, message: "缺少访问令牌" });
return;
}
res.json({ code: 0, data: [] });
});
手动模拟预检:
curl -i -X OPTIONS http://localhost:8080/api/orders \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization"
正式请求:
curl -i http://localhost:8080/api/orders \
-H "Origin: http://localhost:5173" \
-H "Authorization: Bearer demo-token"
没有 Origin 的 curl 不是浏览器跨源请求;它可以验证接口,但不能单独证明浏览器 CORS 配置正确。
3. JSON POST 为什么经常预检
Content-Type: application/json 不属于 CORS safelisted request-header 的简单取值,所以常触发预检:
curl -i -X OPTIONS http://localhost:8080/api/articles \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
不要为了避免 OPTIONS 而把 JSON 改造成不恰当的数据格式。预检是浏览器的正常安全流程。
4. 下载文件并读取文件名
app.get("/api/reports/latest", (_req, res) => {
res.attachment("销售报表 2026.csv");
res.send("id,total\n1,99\n");
});
浏览器可以下载响应体,但前端若要读取 Content-Disposition,服务端必须通过 Access-Control-Expose-Headers 暴露它。本章的全局 exposedHeaders 已完成该设置。
const response = await fetch("http://localhost:8080/api/reports/latest");
const disposition = response.headers.get("content-disposition");
const body = await response.blob();
curl -i http://localhost:8080/api/reports/latest \
-H "Origin: http://localhost:5173"
重点检查:
Access-Control-Allow-Origin: http://localhost:5173Access-Control-Expose-Headers: Content-Disposition, X-Request-IdContent-Disposition: attachment; ...
5. Cookie 登录场景
跨源 Cookie 比 Bearer Token 多一组约束:
const cookieCorsOptions: CorsOptions = {
origin: "http://localhost:5173",
credentials: true,
};
app.use("/api/session", cors(cookieCorsOptions));
前端也必须主动允许凭据:
await fetch("http://localhost:8080/api/session/me", {
credentials: "include",
});
启用 credentials: true 时,Access-Control-Allow-Origin 不能是 *。此外 Cookie 是否真正发送还受 SameSite、Secure、Domain、Path 和浏览器第三方 Cookie 策略影响;CORS 允许不等于 Cookie 一定可用。
6. 本地调试清单
- Network 中先找 OPTIONS,再找正式请求。
- 看请求的
Origin,不要凭感觉填写白名单。 - 检查 OPTIONS 状态码和 Allow 系列 Header。
- 检查正式响应,包括 401、404 和错误响应是否也带 CORS Header。
- Console 中 CSP 和 CORS 报错要分开判断。
- curl 测试时显式添加
Origin,预检还要添加两个Access-Control-Request-*Header。