网络请求踩坑
前端网络请求中高频遇到的跨域、Cookie、Token 和请求行为问题。
预防措施
- 开发环境配置代理:Vite 的
server.proxy或 Webpack 的devServer.proxy可避免开发时跨域问题 - 统一请求拦截器:在 axios 拦截器中统一处理 Token、错误码、重定向
- 接口响应格式规范:与后端约定统一的响应结构(如
{ code, data, message }) - 超时时间分层设置:普通接口 15 秒,文件上传 1 分钟,导出 2 分钟
- 请求取消机制:页面切换时取消未完成的请求,避免数据错乱
1. CORS 跨域错误
什么是 CORS(Cross-Origin Resource Sharing,跨域资源共享)? CORS 是浏览器的安全机制,用于限制网页向不同源(协议、域名、端口任一不同)的服务器发起请求。 当检测到跨域请求时,浏览器会先检查服务器是否允许该跨域访问,不允许则拦截响应。
Access to XMLHttpRequest at 'http://api.example.com' from origin
'http://localhost:5173' has been blocked by CORS policy原因:后端没有设置允许跨域的响应头。
快速排查:
- 打开 Network 面板,看请求是否发出
- 看 Console 报错是 CORS 还是其他网络错误
- 确认后端是否返回了
Access-Control-Allow-Origin头
解决方案:
- 后端添加 CORS 头(
Access-Control-Allow-Origin等) - 开发环境用 Vite 代理
- 生产环境用 Nginx 反向代理
📖 完整代理配置和跨域原理见 接口联调与调试 → 跨域处理
2. 预检请求(OPTIONS)
什么是预检请求(Preflight Request)? 当浏览器发起"非简单请求"时,会先自动发送一个 OPTIONS 请求给服务器,询问是否允许该跨域请求。 只有服务器确认允许后,浏览器才会发送真正的请求。这个 OPTIONS 请求就是"预检请求"。
# 浏览器自动发送 OPTIONS 请求检查是否允许跨域
# 以下情况会触发预检:
# - 使用了 PUT/DELETE/PATCH 等非简单方法
# - 请求头包含自定义字段(如 Authorization)
# - Content-Type 不是 application/x-www-form-urlencoded / multipart/form-data / text/plain
# 常见问题:后端没有处理 OPTIONS 请求,返回 404/405bash
# 后端需要处理 OPTIONS 请求并返回正确的 CORS 头
# 或使用 nginx / 网关统一处理3. Cookie 不携带
js
// 跨域请求默认不携带 Cookie(浏览器安全策略)
axios.get('http://api.example.com/user') // ❌ 不带 Cookie
// ✅ 设置 withCredentials: true,允许跨域请求携带 Cookie
axios.get('http://api.example.com/user', {
withCredentials: true // 跨域时携带 Cookie
})
// fetch 也一样,credentials 选项控制 Cookie 行为
fetch('http://api.example.com/user', {
credentials: 'include' // 'include' 始终携带,'same-origin' 仅同源携带
})bash
# 后端也需要配合,返回以下响应头:
Access-Control-Allow-Credentials: true
# 注意:Allow-Credentials: true 时,Allow-Origin 不能用 *,必须指定具体域名
# 例如:
Access-Control-Allow-Origin: https://example.com
# 如果需要支持多个域名,后端需要根据请求的 Origin 动态返回对应的值4. Token 过期处理
js
// 问题:token 过期后,多个请求同时失败,刷新多次
let isRefreshing = false // 是否正在刷新
let pendingQueue = [] // 等待队列
axios.interceptors.response.use(null, async error => {
if (error.response?.status !== 401) return Promise.reject(error)
if (!isRefreshing) {
isRefreshing = true
try {
const newToken = await refreshToken()
setToken(newToken)
// 重试队列中的请求
pendingQueue.forEach(cb => cb(newToken))
pendingQueue = []
// 重试当前请求
error.config.headers.Authorization = `Bearer ${newToken}`
return axios(error.config)
} catch {
removeToken()
router.push('/login')
} finally {
isRefreshing = false
}
}
// 正在刷新,将请求加入队列
return new Promise(resolve => {
pendingQueue.push(token => {
error.config.headers.Authorization = `Bearer ${token}`
resolve(axios(error.config))
})
})
})5. 重复请求未取消
js
// 快速切换页面时,上一个请求还没返回,数据错乱
// ✅ 方式一:AbortController
const controller = new AbortController()
fetch('/api/data', { signal: controller.signal })
// 页面切换时取消
onUnmounted(() => controller.abort())
// ✅ 方式二:axios CancelToken(旧版)
const source = axios.CancelToken.source()
axios.get('/api/data', { cancelToken: source.token })
source.cancel('请求取消')6. 文件下载拿不到文件名
js
// 后端返回文件流,前端需要从响应头获取文件名
const res = await fetch('/api/export')
const disposition = res.headers.get('Content-Disposition')
// 解析文件名
let filename = 'download.xlsx'
if (disposition) {
// 优先取 filename*(RFC 5987 标准,支持中文编码)
// 格式:filename*=UTF-8''%E6%96%87%E4%BB%B6.xlsx
const match = disposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/)
if (match) filename = decodeURIComponent(match[1])
}
// 创建下载链接
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)7. 请求超时设置
为什么要设置超时?为什么不都用同一个超时时间?
超时是为了防止请求"卡住"。如果服务器没有响应,浏览器会一直等待,用户体验很差。
不同接口需要不同的超时时间:
- 普通查询接口:15 秒足够,服务器一般 1-2 秒就能返回
- 文件上传:可能需要 1 分钟甚至更长,取决于文件大小和网速
- 数据导出:大数据量导出可能需要 2-3 分钟
- 超时太短:会误判为失败,用户看到"请求超时"但其实服务器还在处理
- 超时太长:用户等待体验差,可能以为页面卡死了
js
// 超时时间要根据接口特点设置
const service = axios.create({
timeout: 15000 // 默认 15 秒
})
// 文件上传/导出等长操作单独设置
request.post('/api/upload', formData, { timeout: 60000 }) // 1 分钟
request.get('/api/export', { timeout: 120000, responseType: 'blob' }) // 2 分钟8. 接口返回 302 重定向
js
// 问题:axios 默认自动跟随 302,拿不到重定向的响应
// fetch 默认也会自动跟随
// 如果需要手动处理重定向:
fetch('/api/redirect', {
redirect: 'manual' // 不自动跟随
}).then(res => {
console.log(res.status) // 0(opaque redirect)
console.log(res.headers.get('Location')) // 重定向地址
})9. content-type 不匹配
为什么不能手动设置 FormData 的 content-type?
FormData 发送文件时,浏览器会自动设置 Content-Type: multipart/form-data; boundary=xxx。这个 boundary 是一个随机字符串,用来分隔表单中的多个字段。
如果你手动设置 Content-Type: application/json,就会破坏这个机制,后端无法解析表单数据,导致上传失败。
简单记住:
- 发送 JSON 数据:
Content-Type: application/json(axios 默认) - 发送 FormData/文件:不要手动设置,让浏览器自动设置
js
// ❌ 发送 JSON 数据但 content-type 不对
axios.post('/api/user', { name: '张三' }) // 自动设置 application/json ✅
// ❌ 发送 FormData 但手动设了 content-type
const form = new FormData()
form.append('file', file)
axios.post('/api/upload', form, {
headers: { 'Content-Type': 'application/json' } // ❌ 会破坏 boundary
})
// ✅ FormData 不要手动设置 content-type,让浏览器自动设置
axios.post('/api/upload', form) // 浏览器自动设置 multipart/form-data; boundary=xxx10. HTTP 缓存不生效
nginx
# 问题:接口设置了缓存但浏览器不缓存
# Cache-Control 指令说明:
# - no-cache:每次使用缓存前必须向服务器验证(不是不缓存,而是"协商缓存")
# - no-store:完全不缓存,每次都从服务器获取
# - max-age=N:缓存 N 秒内有效,期间直接使用缓存不请求服务器
# - public:允许 CDN 等中间代理缓存
# - private:只允许浏览器缓存,不允许代理缓存
# - immutable:资源永远不变,浏览器不会发送验证请求
# 原因一:Cache-Control 设置了 no-cache
# no-cache = 每次都向服务器验证(不是不缓存)
# 原因二:请求头有 Cache-Control: no-cache
# 浏览器开发者工具勾选了 "Disable cache"
# 原因三:URL 有随机参数
# /api/data?_t=123456 → 每次 URL 不同,缓存失效bash
# 推荐的缓存策略
# HTML(入口文件):不缓存或协商缓存
Cache-Control: no-cache
# 静态资源(JS/CSS/图片,文件名带 hash):强缓存 1 年
Cache-Control: public, max-age=31536000, immutable
# API 接口:根据业务需求设置
Cache-Control: max-age=60 # 缓存 60 秒真实场景
场景 1:登录后请求接口仍返回 401
- 问题:用户已登录,但调用接口时后端返回 401 未授权
- 原因:跨域请求默认不携带 Cookie,未设置
withCredentials: true - 解决:前端设置
withCredentials: true,后端设置Access-Control-Allow-Credentials: true
场景 2:Token 过期后页面反复跳转登录页
- 问题:Token 过期时,页面同时发出 10 个请求,每个都触发刷新 Token,导致多次跳转登录
- 原因:未做 Token 刷新的并发控制
- 解决:使用标志位和队列,确保只刷新一次 Token,其他请求等待新 Token 后重试
场景 3:文件上传总是失败
- 问题:使用 FormData 上传文件,后端接收不到数据
- 原因:手动设置了
Content-Type: application/json,破坏了 multipart/form-data 的 boundary - 解决:删除手动设置的 Content-Type,让浏览器自动设置