各位驰骋在代码世界的英雄们,大家好!在前端和后端开发的江湖中,有一个“拦路虎”想必大家都曾遇到过,或者至少听说过它的大名——那就是“跨域问题”。每当你兴致勃勃地想让你的前端“小应用”和远方的API“小伙伴”打个招呼、取点数据时,浏览器这位严格的“保安大爷”可能就会跳出来,冷冰冰地甩给你一个控制台错误,告诉你:“禁止跨域访问!”
这是为什么呢?这背后其实是浏览器为了保护我们网上冲浪安全而设立的“同源策略”在默默守护。但现代Web应用又离不开各种API的调用和资源共享,这“一堵墙”和“要通路”的矛盾该如何化解?
别担心!本篇文章将用一系列生动有趣的生活化比喻和代码案例,带你走进CORS、JSONP、服务器代理这三大“跨域解决方案”的奇妙世界。无论你是初入江湖的小虾米,还是经验丰富的老司机,相信都能在这里找到豁然开朗的感觉。准备好了吗?让我们一起拉开这场“跨域大作战”的序幕吧!
我家“规矩”大!—— 认识“同源策略”与“跨域”
啥是“自家人”?——“同源”的奥秘
想到“别人家”串门?——“跨域请求”登场
“小区保安”为何如此严格?—— 一切为了你家安全!
“规矩”的边界——哪些“邻里互动”可以网开一面?
给“别人家”送礼与看回执——“写操作”的微妙
浏览器的角色——忠诚的“小区保安队长”!
“规矩”的意义与未来——通往安全的“友好访问”之路
一、CORS (跨源资源共享) - 精密的“国际旅行与海关通行”协议
1. 基本国情:同源策略(回顾)
2. 问题的出现:全球化贸易的需求
3. CORS 的诞生:国际贸易与旅行的“通行协议”
CORS 的工作流程,我们用“国际旅行”来细致解读:
场景一:简单的“短期观光” (Simple Requests - 简单请求)
场景二:复杂的“商务考察”或“携带特殊物品入境” (Preflighted Requests - 预检请求)
更多重要的“国际旅行”细节:
CORS的核心知识点总结:
代码示例与解读:
代码描述 1: 前端交互页面 (HTML, CSS, JavaScript)
代码描述 2: 后端 API 服务器 (使用 cors 中间件库)
代码描述 3: 后端 API 服务器 (手动配置CORS头部)
使用cors中间件库与手动配置CORS头部对比:
二、JSONP - “外卖订单的特殊备注” (仅限GET,老办法,不常用啦)
场景开始:
JSONP客户端的核心知识点就是这么回事:
但这法子有点“取巧”:
代码示例与解读 :
代码描述 1: 前端 JSONP 客户端
代码描述 2: 后端 JSONP 服务器
三、 代理服务器 - 请个“管家”帮忙跑腿
代码示例与解读:
代码描述 1: 前端客户端 (proxy_client.html)
代码描述 2: 模拟的第三方 API 服务器 (mock_restaurant_api.js)
代码描述 3: 代理服务器 (proxy_server.js)
总结:跨域江湖,哪招最妙?
你好呀,各位前端小伙伴和对网页小秘密好奇的朋友们!今天我们不聊代码,聊聊咱们网上“家”的那些“规矩”——大名鼎鼎的“同源策略”(Same-Origin Policy),以及它带来的“邻里问题”,也就是“跨域请求”。别担心,保证用最接地气的大白话,让你一听就明白!
想象一下,你的网站(比如 http://我的温馨小窝.com
)就是你在互联网上的一套温馨小房子。为了安全,不是谁都能随随便便进你家门,更不能在你家里乱翻东西。你家有一套非常严格的“访客登记制度”,只有“自家人”才能畅通无阻。这就是“同源策略”的核心思想。
那什么才算“自家人”(同源)呢?简单说,就像你家的“门牌号”得完完全全一模一样,精确到每一个字母和数字!具体来说,有三个关键部分必须完全相同:
“小区名称”(协议相同):
比如你家住的是“http小区”(http://
),那隔壁“https小区”(https://
)虽然名字像,但安保级别不一样,也算是“外小区”了。两者不能随便串门。“街道门牌号”(域名相同):
我的温馨小窝.com
号”。隔壁老王家.com
号”肯定是“外人”。我的温馨小窝.com
的 前院 (前院.我的温馨小窝.com
)”或者“后花园 (后花园.我的温馨小窝.com
)”(子域名不同),在严格的“门禁卡”制度下,也算是独立的“房产”,不能直接共享内部资源,得按“外人”规矩来。“单元楼层及房号”(端口号相同):
我的温馨小窝.com:8080
(住在8080室),虽然街道门牌号一样,但“房号”(端口号)不同,也算是“另一户人家”了。只有这“小区名称”、“街道门牌号”、“单元房号”三者完全一致,才算是“一家人”,可以在自家地盘(同源)下自由自在地共享资源、传递信息。
现在,你在自家(http://我的温馨小窝.com
里的JavaScript代码)突然发现酱油用完了,想去“隔壁小区的超市”(比如 http://api.万能小超市.com
)借一瓶(获取最新的商品信息),或者想给住在另一个城市的朋友(不同源的API)寄点土特产(发送数据)。这种“出远门”去别人地盘办事的行为,就叫做“跨域请求”。
生活案例:点个外卖,咋就“跨域”了?
http://我的美食App界面.com
—— 这是你手机上漂亮的点餐界面。http://api.美味餐厅后厨.com
—— 这里才是真正处理菜单数据、接收订单的地方。当你在你的App界面上兴高采烈地点“查看今日特价菜”时,你的界面(前端脚本)就需要向“美味餐厅后厨”的API(后端)喊一嗓子:“老板,今天有啥好吃的,菜单亮出来瞅瞅!”
糟糕!因为 http://我的美食App界面.com
和 http://api.美味餐厅后厨.com
的“门牌号”对不上(域名不同),它们不是“自家人”。这时候,你家最忠诚的“保安大爷”(浏览器)就会立刻站出来,警惕地一挥手:“等等!小伙子,你去‘外人家’要东西,这不合规矩,我得先拦着你!” —— 咚!你的跨域请求就被英勇的“保安大爷”给阻止了。
你可能觉得“保安大爷”(浏览器)有点多管闲事,但其实他是为了保护你家的“财产安全”和“隐私”!
想象一下,如果没有这道“门禁”,会发生什么可怕的事情?
http://我是坏蛋张三.com
)。与此同时,你可能刚好在另一个浏览器标签页登录着你的“网上银行”(http://我的重要银行.com
)。如果“保安大爷”不设防,那么“坏蛋张三”网站上的脚本,就能在你完全不知情的情况下,偷偷摸摸地用你的名义,向你的“网上银行”发送指令,比如“快给我把钱转到张三账户上!” 想想都后怕!所以,“同源策略”这位严格的“保安大爷”,虽然有时候让我们感觉不方便,但它确确实实是保护我们网上冲浪安全最重要的防线之一!
虽然“保安大爷”很严格,但也不是完全不通人情,有些“邻里互动”是被允许的:
“远观”和“邀请”通常可以:
)看看隔壁“天气预报小区”的花园里的花花草草(嵌入一个跨域页面)。但注意,你只能看,你家管家(脚本)可不能直接跳出窗户去人家花园里摘花或者指挥人家园丁干活(你的脚本通常不能直接操作iframe里的跨域内容,除非对方也同意并且用了特殊的沟通方式,比如postMessage
)。![]()
标签引用跨域图片是允许的)。
或
引用跨域的JavaScript库或CSS样式文件)来家里热闹热闹或装修一番。这些被认为是主人(你)主动邀请的,而且它们主要是在你家地盘上发挥作用(尽管JS脚本的能力很强,但其对其他源的直接DOM访问等核心操作仍受同源策略的严格限制)。JSONP这种老办法,当年就是巧妙地利用了“戏班子可以随便请”这个“漏洞”。主要限制的是“深入交流”和“私自拿取”: “保安大爷”主要盯防的是,你家脚本(JavaScript)不能直接跑到“别人家”(不同源)的“抽屉”(数据存储)里翻看人家的“账本”(通过 Workspace
或 XMLHttpRequest
读取响应数据),也不能在人家“墙上”乱写乱画(修改其他源的DOM内容)。
如果你想给“别人家”(比如 http://api.慈善机构.com
)送一份爱心包裹(比如通过POST请求捐款),你的“快递员”(浏览器)通常能把您的包裹送到对方门口,对方(服务器)也可能高高兴兴地收下了。
但是,当对方签收后,给你一张写着“感谢您的慷慨解囊,这是你的捐赠编号XXX”的“回执单”(服务器的响应),您的“快递员”(前端脚本)想拿回来仔细看看上面的具体内容时,“保安大爷”(浏览器)可能又会站出来,眯着眼睛说:“等等!这回执单是‘别人家’开出来的,上面的详细内容,按规矩,不一定能让你全看清楚哦!” 也就是说,你的捐款请求可能已经成功被服务器处理了,但你的前端脚本可能无法读取到服务器返回的完整响应信息(比如自定义的响应头或响应体),除非“别人家”在“回执单”上明确贴了“CORS欢迎告示”,说:“这份回执的详细内容,尽管看,没关系!”
请一定记住,执行这些“不准乱串门”、“看了也可能不让你全看明白”的规矩的,主要是您家这位忠心耿耿、时刻保持警惕的“小区保安队长”——您的浏览器!
很多时候,你感觉跨域请求失败了,可能并不是“别人家”(目标服务器)真的把你拒之门外,而是你的“保安队长”(浏览器)在请求发出前(比如预检请求阶段)或收到响应后,发现整个过程不符合“社区安全规定”(同源策略以及后续的CORS规则),为了保护您,主动“劝返”了这次“出行”或“扣押”了“外来包裹”。当然,如果目标服务器没有正确配置CORS,那它实际上也是间接导致了“保安队长”的行动。
所以你看,“同源策略”这位严格的“保安大爷”,虽然有时让我们在需要从不同来源获取数据时感觉有点“碍手碍脚”,但它确确实实是我们畅游互联网世界的安全基石。
当然,时代在进步,“小区”之间的友好往来和“贸易合作”也是大势所趋。我们总不能因为安全就把所有门都关死。因此,为了在确保安全的前提下,让合法的、双方都同意的“跨小区信息共享”成为可能,后来就诞生了更完善、更精细的“社区友好访问与贸易规则”——也就是我们接下来要深入探讨的 CORS(跨源资源共享)。它就像是为信得过的“国际友人”办理的“多次往返签证”和“海关绿色通道”,让“跨域”这件原本麻烦事,变得既安全又合规!
想象一下我们的互联网世界就像一个地球村,每个网站(比如 http://我家网站.com
)都是一个独立的“国家”。
我家网站.com
)内部,你的“国民”(页面上的JavaScript脚本)可以自由活动,互相访问资源,没有任何限制。比如,我家网站.com/page1.html
上的脚本想访问 我家网站.com/api/data
的数据,畅通无阻。http://别人家API.com
)去拿东西(请求数据)或搞建设(发送数据),除非那个国家明确表示欢迎你,并且手续齐全。这就好比,你不能随便闯入别人家里拿东西。随着“地球村”的发展,各个“国家”之间需要进行贸易往来(比如,你的网站需要调用天气API、地图API、或者公司内部不同子域名下的微服务API)。如果完全死守“出国限制”,那全球贸易就没法做了。
为了解决这个问题,国际社会(W3C组织)制定了一套详细的“国际旅行与海关通行协议”,这就是 CORS。它不是简单的一张“通行证”,而是一整套精密的规则和流程,规定了“国家A”的“国民”(浏览器脚本)如何才能安全、合规地访问“国家B”(目标服务器)的资源。
核心角色:
http://我家网站.com
的脚本):一位想要出国访问或进行贸易的“本国公民”。http://别人家API.com
):你想访问的“外国”。有些请求比较简单,风险较低,比如你只是想给“外国”寄一封平信(GET请求,且只用了些标准信封和邮票,即标准头部),或者填个简单的入境卡(HEAD请求,或者某些特定类型的POST请求)。
http://别人家API.com/news
的新闻。你告诉浏览器:“我要去‘别人家API国’看看新闻。”别人家API.com
的请求中,自动加入一个 Origin
头部(你的“护照”): Origin: http://我家网站.com
(意思是:“此请求来自‘我家网站国’”)别人家API.com
服务器收到请求后,它的“移民局”(服务器上的CORS处理逻辑)会查看你的“护照”(Origin
头部)。然后,它会查阅本国的“对外国公民入境政策告示板”(服务器CORS配置)。
Access-Control-Allow-Origin: http://我家网站.com
(明确欢迎“我家网站国”的公民),或者 Access-Control-Allow-Origin: *
(本国完全对外开放,欢迎任何人——但这通常不推荐用于需要身份验证的场景,因为太宽松了!)。Access-Control-Allow-Origin
响应头)一起送回到你的浏览器。 你的“旅行管家”(浏览器)会严格检查这个“许可戳”:
Access-Control-Allow-Origin: http://我家网站.com
或者 Access-Control-Allow-Origin: *
,浏览器一看,“哦,对方国家明确表示欢迎我国公民,手续齐全!” 于是,它就把新闻数据交给你(前端脚本)。Access-Control-Allow-Origin: http://隔壁老王家.com
),浏览器就会说:“抱歉,对方国家没给你发签证,或者签证发错了人!根据规定,这份回复(数据)不能给你。” 然后你就会在控制台看到CORS错误。如果你想做的不是简单的“观光”,而是复杂的“商务考察”(比如用 PUT
、DELETE
方法,或者 POST
一些特殊格式的数据如 application/json
),或者你想“携带特殊行李”(发送自定义的HTTP头部,如 X-Custom-Auth-Token
),情况就复杂了。
POST
请求,Content-Type: application/json
),并且我要佩戴我的‘VIP徽章’(自定义头部 X-VIP-Pass: true
)。”OPTIONS
预检请求) 浏览器说:“哎呀,你这个请求有点复杂,又是JSON又是VIP徽章的。根据‘国际通行协议’,我得先替你发一份‘外交照会’(OPTIONS
请求)给‘别人家API国’,问问他们允不允许我们国家的人以这种方式、带这些东西进行访问。这叫‘预检’,确保万无一失。” 这份“外交照会” (OPTIONS
请求) 会包含以下信息:
Origin: http://我家网站.com
(“我是‘我家网站国’的外交代表”)Access-Control-Request-Method: POST
(“我们计划用POST方式进行正式访问”)Access-Control-Request-Headers: Content-Type, X-VIP-Pass
(“我们计划在正式访问时,信件上会使用JSON格式,并佩戴VIP徽章”)别人家API.com
服务器收到这份“外交照会”后,它的“外交部”(CORS处理逻辑)会查阅本国的“对外国复杂访问的详细政策规定”。 如果政策允许,它的回复(对OPTIONS
请求的响应)会包含一系列“官方批文”,详细说明它允许什么:
Access-Control-Allow-Origin: http://我家网站.com
(“是的,我们欢迎‘我家网站国’的代表进行此类复杂访问。”)Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
(“对于贵国代表,我们允许这些访问方式。”——这里必须包含你预检请求中Access-Control-Request-Method
里声明的方法,比如POST
)Access-Control-Allow-Headers: Content-Type, X-VIP-Pass, Authorization
(“对于贵国代表,我们允许信件使用JSON格式,并接受VIP徽章和身份授权证明。”——这里必须包含你预检请求中Access-Control-Request-Headers
里声明的头部)Access-Control-Max-Age: 86400
(可选:“这份‘外交许可’有效期24小时。24小时内,贵国代表如果再进行类似的复杂访问,就不用重复发‘外交照会’了,可以直接来。”——这能减少不必要的预检请求,提升性能。)Access-Control-Allow-Origin
)POST
)在允许列表里吗?(Access-Control-Allow-Methods
)Content-Type
, X-VIP-Pass
)在允许列表里吗?(Access-Control-Allow-Headers
) 如果所有条件都满足,浏览器就说:“太好了!‘外交照会’成功,‘别人家API国’已经为你的这次‘商务考察’开了绿灯!”POST
请求(带着JSON数据和X-VIP-Pass
头部)。这个请求的后续处理流程(服务器验证Origin
、返回数据和Access-Control-Allow-Origin
等)就和上面的“简单请求”类似了。 如果预检失败(比如对方国家不允许POST
,或者不允许你带X-VIP-Pass
),浏览器会直接阻止后续的真实请求,并在控制台报错。Access-Control-Allow-Credentials
)
Workspace
或 XHR)在发起请求时必须明确说:“我要带上我的凭证”(例如 Workspace(url, { credentials: 'include' })
)。Access-Control-Allow-Origin: http://我家网站.com
这里不能是*
号通配符!),还必须明确声明:Access-Control-Allow-Credentials: true
(“我们认可并接受贵国公民使用‘外交护照’或‘信用卡’!”)。Access-Control-Allow-Origin: *
,那么 Access-Control-Allow-Credentials: true
是无效的,浏览器不会发送凭证。因为允许任何人带凭证访问太危险了。Access-Control-Expose-Headers
)
X-RateLimit-Remaining
告诉你你还剩多少次访问机会,或者 ETag
用于缓存)。Access-Control-Expose-Headers: X-RateLimit-Remaining, ETag
(“我们特许贵国公民查看这两个印章。”)。这样,你的前端JavaScript才能通过 getResponseHeader()
方法访问到这些特定的响应头。Origin
头部:由浏览器自动添加到跨域请求中,告知服务器请求的来源。Access-Control-Allow-Origin
响应头:服务器最重要的CORS响应头,告知浏览器哪些源被允许访问。可以是单个具体源,或*
(不推荐与凭证一起用)。OPTIONS
):
PUT
、DELETE
,或带自定义头、Content-Type: application/json
的POST
等)会先触发一个由浏览器自动发起的 OPTIONS
预检请求。Access-Control-Request-Method
, Access-Control-Request-Headers
。Access-Control-Allow-Methods
, Access-Control-Allow-Headers
,以及可选的 Access-Control-Max-Age
。credentials
):跨域发送Cookie等凭证信息需要前后端双方都明确同意 (credentials: 'include'
和 Access-Control-Allow-Credentials: true
,且 Access-Control-Allow-Origin
不能是 *
)。Access-Control-Expose-Headers
):允许前端JS访问默认不可见的响应头。CORS看起来复杂,但理解了这套“国际旅行和海关检查”的逻辑后,就能明白每个环节都是为了在便利数据交换的同时,最大限度地保障网络安全。它是一套非常重要且设计精巧的机制!
下面是一个前端HTML页面,旨在演示客户端如何发起跨域资源共享 (CORS) 请求。页面包含两个按钮:“获取菜单 (GET)” 和 “下订单 (POST)”,用户点击按钮后会通过 JavaScript 调用后端 API。代码的核心是一个名为 makeApiRequest
的通用异步函数,它封装了 Workspace
API 调用,负责处理请求的发送、加载状态的UI更新、响应的JSON解析、错误捕获与显示,以及按钮的禁用/启用逻辑以优化用户体验。此前端页面被配置为与运行在 http://localhost:3000
的后端服务进行通信,并通过发送自定义HTTP头部 (X-Requested-With
) 来确保某些请求会触发CORS预检。
CORS 跨域请求示例
餐厅菜单
点击按钮加载数据...
cors
中间件库)下面是一个使用 Node.js 和 Express 框架构建的后端 API 服务器,其核心功能是演示如何通过引入并配置 cors
这个流行的第三方中间件来处理跨域资源共享 (CORS) 请求。服务器通过 corsOptions
对象精细化配置了CORS策略,包括动态校验请求来源 (origin
)是否在允许的白名单内、定义允许的HTTP方法和请求头、以及启用凭证(credentials
)支持和预检请求的缓存时间(maxAge
)。此服务器提供了两个API端点:/menu
(GET方法) 用于获取菜单数据,/order
(POST方法,需要 express.json()
中间件解析请求体) 用于接收订单。该示例服务器配置为监听 3001
端口 (前端示例代码默认请求 3000
端口,实际运行时需注意两者端口匹配或修改其中一方配置)。
// 使用 cors 中间件库的示例
const cors = require('cors'); // 1. 引入 'cors' 模块
const express = require('express');
const app = express();
// 2. 定义允许的源列表
const allowedOrigins = ['http://localhost:8080', 'http://127.0.0.1:8080', 'http://localhost:3000']; // 添加前端的源或测试源
// 3. 配置 cors 中间件的选项
const corsOptions = {
origin: function (origin, callback) { // 3a. 动态判断请求源是否允许
// 允许没有origin的请求 (例如服务器到服务器的直接调用, 或 Postman 等工具, 或直接打开 file:// HTML)
// 或者 origin 在允许列表中
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true); // 允许该源
} else {
console.warn(`CORS: Origin ${origin} not allowed.`);
callback(new Error('Not allowed by CORS')); // 拒绝该源
}
},
methods: 'GET,POST,PUT,DELETE,OPTIONS', // 3b. 允许的HTTP方法
allowedHeaders: 'Content-Type,Authorization,X-Requested-With', // 3c. 允许的请求头
credentials: true, // 3d. 是否允许发送Cookie (如果为true, origin不能为'*')
maxAge: 3600 // 3e. 预检请求结果的缓存时间 (秒)
};
// 4. 应用 cors 中间件
app.use(cors(corsOptions));
// 5. (可选) cors 中间件通常会自动处理 OPTIONS 预检请求
// --- API 路由 ---
app.get('/menu', (req, res) => {
console.log(`收到来自 ${req.headers.origin || 'unknown'} 的 /menu GET 请求 (使用 cors 中间件)`);
res.json({ message: 'Menu data from server with CORS middleware (Port 3000 or 3001)', items: [{id:1, name:"CORS宫保鸡丁"}] });
});
app.post('/order', express.json(), (req, res) => { // express.json() 用于解析JSON请求体
console.log(`收到来自 ${req.headers.origin || 'unknown'} 的 /order POST 请求 (使用 cors 中间件)`);
console.log('订单内容:', req.body);
res.status(201).json({ message: 'Order received by server with CORS middleware', orderDetails: req.body });
});
const PORT_CORS_LIB = 3000; // 改为3000以匹配前端apiUrlBase,或保持3001并在前端修改
app.listen(PORT_CORS_LIB, () => {
console.log(`CORS API 服务器 (使用 cors 库) 已启动,正在监听 http://localhost:${PORT_CORS_LIB}`);
});
这段代码同样是使用 Node.js 和 Express 框架构建的后端 API 服务器,但它演示了不依赖任何第三方CORS库,而是通过手动编写自定义中间件来配置和处理跨域资源共享 (CORS) 的方法。在该自定义中间件中,服务器逻辑直接检查请求的 Origin
头部是否在预定义的允许来源白名单内,并相应地手动设置 Access-Control-Allow-Origin
、Access-Control-Allow-Methods
和 Access-Control-Allow-Headers
等CORS响应头。此外,它还显式地处理了浏览器的 OPTIONS
预检请求,通过返回 204 No Content
状态码来表示允许后续的实际请求。服务器也提供了 /menu
(GET) 和 /order
(POST) 两个API端点,并配置为监听 3000
端口,可以直接与前端示例代码的目标端口匹配。
// 手动配置 CORS 头部
const expressManual = require('express');
const appManual = expressManual();
const PORT_MANUAL_CORS = 3000;
// --- CORS 配置中间件 (手动) ---
appManual.use((req, res, next) => {
const allowedOrigins = ['http://localhost:8080', 'http://127.0.0.1:8080', 'http://localhost:3000']; // 确保包含前端源
const origin = req.headers.origin;
if (!origin || allowedOrigins.includes(origin)) { // 处理 origin 未定义或在白名单中
res.setHeader('Access-Control-Allow-Origin', origin || '*'); // 如果 origin 未定义,可考虑返回 '*' 或特定默认值,但动态设置更安全
// 当允许凭证时,这里不能是 '*'
} else {
console.warn(`Manual CORS: Origin ${origin} not in allowed list.`);
// 如果源不在白名单中,不设置CORS头部,浏览器将阻止请求
// 或者可以显式返回错误,但通常让浏览器处理更好
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
// 如果需要凭证,取消注释下一行,并确保 Access-Control-Allow-Origin 是具体源而不是 '*'
// res.setHeader('Access-Control-Allow-Credentials', 'true');
// res.setHeader('Access-Control-Max-Age', '3600');
if (req.method === 'OPTIONS') {
// 确保对 OPTIONS 请求也正确设置了允许的源
// 如果上面的 if 条件不满足,这里的 OPTIONS 响应可能没有 Access-Control-Allow-Origin
// 最好是在检查通过后才发送204
if (!origin || allowedOrigins.includes(origin)) {
return res.sendStatus(204);
} else {
// 对于不允许的源的OPTIONS请求,可以不响应或返回错误
// 但通常浏览器会因缺少CORS头部而失败
return res.sendStatus(403); // Forbidden for OPTIONS from disallowed origin
}
}
next();
});
// --- API 路由 ---
appManual.get('/menu', (req, res) => {
console.log(`收到来自 ${req.headers.origin || 'unknown'} 的 /menu GET 请求 (手动配置 CORS)`);
res.json({
message: `成功从端口 ${PORT_MANUAL_CORS} 的API获取菜单数据!(手动配置)`,
items: [
{ id: 1, name: '手动宫保鸡丁', price: 32.50 },
{ id: 2, name: '手动鱼香肉丝', price: 30.00 }
]
});
});
appManual.post('/order', expressManual.json(), (req, res) => {
console.log(`收到来自 ${req.headers.origin || 'unknown'} 的 /order POST 请求 (手动配置 CORS)`);
console.log('订单内容:', req.body);
res.status(201).json({
message: '订单已收到!(手动配置)',
orderDetails: req.body
});
});
appManual.listen(PORT_MANUAL_CORS, () => {
console.log(`CORS API 服务器 (手动配置) 已启动,正在监听 http://localhost:${PORT_MANUAL_CORS}`);
});
cors
中间件库与手动配置CORS头部对比:特性 | 代码一 (cors 中间件库) |
代码二 (手动配置CORS头部) |
---|---|---|
实现方式 | 使用第三方 cors 库,配置驱动。 |
手动编写中间件,通过 res.setHeader 设置。 |
简洁性 | 更高。CORS逻辑被封装,代码量较少。 | 相对冗长,所有逻辑显式编写。 |
可读性 | 对于熟悉cors 库的开发者,意图清晰。 |
逻辑直接可见,有助于理解CORS头部如何设置。 |
可维护性 | 依赖库的更新和维护。通常更稳定,因库会处理边缘情况。 | 完全由开发者维护,需确保覆盖所有CORS规范细节。 |
依赖 | 引入外部 cors npm包。 |
无额外CORS相关npm包依赖 (仅 Express)。 |
origin 处理 |
强大灵活:支持字符串、正则、数组、函数。示例中函数能处理!origin 的情况。 |
示例中通过includes 检查,优化后也处理!origin (但需谨慎处理* 与凭证)。 |
OPTIONS 处理 |
通常由库自动处理,更可靠。 | 显式通过 if (req.method === 'OPTIONS') 处理,需确保逻辑完整。 |
credentials |
通过 corsOptions.credentials = true 启用。 |
通过 res.setHeader('Access-Control-Allow-Credentials', 'true') (示例中需取消注释并小心配置Origin )。 |
maxAge |
通过 corsOptions.maxAge = 3600 启用。 |
通过 res.setHeader('Access-Control-Max-Age', '3600') (示例中需取消注释)。 |
Vary: Origin |
cors 库通常会自动添加此重要响应头,优化缓存。 |
手动实现中未包含,但可以添加 (res.setHeader('Vary', 'Origin') )。 |
完整性/健壮性 | 通常更高,库会考虑更多CORS规范的细节和边缘情况。 | 取决于手动实现的细致程度,可能遗漏某些细节。 |
学习价值 | 学习如何使用和配置常用中间件。 | 非常适合深入理解CORS底层机制和HTTP头部。 |
这个方法啊,就好比是点外卖时,你跟一家平时只做堂食、不直接外送的“网红私房菜馆”(不同域的服务器)想办法要到他们菜单上的“隐藏菜品”(数据)。
handleMySecretDish
) 快到碗里来!” 这个口号是你自己定的,而且全小区(你的网页全局作用域)都能看到。当外卖小哥喊这个口号并递上食物时,你就知道是给你的,然后你就按这个口号的指示去“处理 (handleMySecretDish
)”这份美食。 (这就是你在前端定义一个全局JavaScript函数,比如 function handleMySecretDish(dishData) { display(dishData.name); }
)
标签)。这个小哥很神奇,他能进出任何餐厅(任何域名)的“打包外带窗口”(服务器的特定资源路径)去取东西,而且小区保安(同源策略)从不拦他,因为他拿回来的包裹看起来都像是“公开的宣传单或说明书”(JavaScript脚本),保安觉得这玩意儿没啥秘密,可以直接让你看。http://私房菜API.com/getSecretDish
),帮我要一份‘秘制咕咾肉’。最最重要的是,拿到菜之后,让他们在打包盒上用大字写上我的‘专属接菜口号’——‘芝麻开门,美食 (handleMySecretDish
) 快到碗里来!’,后面再附上菜品详情。” (这就是你动态创建一个
标签,它的src
是 http://私房菜API.com/getSecretDish?myOrderCallback=handleMySecretDish
。myOrderCallback=handleMySecretDish
就是你告诉餐厅,他们准备好“菜”之后,要用哪个“口号”来包装。)handleMySecretDish({name: "秘制咕咾肉", ingredients: "糖、醋、排骨..."});
。
标签里的内容)。 当读到“芝麻开门,美食 (handleMySecretDish
) 快到碗里来!……”的时候,你家那个写着同样口号的“接收机制”(handleMySecretDish
函数)立刻就被触发了,它“听”到了后面的菜品详情,于是你就知道“秘制咕咾肉”是怎么回事了,数据到手!
的“特权”:这个标签就像个能自由出入各处的“跑腿小哥”,不受“小区保安”的严格盘查。callback=我的函数名
)把你的“接头暗号”告诉远方的服务器。这段代码用于演示如何通过JSONP (JSON with Padding) 技术发起跨域请求以获取新闻数据。它定义了一个全局回调函数 handleNewsData
,该函数负责处理从服务器接收到的数据并更新页面DOM。核心功能 WorkspaceNewsViaJsonp
动态创建一个 标签,将其
src
指向后端API,并通过URL参数传递回调函数名。此优化版本加入了请求超时处理逻辑,并在请求成功、失败或超时后自动从DOM中移除动态添加的脚本标签,以保持页面整洁和管理资源。
JSONP 示例客户端
JSONP 新闻获取示例
正在加载新闻...
这是一个使用 Node.js 和 Express 框架构建的后端 API 服务器。专门用于响应JSONP请求,并进行了安全性和健壮性优化。服务器在 /getnews
端点监听GET请求。核心优化在于它会严格校验前端通过URL参数传递的回调函数名,确保其符合合法的JavaScript标识符规范,以防止潜在的跨站脚本(XSS)攻击。服务器准备好JSON数据后,会将其序列化,并在序列化过程外层包裹try...catch
块以处理潜在错误。最后,它将数据包装在经过验证的回调函数调用中(例如 validCallbackName({"key":"value"})
),并将响应的 Content-Type
正确设置为 application/javascript
。该服务器运行在 3001
端口。
// 1. 引入 express 模块
const express = require('express');
// 2. 创建 express 应用实例
const app = express();
// 3. 定义服务器端口
const PORT = 3001;
// (可选) 中间件,用于记录请求,方便调试
app.use((req, res, next) => {
console.log(`收到请求: ${req.method} ${req.url} from ${req.headers.origin || 'unknown origin'}`);
next();
});
// 4. 定义 /getnews 路由来处理 JSONP 请求 (优化版)
app.get('/getnews', (req, res) => {
const callbackParamName = 'myCallbackFunctionName'; // 与前端约定的参数名
const callbackName = req.query[callbackParamName];
// 4a. 优化:严格校验回调函数名
if (!callbackName) {
return res.status(400).type('text/plain').send(`错误:缺少回调函数名参数 (${callbackParamName})。`);
}
// 基本的JavaScript标识符正则表达式 (不完全覆盖所有Unicode情况,但对常见场景足够)
// 一个更严格或全面的正则可以根据需求调整
const validCallbackRegex = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
if (!validCallbackRegex.test(callbackName)) {
console.warn(`检测到无效的回调函数名: ${callbackName}`);
return res.status(400).type('text/plain').send('错误:回调函数名包含无效字符。');
}
// 准备要发送给前端的数据
const newsData = {
title: "今日优化要闻:JSONP 安全性提升!",
content: "服务器端已增加回调函数名校验,增强了安全性。",
timestamp: new Date().toISOString(),
source: "优化版JSONP服务器"
};
// 5. 设置响应头 Content-Type 为 application/javascript
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
// 可选:增加额外的安全头,指示浏览器不要猜测内容类型
res.setHeader('X-Content-Type-Options', 'nosniff');
// 6. 构建并发送 JSONP 响应 (优化:增加 try-catch)
try {
// 优化:对于JSON字符串中的特殊JS字符,如行分隔符和段落分隔符进行转义
// 虽然现代浏览器大多能正确处理,但这是更安全的做法
const jsonDataString = JSON.stringify(newsData)
.replace(/\u2028/g, '\\u2028') // Line separator
.replace(/\u2029/g, '\\u2029'); // Paragraph separator
const responseBody = `${callbackName}(${jsonDataString})`;
console.log(`发送 JSONP 响应: ${responseBody}`);
res.send(responseBody);
} catch (error) {
console.error('序列化JSON数据或构建响应时出错:', error);
// 如果序列化失败,不应尝试调用回调,否则前端可能执行一个不完整的函数调用
// 发送一个服务器错误,前端的 script.onerror 可能会捕获到加载问题(取决于具体错误)
// 或者,可以尝试发送一个调用回调并传递错误对象的JSONP响应
// 例如: res.send(`${callbackName}(${JSON.stringify({ error: "服务器内部错误" })})`);
// 这里选择发送500错误文本,前端onerror会处理网络层面的失败。
res.status(500).type('text/plain').send('服务器内部错误:无法处理您的请求。');
}
});
// (可选) 一个简单的根路径路由
app.get('/', (req, res) => {
res.send('优化版 JSONP 示例服务器正在运行!');
});
// 7. 启动服务器并监听指定端口
app.listen(PORT, () => {
console.log(`优化版 JSONP 示例服务器已启动,正在监听 http://localhost:${PORT}`);
console.log(`前端页面 (jsonp_client.html) 应配置为从此服务器获取数据。`);
console.log(`测试URL示例: http://localhost:${PORT}/getnews?myCallbackFunctionName=someFunction`);
});
想象一下:
http://我的外卖APP界面.com
):就是你自己,舒舒服服地待在你的“豪宅”里 (你的网站)。http://api.餐厅后台.com
):这是“市中心的高级餐厅厨房”。这家餐厅很讲究,或者说安保严格,不接受陌生人直接闯进后厨点单或索要菜单 (这就是浏览器的同源策略,它不允许你的网页随便去访问不同域名下的资源,怕出乱子)。http://我的外卖APP界面.com/api/proxy-menu
):这是你“豪宅”里一位非常得力、人脉广、且被你完全信任的“大管家”。他的办公室就在你“豪宅”里的一间房,门牌号是 /api/proxy-menu
。现在,你想点这家高级餐厅的菜,要看菜单:
http://我的外卖APP界面.com/api/proxy-menu
这个地址发送请求。因为“管家办公室”就在你的“豪宅”内部,这属于内部事务,安全又便捷——这就是“同源”访问!浏览器保安一看,哦,自己家的人跟自己家的人说话,不管不管,随便说!)http://api.餐厅后台.com
发送了获取菜单的请求。)http://api.餐厅后台.com
把菜单数据返回给了你的代理服务器。)/api/proxy-menu
这个路径,返回给你(前端浏览器)。)对你(前端)来说: 整个过程,你都只是在“豪宅”里和你的“大管家” (/api/proxy-menu
) 打了个招呼。你就没“离开过豪宅大门”,所以浏览器的“小区保安”(同源策略)全程绿灯,一点麻烦都没有!所有“出远门”、“和外人打交道”、“可能被盘问”的“脏活累活”,全都由你家能干的“大管家”代劳了。你只需要安坐家中,就能轻松拿到想要的东西。
这个“管家”就是代理服务器,它帮你把“跨小区”(跨域)的事情,变成了“小区内部”(同源)的事情来处理,从而巧妙地绕过了限制。
proxy_client.html
)这是个前端HTML页面用于演示客户端如何通过同源代理来获取跨域数据。页面包含一个按钮,点击后会调用 JavaScript 函数 WorkspaceMenuViaProxy
。该函数使用 Workspace
API 向其自身所在的服务器(代理服务器,运行在 localhost:8000
)上的特定路径 (/api/proxy/menu
) 发起一个同源请求。代理服务器接收到此请求后,会负责向真正的第三方API服务器获取数据,并将结果返回给前端。前端随后将获取到的菜单数据或发生的任何错误信息展示在页面上。这种方式对浏览器来说是透明的,因为它只与同源的代理服务器交互,从而绕过了浏览器的同源策略限制。
代理服务器获取菜单示例
餐厅菜单 (通过代理服务器获取)
mock_restaurant_api.js
)这是一个使用Node.js和Express框架创建的极简服务器,其目的是模拟一个真实的第三方餐厅API服务。它运行在 localhost:3001
端口,并提供一个主要的GET请求端点 /menu
以及一个模拟错误的 /menu-error
端点。当 /menu
端点被访问时,服务器会模拟网络延迟后返回一个包含固定JSON格式的菜单数据,并附带来源说明和时间戳。此服务器主要用于配合代理服务器示例进行测试,让代理服务器有目标数据源可供请求。它也配置了基础的CORS支持,方便在必要时直接从浏览器进行测试。
// mock_restaurant_api.js
const express = require('express');
const app = express();
const PORT = 3001;
// 优化:添加基础的CORS中间件,方便直接从浏览器测试此mock API (如果需要)
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*'); // 允许任何源
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
if (req.method === 'OPTIONS') {
return res.sendStatus(200); // Pre-flight
}
next();
});
app.get('/menu', (req, res) => {
console.log(`[模拟餐厅API] 收到 /menu GET 请求 from ${req.headers.origin || 'unknown'}`);
// 模拟网络延迟
setTimeout(() => {
const menuData = {
source: `模拟餐厅API (端口 ${PORT})`,
items: [
{ id: 101, name: '豪华海鲜披萨', price: 88.00 },
{ id: 102, name: '经典意式肉酱面', price: 58.00 },
{ id: 103, name: '凯撒沙拉配烤鸡胸', price: 42.00 },
{ id: 104, name: '香草拿铁(大杯)', price: 35.00}
],
timestamp: new Date().toISOString()
};
res.json(menuData);
}, 500 + Math.random() * 1000); // 模拟0.5到1.5秒的延迟
});
// 优化:添加一个模拟错误的接口,方便测试代理的错误处理
app.get('/menu-error', (req, res) => {
console.log(`[模拟餐厅API] 收到 /menu-error GET 请求 - 将返回错误`);
setTimeout(() => {
res.status(503).json({
source: `模拟餐厅API (端口 ${PORT})`,
message: "服务暂时不可用 (模拟错误)",
errorCode: "SERVICE_UNAVAILABLE"
});
}, 300);
});
app.listen(PORT, () => {
console.log(`[模拟餐厅API] 服务器已启动,正在监听 http://localhost:${PORT}`);
console.log(` 提供菜单数据接口: http://localhost:${PORT}/menu`);
console.log(` 模拟错误接口: http://localhost:${PORT}/menu-error`);
});
proxy_server.js
)这是一个核心的Node.js和Express应用,它扮演双重角色:
proxy_client.html
),使得用户可以通过浏览器访问 localhost:8000
来加载和与之交互。/api/proxy/:path(*)
。当客户端请求此端点时(例如/api/proxy/menu
),该服务器会使用 Workspace
API(Node.js 18+内置或 node-fetch
模块)代表客户端向配置的真实第三方API(默认 http://localhost:3001
,可通过环境变量 TARGET_API_URL
修改)的对应路径发起HTTP请求。它会选择性地转发部分客户端请求头,并将目标API的响应(包括Content-Type
头和状态码)尽可能原样地或经过处理后返回给前端客户端。这种方式有效解决了浏览器端的跨域请求问题,并将跨域的复杂性转移到了服务器端。服务器还包含了对代理过程中可能发生的网络错误或目标API错误的捕获和处理逻辑。// proxy_server.js
const express = require('express');
const path = require('path'); // 用于处理文件路径
let fetch;
async function initializeFetch() {
if (typeof global.fetch === 'function') {
fetch = global.fetch;
console.log("[代理服务器] 使用内置 fetch API.");
} else {
try {
const nodeFetch = await import('node-fetch');
fetch = nodeFetch.default;
console.log("[代理服务器] 使用 node-fetch模块.");
} catch (err) {
console.error("[代理服务器] 未找到 fetch 实现 (请确保 Node.js >= 18 或已安装 'node-fetch' v3+,并使用ESM或正确配置)。");
console.error("如果使用 CommonJS, 请 'npm install node-fetch@2' 并用 const fetch = require('node-fetch');");
process.exit(1);
}
}
}
const app = express();
const PORT = 8000; // 代理服务器监听的端口
// 优化:将目标API URL配置为环境变量,如果未设置则使用默认值
const ACTUAL_API_BASE_URL = process.env.TARGET_API_URL || 'http://localhost:3001';
// --- 提供静态前端文件 ---
// 服务当前目录下的静态文件,主要是 proxy_client.html
app.use(express.static(path.join(__dirname)));
// 根路径明确指向 proxy_client.html
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'proxy_client.html'));
});
// --- 代理 API 接口 ---
app.get('/api/proxy/:path(*)', async (req, res) => {
const targetPath = req.params.path; // 例如 "menu" 或 "menu-error"
const clientQuery = req.query; // 获取客户端的查询参数
let targetUrl = `${ACTUAL_API_BASE_URL}/${targetPath}`;
// 将客户端查询参数附加到目标URL
const queryString = new URLSearchParams(clientQuery).toString();
if (queryString) {
targetUrl += `?${queryString}`;
}
console.log(`[代理服务器] 收到前端对 /api/proxy/${targetPath} (参数: ${queryString}) 的 GET 请求`);
if (!fetch) { // 确保 fetch 已初始化
return res.status(500).json({ message: '代理服务器 fetch 未初始化。'});
}
try {
console.log(`[代理服务器] 正在向 ${targetUrl} 请求数据...`);
const headersToForward = {
'Accept': req.headers.accept || 'application/json',
'User-Agent': req.headers['user-agent'] || 'ExpressProxy/1.0'
// 根据需要谨慎添加其他要转发的头,如 'Accept-Language'
// 避免转发 'Host', 'Cookie' (除非有特定且安全的理由)
};
// if (req.headers.authorization) { // 如果需要转发认证头
// headersToForward['Authorization'] = req.headers.authorization;
// }
const apiResponse = await fetch(targetUrl, { headers: headersToForward });
// 将第三方API的响应头也部分透传给客户端
res.setHeader('Content-Type', apiResponse.headers.get('Content-Type') || 'application/json');
// 可以考虑透传其他有用的响应头,例如 ETag, Cache-Control 等,但需谨慎
// apiResponse.headers.forEach((value, name) => {
// if (['content-type', 'content-length', 'etag', 'cache-control'].includes(name.toLowerCase())) {
// res.setHeader(name, value);
// }
// });
if (!apiResponse.ok) {
const errorBodyText = await apiResponse.text();
console.warn(`[代理服务器] 第三方API (${targetUrl}) 返回错误: ${apiResponse.status}`);
try {
const jsonData = JSON.parse(errorBodyText); // 尝试解析为JSON
return res.status(apiResponse.status).json(jsonData);
} catch(e) { // 如果不是JSON,返回原始文本
return res.status(apiResponse.status).send(errorBodyText);
}
}
// 假设目标API总是返回JSON,如果不是,这里需要更复杂的处理
const data = await apiResponse.json();
console.log(`[代理服务器] 从第三方API (${targetUrl}) 获取到数据,准备返回给前端。`);
res.json(data);
} catch (error) {
console.error('[代理服务器] 代理请求过程中发生严重错误:', error);
res.status(502).json({ // 502 Bad Gateway 通常表示代理从上游服务器收到无效响应
message: '代理服务器在请求上游服务时发生错误。',
errorDetails: error.message,
requestedUrl: req.originalUrl,
targetUrl: targetUrl
});
}
});
// 初始化 fetch 后启动服务器
initializeFetch().then(() => {
app.listen(PORT, () => {
console.log(`[代理服务器] 服务器已启动,正在监听 http://localhost:${PORT}`);
console.log(` 前端页面访问: http://localhost:${PORT}`);
console.log(` 代理接口示例: http://localhost:${PORT}/api/proxy/menu (GET)`);
console.log(` 代理接口将请求转发至: ${ACTUAL_API_BASE_URL}/[path]`);
console.log(` (可通过 TARGET_API_URL 环境变量修改目标API基地址)`);
});
}).catch(err => {
console.error("无法初始化fetch并启动服务器:", err);
});
恭喜你!我们一起“游历”了CORS的“国际邦交规则”,体验了JSONP的“民间智慧”,还请动了代理服务器这位“全能管家”。现在,面对跨域这座大山,你手中已经握有了好几把利器。
简单回顾一下:
标签不受同源策略限制的“特性”,像个“喊麦传话筒”一样让服务器把数据包裹在函数调用里送回来。它简单直接,但仅限GET请求,且存在一定的安全风险(如果目标服务器不可信)。属于“老办法”,现在已不主张使用,了解其原理即可。那么,到底哪招最妙? 答案是:没有一招鲜吃遍天的武功,只有最适合当前场景的招式!
最重要的是,通过这些“小剧场”,希望你不仅学会了这些招式怎么用,更理解了它们背后的“为什么”——一切的起点,都是为了在那个名为“同源策略”的“家规”下,安全、有效地实现我们日益增长的“跨界交流”需求。
跨域并不可怕,理解了原理,选对了方法,它就再也拦不住你探索更广阔Web世界的脚步啦!祝你在前端江湖中越战越勇,所向披靡!