如果你做过前端开发,一定遇到过跨域问题。那种"Access-Control-Allow-Origin"错误信息,让无数开发者头疼不已。在实际项目中,这个问题尤其常见:本地开发时前后端分离,或者需要调用第三方API时,跨域问题总是不期而至。
经过多年开发经验的积累,我发现很多开发者对跨域的理解还停留在表面,遇到问题时常常靠搜索临时解决。这篇文章我想从原理到实践,系统地梳理跨域问题的本质和解决方案,帮助大家真正理解背后的机制,而不仅是知道怎么配置。
本文将从以下几个方面详细解析跨域问题:
如果你对跨域已经有了基本了解,可以直接跳转到解决方案部分或实战代码示例。
跨域(Cross-Origin Resource Sharing,CORS)是Web开发中绕不开的话题。简单来说,就是浏览器出于安全考虑,阻止一个域名的网页去请求另一个域名的资源。这个机制就是同源策略(Same-Origin Policy)。
听起来可能有点抽象,我来举个例子:你在浏览器中打开了https://example.com,页面上的JavaScript代码想要请求https://api.example.com的数据,这就算是跨域请求,因为虽然主域名相同,但实际上是不同的子域。
浏览器判断两个页面是否同源的标准:
// 当前页面URL
const currentUrl = 'https://example.com:8080/path/page.html?query=test#hash'
// 同源必须满足以下三个条件相同:
// 1. 协议 (Protocol): https
// 2. 域名 (Domain): example.com
// 3. 端口 (Port): 8080在实际开发中,我们经常会遇到这些跨域情况:
| 场景 | URL1 | URL2 | 是否跨域 | 说明 |
|---|---|---|---|---|
| 不同域名 | https://a.com | https://b.com | 跨域 | 完全不同的域名 |
| 不同子域 | https://a.example.com | https://b.example.com | 跨域 | 主域名相同,子域名不同 |
| 不同协议 | http://example.com | https://example.com | 跨域 | HTTP vs HTTPS |
| 不同端口 | https://example.com:80 | https://example.com:443 | 跨域 | 端口不同 |
| 同源 | https://example.com/a | https://example.com/b | 同源 | 协议、域名、端口都相同 |
记住这个判断标准:协议、域名、端口,三个都必须完全相同才算同源。
同源策略是浏览器最重要的安全机制,它就像一道防火墙,保护着我们的数据安全。如果没有同源策略,你在银行网站登录后,访问其他恶意网站时,对方可能会悄悄读取你在银行网站的信息,这后果想想都可怕。
同源策略主要解决几个核心安全问题:
值得注意的是,并不是所有的跨域请求都会被阻止:
| 请求类型 | 是否受限制 | 解决方案 |
|---|---|---|
| AJAX请求 | 受限制 | CORS、JSONP、代理 |
| Cookie/LocalStorage | 受限制 | 跨域Cookie设置 |
| DOM访问 | 受限制 | postMessage通信 |
| CSS/JS/图片 | 不受限制 | 嵌入资源通常可以跨域 |
这就是为什么我们可以直接在页面中引用其他域名的图片、CSS文件和JavaScript,但是用AJAX请求这些数据就会被阻止。
在解决跨域问题之前,我们需要了解浏览器对跨域请求的分类。浏览器把跨域请求分为两种:简单请求和复杂请求。
满足以下条件的请求:
// 简单请求示例
fetch('https://api.example.com/data', {
method: 'GET', // 或 POST、HEAD
headers: {
'Content-Type': 'application/x-www-form-urlencoded', // 或指定类型
},
})简单请求条件:
GET、POST、HEADapplication/x-www-form-urlencoded、multipart/form-data、text/plain不满足简单请求条件的请求会先发送预检请求:
// 复杂请求示例
fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token123',
},
})
// 浏览器会自动先发送:
// OPTIONS https://api.example.com/data
// Access-Control-Request-Method: POST
// Access-Control-Request-Headers: content-type, authorizationCORS(Cross-Origin Resource Sharing)是目前最标准的跨域解决方案。
Node.js + Express
const express = require('express')
const cors = require('cors')
const app = express()
// 基本CORS配置
app.use(cors())
// 自定义CORS配置
app.use(
cors({
origin: ['https://yourdomain.com', 'https://anotherdomain.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
optionsSuccessStatus: 200,
}),
)
// 手动设置CORS头部
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'https://yourdomain.com')
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE')
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
res.header('Access-Control-Allow-Credentials', 'true')
res.header('Access-Control-Max-Age', '86400') // 预检请求缓存24小时
if (req.method === 'OPTIONS') {
res.sendStatus(200)
} else {
next()
}
})Nginx配置
server {
listen 80;
server_name api.example.com;
location / {
# 允许的源
add_header 'Access-Control-Allow-Origin' 'https://yourdomain.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
if ($request_method = 'OPTIONS') {
return 204;
}
# 代理到后端服务
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}开发环境代理(Webpack DevServer)
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
secure: false,
pathRewrite: {
'^/api': '',
},
},
},
},
}生产环境代理
// 使用http-proxy-middleware
const { createProxyMiddleware } = require('http-proxy-middleware')
const app = express()
app.use(
'/api',
createProxyMiddleware({
target: 'https://api.example.com',
changeOrigin: true,
secure: false,
pathRewrite: {
'^/api': '',
},
}),
)JSONP是早期的跨域解决方案,现在已不推荐使用。
// JSONP实现
function jsonp(url, callback) {
const script = document.createElement('script')
const callbackName = 'callback_' + Date.now()
window[callbackName] = function (data) {
callback(data)
document.head.removeChild(script)
delete window[callbackName]
}
script.src = `${url}?callback=${callbackName}`
document.head.appendChild(script)
}
// 使用示例
jsonp('https://api.example.com/data', function (data) {
console.log('Received data:', data)
})JSONP的缺点:
// 页面A (https://domain-a.com)
const popup = window.open('https://domain-b.com/popup', 'popup')
// 发送消息到popup
popup.postMessage('Hello from domain A!', 'https://domain-b.com')
// 接收消息
window.addEventListener('message', function (event) {
// 验证消息来源
if (event.origin === 'https://domain-b.com') {
console.log('Received message:', event.data)
}
})
// 页面B (https://domain-b.com/popup)
// 接收消息
window.addEventListener('message', function (event) {
if (event.origin === 'https://domain-a.com') {
console.log('Received message:', event.data)
// 回复消息
event.source.postMessage('Hello from domain B!', event.origin)
}
})// src/api/index.js
import axios from 'axios'
const API_BASE_URL = process.env.REACT_APP_API_URL || 'https://api.example.com'
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
// 添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error),
)
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 处理未授权
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
export default apiClient
// 组件中使用
import React, { useEffect, useState } from 'react'
import apiClient from './api'
function UserProfile() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchUser = async () => {
try {
const response = await apiClient.get('/user/profile')
setUser(response.data)
} catch (error) {
console.error('Failed to fetch user:', error)
} finally {
setLoading(false)
}
}
fetchUser()
}, [])
if (loading) return <div>Loading...</div>
return <div>Hello, {user?.name}!</div>
}// vue.config.js
module.exports = {
devServer: {
port: 8080,
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
secure: false,
onProxyReq: (proxyReq, req, res) => {
proxyReq.setHeader('Origin', 'https://api.example.com')
},
},
},
},
}
// src/utils/request.js
import axios from 'axios'
const service = axios.create({
baseURL: process.env.VUE_APP_API_URL || '/api',
timeout: 15000,
})
service.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers['X-Token'] = token
}
return config
},
(error) => {
console.error('Request error:', error)
return Promise.reject(error)
},
)
export default service// server.js
const express = require('express')
const cors = require('cors')
const helmet = require('helmet')
const rateLimit = require('express-rate-limit')
const app = express()
const PORT = process.env.PORT || 3000
// 安全中间件
app.use(helmet())
// 速率限制
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
max: 100, // 限制每个IP 100次请求
})
app.use(limiter)
// CORS配置
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
'https://yourdomain.com',
'https://www.yourdomain.com',
'http://localhost:3000',
'http://localhost:8080',
]
// 允许没有origin的请求(移动应用等)
if (!origin) return callback(null, true)
if (allowedOrigins.indexOf(origin) !== -1) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
exposedHeaders: ['Content-Length', 'X-Foo', 'X-Bar'],
maxAge: 86400, // 预检请求缓存24小时
}
app.use(cors(corsOptions))
// 处理预检请求
app.options('*', cors(corsOptions))
// API路由
app.use('/api/users', require('./routes/users'))
app.use('/api/posts', require('./routes/posts'))
// 错误处理
app.use((err, req, res, next) => {
if (err.message === 'Not allowed by CORS') {
return res.status(403).json({
error: 'CORS Error: Origin not allowed',
})
}
console.error(err.stack)
res.status(500).json({
error: 'Internal Server Error',
})
})
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})最小权限原则
// ❌ 错误:允许所有域
app.use(cors({ origin: '*' }))
// ✅ 正确:指定允许的域
app.use(
cors({
origin: ['https://trusted-domain.com'],
}),
)动态Origin验证
const allowedOrigins = ['https://domain1.com', 'https://domain2.com']
app.use(
cors({
origin: function (origin, callback) {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('Not allowed by CORS'))
}
},
}),
)避免暴露敏感信息
// ❌ 错误:暴露过多头部信息
res.header('Access-Control-Expose-Headers', '*')
// ✅ 正确:只暴露必要的头部
res.header('Access-Control-Expose-Headers', 'Content-Length, X-Total-Count')缓存预检请求
// 设置较长的缓存时间
res.header('Access-Control-Max-Age', '86400') // 24小时减少不必要的头部
// 只包含必要的头部
const allowedHeaders = ['Content-Type', 'Authorization']
res.header('Access-Control-Allow-Headers', allowedHeaders.join(', '))const isDevelopment = process.env.NODE_ENV === 'development'
const corsOptions = {
origin: isDevelopment
? ['http://localhost:3000', 'http://localhost:8080']
: ['https://yourdomain.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
}
app.use(cors(corsOptions))错误原因: 服务器没有正确设置CORS头部
解决方案:
// 确保服务器正确设置了CORS头部
res.header('Access-Control-Allow-Origin', 'https://yourdomain.com')错误原因: 请求来源为空(如文件协议)
解决方案:
// 开发环境允许null origin
if (isDevelopment && origin === 'null') {
return callback(null, true)
}错误原因: 不能同时设置credentials为true和origin为*
解决方案:
// ❌ 错误配置
res.header('Access-Control-Allow-Origin', '*')
res.header('Access-Control-Allow-Credentials', 'true')
// ✅ 正确配置
res.header('Access-Control-Allow-Origin', 'https://yourdomain.com')
res.header('Access-Control-Allow-Credentials', 'true')浏览器开发者工具
// 在Network标签中查看CORS相关的请求头
// 特别关注以下头部:
// - Access-Control-Allow-Origin
// - Access-Control-Allow-Methods
// - Access-Control-Allow-HeadersCORS测试工具
// 简单的CORS测试函数
function testCORS(url) {
fetch(url)
.then((response) => response.text())
.then((data) => console.log('CORS Test Success:', data))
.catch((error) => console.error('CORS Test Failed:', error))
}
testCORS('https://api.example.com/test')在线CORS测试工具
// 添加CORS请求日志
app.use((req, res, next) => {
const origin = req.headers.origin
const method = req.method
console.log(`CORS Request: ${method} ${origin || 'no-origin'} -> ${req.path}`)
next()
})写到这里,跨域问题这个困扰了很多开发者的"老大难"话题,应该已经变得清晰了。从原理到实践,我们系统地了解了跨域的本质和各种解决方案。
如果对跨域问题还有更深入的需求,建议阅读:
在实际项目中遇到跨域问题时,最重要的是先理解业务场景和安全性需求,然后选择最合适的解决方案。记住,没有银弹,每种方案都有其适用范围和局限性。
希望这篇文章能帮你真正理解跨域问题,而不是仅仅知道怎么"抄配置"。如果你在项目中遇到了特殊的跨域场景,欢迎在评论区交流讨论。