浏览器跨域

1. 先看懂跨域报错

关键词:浏览器是否允许 JS 读取响应。

你启动了一个前端项目,页面跑在:

1
http://localhost:5173

后端接口跑在:

1
http://localhost:8080

页面里写了一句请求:

1
fetch('http://localhost:8080/users')

结果浏览器报跨域。

Postman 能调通,后端日志也正常,为什么浏览器里的 fetch 就不行?更别扭的是,前端和后端都在本机,端口不同就算跨域。

先记住一句话:

跨域不是网络不通。跨域是浏览器不让网页里的 JS 读取不该读的响应。

同一个接口,Postman 能调,curl 能调,后端服务也能调。只有浏览器里的 JS 可能被拦。

因为浏览器里坐着一个已经登录了很多网站的用户。你登录过 bank.com,浏览器里有银行网站的 Cookie。然后你又打开了 evil.com。如果浏览器不做限制,evil.com 里的 JS 就可以偷偷请求 bank.com

1
2
3
4
5
用户打开 evil.com
evil.com 的 JS 请求 bank.com/api/balance
浏览器自动带上 bank.com 的 Cookie
bank.com 返回账户余额
evil.com 的 JS 读到余额

这就是同源策略存在的原因:网页从哪里来,它里面的 JS 默认只能读哪里的数据。

常见报错长这样:

1
2
3
4
Access to fetch at 'http://localhost:8080/users'
from origin 'http://localhost:5173'
has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

这段报错要拆成三块看:

报错片段你要读出什么
fetch at 'http://localhost:8080/users'JS 想请求的接口地址
from origin 'http://localhost:5173'当前页面的来源
No 'Access-Control-Allow-Origin'服务器响应里缺少浏览器要看的放行头

所以这不是在说「接口一定坏了」。它是在说:

1
2
3
4
5
6
浏览器问服务器:
http://localhost:5173 这个页面能读你的响应吗?

服务器没有回答「能」。

浏览器就把响应挡住,不交给 JS。

搞清报错含义后,下一步是看浏览器怎么定义「同一个来源」。

2. 浏览器怎么判断「同一个来源」

浏览器判断两个地址是否同源,只看三样东西:

1
协议 + 域名 + 端口

路径不算。

以这个地址为基准:

1
https://www.example.com:443/user/list

看几个例子:

比较地址是否同源原因
https://www.example.com:443/order/list只有路径不同
http://www.example.com:443/user/list协议不同
https://www.example.com:8080/user/list端口不同
https://api.example.com:443/user/list域名不同
https://example.com:443/user/listwww.example.comexample.com 不是同一个域名
https://www.example.com/user/listHTTPS 默认端口就是 443

所以这两个也不同源:

1
2
http://localhost:5173
http://localhost:8080

它们都在你的电脑上,但端口不同。浏览器不按「是不是本机」判断,它按「协议、域名、端口」判断。

排查时还要分清三个头:

名称白话解释例子
Host这次请求发给谁api.example.com
Origin发起请求的页面来自哪里http://localhost:5173
Referer用户是从哪个完整页面过来的http://localhost:5173/profile

CORS 主要看的是 Origin,不是 Host

同源规则之外,本地开发还有一个容易踩坑的特殊场景:file:// 页面。

3. 为什么 file:// 也会跨域

前后端分离是最常见的跨域场景。file:// 则是另一个很容易让新手误会的坑。

你会觉得:

1
2
file:///Users/you/demo/index.html
file:///Users/you/demo/data.json

它们在同一个文件夹里,应该同源。

但现代浏览器通常不这么想。

如果浏览器说「本地文件都同源」,那你从网上下载了一个 HTML,双击打开后,它就可能尝试读取你电脑上的其他文件。这个边界太危险。

所以浏览器常把 file:// 页面当成一种特殊来源,叫不透明来源。不用背这个词,只要记住它的意思:

1
浏览器不给这个页面一个正常的网站身份。

这种页面发请求时,来源经常会表现成:

1
Origin: null

