实战一:本地前后端分离开发

本章搭建一个常见环境: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"

重点检查:

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 是否真正发送还受 SameSiteSecure、Domain、Path 和浏览器第三方 Cookie 策略影响;CORS 允许不等于 Cookie 一定可用。

6. 本地调试清单

  1. Network 中先找 OPTIONS,再找正式请求。
  2. 看请求的 Origin,不要凭感觉填写白名单。
  3. 检查 OPTIONS 状态码和 Allow 系列 Header。
  4. 检查正式响应,包括 401、404 和错误响应是否也带 CORS Header。
  5. Console 中 CSP 和 CORS 报错要分开判断。
  6. curl 测试时显式添加 Origin,预检还要添加两个 Access-Control-Request-* Header。