最近在给前端项目搭 API 服务,又是 CORS 问题把我卡住了。前端跑在 localhost:5173,后端接口部署在 Cloudflare Workers 上,浏览器直接报“has been blocked by cors policy: No 'Access-Control-Allow-Origin' header is present”。这个报错你们应该不陌生,尤其是用 Express 写 API 再部署到 Cloudflare 边缘场景的同学,十个里有九个会撞上。之所以拿这个组合出来写,是因为它确实有代表性:Express 是 Node 生态里最常见的 API 框架,Cloudflare Workers 又是目前最便宜的边缘部署方式之一,两边一拼,跨域配置稍有不注意就全线飘红。
这篇文章我会从一个实际可运行的 Express API 项目出发,讲清楚为什么会出现 CORS 拦截、在 Cloudflare Workers 上跑 Express 时有哪些适配工作、以及三种我从项目实践中整理出来的 CORS 解决方案。不管你是刚接触 Cloudflare Workers 的新手,还是已经被跨域问题折磨过几轮的老人,这篇都能给你一套直接能用的配置方案,照着抄就行。
1. 先搞清楚:为什么 Express API 部署到 Cloudflare 后会被 CORS 拦住
1.1 跨域问题的本质是什么
浏览器跨域拦截这事儿,很多人第一反应是后端搞的鬼,其实真正的“执法者”是浏览器本身。你的前端页面加载的时候,JavaScript 代码向另一个域名发起 AJAX 请求,浏览器会先检查响应头里有没有Access-Control-Allow-Origin,并且这个头是否匹配当前页面的域名。如果不匹配或没有,浏览器就拒绝把响应交给 JavaScript,控制台就出现那条经典的报错。
这里有个关键认知:API 服务器其实已经把数据返回了,只是浏览器帮你拦下来了。这也是为什么你用 Postman、curl 测接口一切正常,一放到浏览器里就炸的原因。理解这一点,排查 CORS 问题时你会容易很多——先确认请求是否真的到达了服务器、响应头是否正确,再考虑其他因素。
1.2 Cloudflare Workers 上的 Express 有什么特殊性
Cloudflare Workers 本身是运行在 V8 隔离环境里的,并不是传统的 Node.js 服务器。早期想在 Workers 里跑 Express,需要做不少适配,因为 Workers 的运行时模型是“一个请求进来,你返回一个 Response”,而 Express 是“监听端口,处理请求”。
但现在的官方生态已经推进得很完善了。你可以通过@cloudflare/workers-express这个官方适配包,以非常接近本地开发的方式运行 Express 应用。实际部署后,你的 Worker 域名是类似https://your-api.workers.dev的地址,而前端页面可能是http://localhost:5173或https://your-frontend.pages.dev。这两个地址的源不同,浏览器自然要执行同源策略。
换句话说,CORS 问题在这个组合下几乎是必然出现的,除非你前端页面和 API 在同一个域名下,否则就必须在服务端显式处理跨域。所以标题里提到的“cloudflare 使用 express 实现 api 防止跨域 cors”,本质上做的是三件事:让 Express 应用在 Workers 上跑起来、正确设置响应头、处理预检请求。
2. 环境准备与项目初始化:让 Express 跑在 Cloudflare Workers 上
2.1 初始化项目与安装依赖
先把我实际用的项目结构展示给你。我习惯用 npm workspace 管理,但单项目也不复杂。创建一个新目录,然后初始化:
mkdir cloudflare-express-api cd cloudflare-express-api npm init -y npm install express @cloudflare/workers-express wrangler --save-dev@cloudflare/workers-express是官方适配器,它会把 Worker 的 fetch 事件转发给 Express 应用实例去处理。安装wrangler用于本地调试和部署。如果你用的是 TypeScript,再补上typescript和@types/express。
2.2 入口文件怎么写
Workers 的入口文件和传统 Node.js 服务不同,不需要app.listen(),而是导出一个包含fetch方法的默认对象。基于官方适配器,代码可以写成这样:
// src/index.js import express from 'express'; import { createHandler } from '@cloudflare/workers-express'; const app = express(); app.get('/api/hello', (req, res) => { res.json({ message: 'Hello from Cloudflare Workers + Express!' }); }); export default createHandler(app);这里的createHandler会返回一个 Worker 请求处理器,内部把 Web 标准的Request转换成 Express 风格的对象,再把 Express 返回的Response转回 Web 标准响应。这一步是适配核心,也解决了很多人手动包装时响应头丢失的问题。
2.3 配置 wrangler 与本地调试
项目根目录下新建wrangler.toml:
name = "cloudflare-express-api" main = "src/index.js" compatibility_date = "2025-01-01" compatibility_flags = ["nodejs_compat"]注意compatibility_flags里的nodejs_compat,Express 依赖 Node.js 的部分内置模块,打开这个标志位后才能在 Workers 里正常运行 Express。这是很多人忽略的点——不开nodejs_compat,本地跑得好好的,部署上去就开始报一些莫名其妙的模块错误。
启动本地开发:
npx wrangler dev实测下来,本地调试体验和普通 Express 开发差别不大,改代码后热更新也生效。但要注意,如果你在本地用了app.listen(3000)这种方式,wrangler dev会直接白屏或报错,一定要用createHandler导出。
3. 三种 CORS 解决方案实测对比
3.1 方案一:手动中间件,零依赖、完全可控
我一开始用的是最原始的方式——写一个 Express 中间件,手动给每个响应加上 CORS 头。这个方案的好处是没有任何额外依赖,逻辑完全透明,适合想彻底搞清楚 CORS 原理的场景。
// src/middleware/cors.js export function corsMiddleware(req, res, next) { const allowedOrigins = [ 'http://localhost:5173', 'https://my-frontend.pages.dev' ]; const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Max-Age', '86400'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); }然后在入口里:
app.use(corsMiddleware);这个方案的关键点在于,你必须在所有路由之前使用这个中间件。否则请求还没走到中间件就返回响应了,CORS 头自然加不上。另外一个容易踩坑的地方是:OPTIONS请求必须直接返回,不能再往后走业务逻辑,否则前端会收到一个非 2xx 的响应,预检照样失败。
3.2 方案二:使用 cors 包,一行代码搞定大部分需求
如果你不追求手写底层细节,直接用cors包是更高效的选择。这是 Express 社区的事实标准,支持各种配置项,绝大多数服务端框架都会用到它。
npm install corsimport cors from 'cors'; const corsOptions = { origin: ['http://localhost:5173', 'https://my-frontend.pages.dev'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400 }; app.use(cors(corsOptions));这里有几个细节值得提醒:
origin可以传数组,也可以传函数,函数可以更灵活地判断请求来源。如果你需要根据环境变量动态配置允许的域名,用函数模式会很方便。credentials: true表示前端可以携带 Cookie 和 HTTP 认证信息。很多人在这一步碰壁,因为一旦开启credentials,Access-Control-Allow-Origin就不能是*,必须指定具体域名,浏览器会直接拒绝*和凭据同时出现的情况。maxAge是预检请求结果缓存的时间,单位是秒。设成86400(一天)能显著减少浏览器重复发送OPTIONS的次数,对接口响应速度提升有帮助。
3.3 方案三:在 Worker 入口统一注入,适用于多路由或 Pages 场景
有时候你的 Worker 不只是跑 Express,还混了一些静态资源、重定向逻辑,或者你就是希望 CORS 逻辑独立在应用之外。这种情况下,可以在 Worker 的fetch阶段统一处理,而不是交给 Express 中间件。
// src/index.js import express from 'express'; import { createHandler } from '@cloudflare/workers-express'; const app = express(); app.get('/api/hello', (req, res) => { res.json({ message: 'Hello from Cloudflare Workers + Express!' }); }); const handler = createHandler(app); const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400' }; export default { async fetch(request, env, ctx) { if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders }); } const response = await handler.fetch(request, env, ctx); const newResponse = new Response(response.body, response); Object.entries(corsHeaders).forEach(([key, value]) => { newResponse.headers.set(key, value); }); return newResponse; } };这个方案的好处是 CORS 逻辑和应用逻辑彻底解耦,以后哪怕你不跑 Express 了,这套头部注入逻辑依然能复用。坏处是要自己多写几行代码,而且得注意复制Response的status、statusText和原有 headers,否则业务响应的状态码和内容类型可能丢失。
3.4 三个方案的取舍建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 手动中间件 | 零依赖、逻辑透明、可精细控制每个响应 | 代码量大、容易遗漏 | 学习原理、极其简单的 API |
| cors 包 | 配置方便、社区标准、覆盖绝大多数场景 | 多一个依赖、默认*需注意 | 大多数 Express 项目首选 |
| Worker 入口统一注入 | 与应用解耦、适合混合场景 | 需要处理 Response 克隆 | 多路由、需要全局控制的复杂项目 |
从我踩坑的经验看,单一 Express API 服务直接选 cors 包就够用,但一定要把origin从默认的*改成明确的域名列表。而如果你的 Worker 里还有其他非 Express 路由,或者你本来就在用 Pages 混合渲染,入口统一注入反而更省心。
4. 预检请求与凭据问题:最容易被忽略的两个坑
4.1 预检请求(OPTIONS)到底是什么
但凡涉及自定义 Header、非简单请求(比如Content-Type: application/json)、或者使用了PUT/DELETE方法,浏览器都会在正式请求之前先发一个OPTIONS请求,这叫“预检请求”。预检通过之后,浏览器才会真正发出业务请求。
很多人在后端看着日志里全是OPTIONS,还以为是有人攻击,其实这是浏览器的正常行为。你要做的不是屏蔽它,而是保证它得到正确响应:
- 状态码必须是
2xx,通常是204 No Content,也可以是200 OK; - 必须返回
Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers; - 响应体不需要内容,但
Content-Length: 0或空 body 都行。
上面方案一和方案二都对这个情况做了处理,方案一里用res.sendStatus(204)专门拦 OPTIONS,方案二里cors包默认处理了预检。最容易出问题的反而是方案三,如果你在 Worker 入口没有对OPTIONS单独判断,预检请求会一直往下走,最终可能落到 Express 某个路由里,返回 404,前端就会报预检失败。
4.2 携带 Cookie 与 Authorization 时的特殊配置
如果你的 API 需要登录态,前端请求带着Authorization: Bearer xxx或者是 Cookie,这时 CORS 的配置要求会变得更严格:
Access-Control-Allow-Origin不能是*,必须是具体的源;- 必须设置
Access-Control-Allow-Credentials: true; Access-Control-Allow-Headers必须包含前端实际发送的 Header,比如Authorization。
我自己在这块就吃过亏。一开始图省事,origin写的是*,前端登录接口一直报 CORS 错误,查了半天才发现是凭据和通配符冲突的问题。后来改成显式列出所有允许的域名,再加credentials: true,问题才彻底解决。
如果你不确定前端到底发了哪些 Header,直接打开浏览器的“网络”面板,看预检请求的Access-Control-Request-Headers,后端按这个值去配置准没错。
5. 常见报错与排查思路实录
5.1 “No 'Access-Control-Allow-Origin' header is present”
这是出现频率最高的报错,几乎每个做前后端分离的人都会遇到。它说明响应里根本没有 CORS 头。排查思路按优先级排序:
- 确认请求是否到达服务器:先看 Worker 日志或后端控制台,有没有对应的请求记录。没有记录,说明请求在更早的环节被拦截了,比如 WAF 规则、路由匹配失败;
- 确认响应是否真的带了 CORS 头:用 curl 模拟:
curl -i -H "Origin: http://localhost:5173" https://your-api.workers.dev/api/hello看输出里有没有Access-Control-Allow-Origin。没有的话,说明你的中间件没有生效,或者顺序不对; 3.确认状态码:如果业务逻辑抛了异常,返回的是 500,某些错误响应可能没经过 CORS 中间件。Express 的错误处理中间件也要放在所有路由之后,并且把 CORS 头加上。
5.2 预检请求返回 404 或 500
预检请求如果返回的不是 2xx,浏览器会把整个请求拦截。常见原因有两个:
- 路由里没有匹配
OPTIONS的处理器,cors包或中间件没在路由之前执行; - Worker 入口对
OPTIONS的直接响应逻辑缺失。
排查时可以先手动发一个OPTIONS请求,看返回码和响应头是否符合预期。如果后端框架或者网关层面做了鉴权,也要确保OPTIONS请求跳过鉴权,否则预检阶段就直接被 401 拒了。
5.3 接口 200 能通,但前端读不到数据
这种情况还挺隐蔽的:响应看起来正常,Access-Control-Allow-Origin也有,前端还是报错。原因往往是响应头里Access-Control-Allow-Origin的值和前端域名不匹配,比如配置了https://my-frontend.pages.dev,但实际页面在https://my-frontend.pages.dev/some/path。注意,CORS 匹配的是“源”,包括协议、域名、端口,但不包括路径。如果你在origin配置里带了路径,就会失败。
还有一个细节:如果是Path或Query参数触发 CDN 层缓存,可能你更新了 Worker 代码,但边缘节点还缓存了旧的响应头。排查时看响应头里的Cf-Cache-Status是否为HIT。
5.4 常见问题速查表
| 报错信息 | 大概率原因 | 快速解法 |
|---|---|---|
| No 'Access-Control-Allow-Origin' header | 响应未带 CORS 头 | 确认中间件顺序,或在 Worker 入口统一注入 |
| Preflight request failed | OPTIONS 返回非 2xx | 拦截 OPTIONS 并返回 204,带全 CORS 头 |
| Credentials flag is 'true' but header is '*' | 凭据和通配符冲突 | 显式指定 origin,开启 credentials |
| Request header field x-custom is not allowed | 缺少 Allow-Headers 配置 | 在 headers 里加上 Authorization、Content-Type 等 |
| 接口偶发 CORS 报错 | CDN 缓存了旧响应 | 调整缓存规则或增加 Vary: Origin |
6. 部署上线与验证:确保线上 CORS 真正生效
6.1 部署到 Cloudflare Workers
代码和配置都验证没问题后,部署其实就一条命令:
npx wrangler deploy部署完成后,控制台会输出你的 Worker 域名,类似https://cloudflare-express-api.你的子域.workers.dev。如果你绑定了自定义域名,那就更好了,因为自定义域名可以和前端同源,从根上绕过 CORS 问题。
这里有个建议:生产环境尽量不要直接用*.workers.dev域名对外提供业务服务,一方面是企业版和免费版对这个域名有频率限制,另一方面是自定义域名可以统一走你自己的 CDN、WAF 策略,便于管理。
6.2 用 curl 和浏览器双重验证
部署完后,我用一条命令把 CORS 相关头全部打印出来,基本能搞定 80% 的排查:
curl -i -X OPTIONS \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: Content-Type" \ https://your-api.workers.dev/api/hello预期的响应头应该是:
HTTP/2 204 access-control-allow-origin: http://localhost:5173 access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS access-control-allow-headers: Content-Type, Authorization access-control-max-age: 86400如果这一步没问题,再打开浏览器开发者工具,切到“网络”面板,发起一个真实请求,观察预检请求和实际请求的响应头。两个都通过,就说明 CORS 配置基本到位了。
6.3 一个值得留意的额外建议
最后,虽然标题和主题都在讲“防止跨域 CORS”,但我想提醒一句:CORS 只是浏览器层面的安全策略,它不能让你的 API 变成“公开免鉴权”的接口。真正要保护后端资源,还是得靠身份认证和授权。CORS 配置严格一些是好事,但别把它当成唯一的安全防线。
我在实际项目中还遇到过一个问题:开发环境经常需要调试多个前端项目,有时是 React 的 5173 端口,有时是 Vue 的 8080 端口。这时候把origin写成固定数组就很痛苦,每次还得改代码重新部署。我的做法是读环境变量:
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean);然后在 Cloudflare Workers 的环境变量里配置不同环境的域名列表。这样代码完全不用动,改环境变量就能调整跨域策略,线上也不容易因为误操作暴露接口。
还有一个实战技巧是配合Vary: Origin响应头使用。当你的Access-Control-Allow-Origin是根据请求的Origin动态变化时,建议把Vary也带上,避免 CDN 层缓存给不同来源的用户返回错误的 CORS 头。虽然 Workers 场景下缓存规则不完全等同于传统 CDN,加上这个头总归是更稳妥的做法。
以上基本覆盖了我这次“Cloudflare Workers 上运行 Express API 并处理 CORS”的完整过程。说实话,折腾一遍下来,感觉 CORS 本身并不复杂,坑大多出在环境适配和配置细节上。尤其是 Workers 这种边缘计算模型,和传统 Node.js 服务在生命周期、运行时行为上都有差异,照搬本地代码是行不通的。希望这篇能帮你少踩几个坑,把 API 平稳跑起来。