null 不是「没有跨域问题」。它更像是浏览器在说:这个页面不是普通的 http://域名:端口 来源,我不把它当正常网站处理。

所以本地开发不要双击 HTML。起一个本地服务:

1
python3 -m http.server 3000

然后访问:

1
http://localhost:3000/index.html

此时:

1
2
页面:http://localhost:3000/index.html
数据:http://localhost:3000/data.json

协议、域名、端口都一样。浏览器就不会按跨域处理。

搞清楚来源之后,CORS 这条主线才真正说得通。

4. CORS 到底在放行什么

CORS 的全称是 Cross-Origin Resource Sharing,意思是跨来源资源共享。

白话说:

1
服务器告诉浏览器:哪些页面可以读我的响应。

注意,是服务器告诉浏览器,不是前端自己告诉浏览器。

前端不能靠自己加一个请求头来绕过跨域。因为如果前端能自己决定,恶意网站也能自己决定,安全规则就没意义了。

一次普通 CORS 请求大致如下:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'actorBkg': '#3B82F6', 'actorTextColor': '#FFFFFF', 'actorBorder': '#2563EB', 'signalColor': '#1E3A5F'}}}%%
sequenceDiagram
    participant Page as "页面 localhost:5173"
    participant Browser as "浏览器"
    participant Server as "服务器 localhost:8080"

    Page->>Browser: fetch("/users")
    Browser->>Browser: 检测到跨域
    Browser->>Server: GET /users + Origin: localhost:5173
    Server->>Server: 校验 Origin
    Server->>Browser: 200 + Access-Control-Allow-Origin
    Browser->>Browser: 校验 CORS 响应头
    Browser->>Page: 返回响应体给 JS

最核心的响应头是这个:

1
Access-Control-Allow-Origin: http://localhost:5173

它的意思是:允许 http://localhost:5173 这个页面读取我的响应。

如果服务端少了它,或者写成了别的来源,浏览器就会拦。

简单请求走上面这条链路。一旦请求方法、请求头或 Content-Type 超出「简单请求」范围,浏览器还会先发一道预检。

5. 预检请求:为什么多了一个 OPTIONS

有些跨域请求,浏览器不会直接发正式请求,而是先发一个 OPTIONS 请求。这个 OPTIONS 请求叫预检请求。

它像是在问服务器:

1
2
3
4
我等会儿要发 POST。
我还要带 Content-Type: application/json。
我还要带 Authorization。
你允许吗?

预检请求长这样:

1
2
3
4
OPTIONS /users HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

服务器如果允许,就回:

1
2
3
4
5
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 7200

浏览器看到预检通过,才会继续发真正的 POST /users

什么时候会预检?日常开发记三个条件就够:

  1. 方法不是 GETHEADPOST,例如 PUTDELETE
  2. 带了自定义请求头,例如 Authorization
  3. Content-Typeapplication/json

所以你看到接口调用前多了一个 OPTIONS,不用慌。那通常不是你代码多调了一次,而是浏览器在做 CORS 预检。

这里也解释了「跨域请求服务器到底收没收到」:

场景业务请求是否可能到达服务器
简单请求通常会到达,浏览器再决定 JS 能不能读响应
预检失败正式业务请求通常不会发
预检成功,正式响应缺 CORS 头业务请求到了,但 JS 读不到响应

预检失败最常见的原因:

现象常见原因修法
Authorization 不被允许后端没放行这个请求头Access-Control-Allow-Headers: Authorization
Content-Type 不被允许后端只放行了默认请求头Access-Control-Allow-Headers: Content-Type
DELETE / PUT 失败后端没放行方法Access-Control-Allow-Methods
OPTIONS 返回 401 / 403鉴权中间件拦了预检OPTIONS 在鉴权前直接返回 204
正式接口 500 后变成跨域错误错误响应没带 CORS 头确保异常响应也加 CORS 头

原理讲完了,接下来用 DevTools 把报错和 Network 面板对上号。

6. 用 DevTools 排查跨域

遇到跨域报错,不要先改代码。先打开浏览器 DevTools,看 Network。

按这个顺序查。

6.1 看页面来源和接口地址

先看控制台报错里的两段:

1
2
Access to fetch at 'http://localhost:8080/users'
from origin 'http://localhost:5173'

这告诉你:

1
2
页面来源:http://localhost:5173
接口地址:http://localhost:8080/users

协议、域名、端口有一个不同,就是跨域。

6.2 看有没有 OPTIONS

如果 Network 里有 OPTIONS,先点开它。

看 Request Headers:

1
2
3
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

这说明浏览器在问:localhost:5173 能不能用 POST,并带 authorizationcontent-type 请求头。

再看 Response Headers,至少应该有:

1
2
3
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Authorization, Content-Type

缺哪个,就修哪个。

6.3 看正式请求的响应头

如果预检通过了,再看真正的业务请求。

正式响应里也要有:

1
Access-Control-Allow-Origin: http://localhost:5173

很多项目的坑是:成功响应有 CORS 头,错误响应没有。

结果后端明明返回了 401、403、500,前端看到的却是跨域错误。此时要修的是后端异常响应链路,而不是前端请求。

6.4 不要用 mode: 'no-cors'

新手很容易搜到这种写法:

1
2
3
fetch('http://localhost:8080/users', {
mode: 'no-cors',
})

这不是解决跨域。

no-cors 的意思更接近:我可以发这个请求,但别让我读响应。

你会拿到一个 opaque 响应。状态码、响应头、响应体基本都读不到。对于调用 API 来说,它没有解决问题。

排查方法有了,再看开发环境和生产环境分别怎么修。

7. 开发环境怎么修

开发时最常见的三种修法。

7.1 本地文件:起本地服务

不要双击打开 file:///Users/you/demo/index.html。参考第 3 节,用本地 HTTP 服务把页面跑起来。

如果只是本地 HTML 读本地 JSON,这一步通常就够了。

7.2 前后端分离:让后端加 CORS

页面在:

1
http://localhost:5173

接口在:

1
http://localhost:8080

后端要允许前端来源:

1
Access-Control-Allow-Origin: http://localhost:5173

如果有预检,还要允许方法和请求头:

1
2
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type

注意两点:

  1. CORS 中间件要放在鉴权和业务处理之前
  2. OPTIONS 预检不要被登录校验拦住

7.3 前端代理:让浏览器只看到同源

代理的思路是:让浏览器只请求同源地址。

页面来自:

1
http://localhost:5173

前端只请求:

1
fetch('/api/users')

浏览器看到的是:

1
http://localhost:5173/api/users

这是同源。

然后开发服务器再转发到真正后端:

1
http://localhost:8080/users

浏览器看不见这一步。服务器和服务器之间没有浏览器同源策略。

Vite 里常见配置是:

1
2
3
4
5
6
7
8
9
10
11
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
}

开发环境修完,带 Cookie 的跨域还要额外满足几条规则。

8. Cookie 跨域为什么更麻烦

不带 Cookie 的跨域请求,只要服务器放行来源,通常就能读响应。

带 Cookie 时,要同时满足四件事。

前端要写:

1
2
3
fetch('https://api.example.com/user', {
credentials: 'include',
})

后端要回:

1
2
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

这里不能写:

1
Access-Control-Allow-Origin: *

Cookie 本身也要允许跨站发送:

1
Set-Cookie: session=xxx; SameSite=None; Secure; HttpOnly

再确认 Cookie 的 Domain 是否覆盖接口域名。

例如:

1
2
app.example.com
api.example.com

它们不同源,因为域名不同。

但它们可能同站,因为都属于 example.com 这个站点。这里要分清两个词:

概念判断标准例子
同源 Same-origin协议 + 域名 + 端口完全一致https://app.example.comhttps://api.example.com 不同源
同站 Same-site通常看可注册域名app.example.comapi.example.com 通常同站

CORS 说的是同源。Cookie 的 SameSite 说的是同站。它们不是一回事。

所以跨域 Cookie 出问题时,按这个清单查:

  1. 前端有没有 credentials: 'include'
  2. 后端有没有 Access-Control-Allow-Credentials: true
  3. Access-Control-Allow-Origin 是不是明确来源,而不是 *
  4. Cookie 有没有 SameSite=None; Secure
  5. Cookie 的 DomainPath 是否覆盖当前接口
  6. 浏览器是否启用了第三方 Cookie 限制

Cookie 规则理清之后,再看生产环境怎么从架构上减少跨域。

9. 生产环境优先用反向代理

生产环境最稳的做法,是让浏览器只面对一个域名。

前端页面:

1
https://app.example.com/

接口地址:

1
https://app.example.com/api/users

Nginx 再把 /api/ 转发到内部后端:

1
2
浏览器 -> https://app.example.com/api/users
Nginx -> http://127.0.0.1:8080/users

浏览器看到的始终是 https://app.example.com,跨域问题从架构上消失。

如果前后端必须分域,例如:

1
2
前端:https://app.example.com
接口:https://api.example.com

那就配置 CORS。关键不是背某个 Nginx 模板,而是保证四件事:

  1. 正常响应带 CORS 头
  2. 错误响应也带 CORS 头
  3. OPTIONS 预检能直接返回
  4. 允许的来源、方法、请求头和凭证配置一致

Nginx 中 add_header 常要加 always,否则 4xx、5xx 响应可能没有 CORS 头。

主线讲完了,最后补几个容易和 CORS 混在一起的边界场景。

10. 几个容易混的边界

10.1 图片能显示,不代表 JS 能读

网页可以加载跨域图片:

1
<img src="https://cdn.example.com/a.png" alt="demo">

因为展示图片不等于读取图片数据。

但如果你把这张图片画到 Canvas,再读像素:

1
2
ctx.drawImage(img, 0, 0)
canvas.toDataURL()

浏览器就会拦。因为这一步已经从「展示资源」变成了「JS 读取跨域数据」。

10.2 Iframe 能嵌入,不代表能读 DOM

页面可以嵌入跨域 iframe:

1
<iframe src="https://embed.example.com"></iframe>

但父页面不能直接读取跨域 iframe 的 DOM。

如果两个页面确实要通信,用 postMessage,并且接收方要校验 event.origin

10.3 CSRF 说明同源策略不是万能的

同源策略主要阻止「读取响应」,不完全阻止「发送请求」。

所以 CSRF 仍然可能发生。

例如恶意页面放一个图片:

1
<img src="https://bank.example.com/transfer?to=hacker&amount=10000" alt="">

浏览器可能会把请求发出去。攻击者不需要读响应,只要请求造成副作用就够了。

所以服务端不能只靠 CORS 防安全问题,还要用 CSRF Token、SameSite Cookie、Origin / Referer 校验等手段。

至于 JSONP、WebSocket、CORB,先知道它们是特殊场景就行。新手优先把 SOP、Origin、CORS、预检、Cookie 这条主线吃透。

11. 最终排障决策树

遇到跨域报错,按下面顺序走:

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
    A["出现跨域报错"] --> B{"页面是 file:// 打开?"}
    B -->|是| C["起本地 HTTP 服务"]
    B -->|否| D{"页面与接口同源?"}
    D -->|是| E["查业务错误、Mixed Content、网络"]
    D -->|否| F{"Network 有 OPTIONS?"}
    F -->|有| G["查预检响应头"]
    F -->|否| H["查正式响应头"]
    G --> I{"预检通过?"}
    I -->|否| J["查 Allow-Origin/Methods/Headers 及 OPTIONS 鉴权"]
    I -->|是| H
    H --> K{"Allow-Origin 正确?"}
    K -->|否| L["补 CORS 头,含 4xx/5xx 响应"]
    K -->|是| M{"需要 Cookie?"}
    M -->|是| N["查 credentials、Allow-Credentials、SameSite"]
    M -->|否| O["跨域链路已通,查业务逻辑"]

最后记这一句:

1
2
3
4
跨域不是接口坏了。
跨域是浏览器问服务器:
这个页面有资格读你的响应吗?
服务器没说「有」,浏览器就当「没有」。