POST 为什么会发送两次请求?
一、预检请求(Preflight Request)详解
1.1 什么是预检请求?
1.2 触发预检请求的条件
// 以下情况会触发预检请求(CORS 非简单请求)
// 1. 使用了非简单方法
fetch('/api/data', {
method: 'PUT', // 或 DELETE、PATCH 等
});
// 2. 自定义请求头
fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 非简单值
'X-Custom-Header': 'value' // 自定义头
}
});
// 3. Content-Type 是非简单值
// 简单值只有三种:
// - application/x-www-form-urlencoded
// - multipart/form-data
// - text/plain
// 4. 携带凭证(credentials)
fetch('/api/data', {
method: 'POST',
credentials: 'include' // 发送 cookies
});
二、HTTP 请求生命周期分析
2.1 完整请求流程
// 客户端发起请求示例
async function makeComplexPostRequest() {
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer token123',
'X-Custom-Header': 'custom-value'
},
body: JSON.stringify({ key: 'value' }),
credentials: 'include',
mode: 'cors'
});
return response.json();
}
// 实际发生的请求:
// 第一次:OPTIONS 请求(预检)
OPTIONS /data HTTP/1.1
Host: api.example.com
Origin: https://client.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,authorization,x-custom-header
// 服务器响应
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://client.example.com
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
// 第二次:实际的 POST 请求
POST /data HTTP/1.1
Host: api.example.com
Origin: https://client.example.com
Content-Type: application/json
Authorization: Bearer token123
X-Custom-Header: custom-value
{"key":"value"}
// 服务器响应
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://client.example.com
Access-Control-Expose-Headers: X-Custom-Response
X-Custom-Response: custom-data
Content-Type: application/json
{"status":"success"}
2.2 服务器端处理示例
// Node.js/Express 服务器端配置
const express = require('express');
const app = express();
// 1. 全局 CORS 中间件
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`, req.headers);
// 处理预检请求
if (req.method === 'OPTIONS') {
console.log('处理预检请求');
res.header('Access-Control-Allow-Origin', 'https://client.example.com');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Custom-Header');
res.header('Access-Control-Allow-Credentials', 'true');
res.header('Access-Control-Max-Age', '86400'); // 24小时缓存
return res.status(204).send(); // 204 No Content
}
next();
});
// 2. 处理实际请求
app.post('/api/data', (req, res) => {
console.log('处理实际 POST 请求');
res.header('Access-Control-Allow-Origin', 'https://client.example.com');
res.header('Access-Control-Allow-Credentials', 'true');
res.json({
status: 'success',
message: 'POST 请求处理成功'
});
});
// 3. 或者使用 cors 中间件
const cors = require('cors');
app.use(cors({
origin: 'https://client.example.com',
credentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'X-Custom-Header'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
maxAge: 86400
}));
三、不同场景下的请求次数分析
3.1 简单请求 vs 复杂请求
// 场景1:简单请求(只有1次请求)
// 满足所有条件:
// 1. 方法为 GET、HEAD、POST
// 2. Content-Type 为简单值
// 3. 没有自定义头
// 4. 不携带凭证(默认)
fetch('/api/simple', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'key=value&name=test'
});
// 实际:1次 POST 请求
// 场景2:复杂请求(2次请求)
fetch('/api/complex', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});
// 实际:1次 OPTIONS + 1次 POST
// 场景3:使用非简单方法(2次请求)
fetch('/api/data', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
}
});
// 实际:1次 OPTIONS + 1次 PUT
// 场景4:带自定义头(2次请求)
fetch('/api/data', {
method: 'POST',
headers: {
'X-Api-Key': '123456'
}
});
// 实际:1次 OPTIONS + 1次 POST
// 场景5:跨域且带凭证(2次请求)
fetch('https://api.other-domain.com/data', {
method: 'POST',
credentials: 'include'
});
// 实际:1次 OPTIONS + 1次 POST
3.2 预检请求的缓存机制
// 服务器可以通过 Access-Control-Max-Age 控制预检请求缓存
app.use((req, res, next) => {
if (req.method === 'OPTIONS') {
// 设置预检请求缓存时间(单位:秒)
res.header('Access-Control-Max-Age', '86400'); // 24小时
// 浏览器在24小时内不会再次发送预检请求
// 适用于同一URL的后续请求
}
next();
});
// 示例:缓存的效果
async function makeMultipleRequests() {
// 第一次调用:发送预检请求
await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
// 24小时内的后续调用:不发送预检请求
await fetch('/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
// 改变方法:会重新发送预检请求
await fetch('/api/data', {
method: 'PUT', // 方法改变,重新预检
headers: { 'Content-Type': 'application/json' }
});
}
四、现代浏览器的具体表现
4.1 Chrome DevTools 网络面板分析
网络面板显示示例:
请求1:OPTIONS /api/data
方法: OPTIONS
状态: 204 No Content
耗时: 15ms
请求头:
Origin: https://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
响应头:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
请求2:POST /api/data
方法: POST
状态: 200 OK
耗时: 50ms
请求头:
Origin: https://example.com
Content-Type: application/json
响应头:
Access-Control-Allow-Origin: https://example.com
4.2 不同浏览器的差异
// 测试不同浏览器对预检请求的处理
const testBrowsers = async () => {
const config = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Test-Header': 'test'
}
};
// Chrome/Firefox/Safari: 会发送预检请求
// Edge (Chromium-based): 同 Chrome
// 旧版 IE: 行为不同(部分版本不支持 CORS)
try {
const response = await fetch('/api/test', config);
console.log('请求成功');
} catch (error) {
console.log('请求失败:', error);
}
};
// 浏览器兼容性检查
if ('fetch' in window) {
// 现代浏览器支持 fetch
if ('credentials' in Request.prototype) {
// 支持 CORS 凭证
}
}
五、解决方案与优化策略
5.1 避免不必要的预检请求
// 方案1:保持请求为简单请求
async function makeSimpleRequest() {
// 使用简单 Content-Type
const formData = new URLSearchParams();
formData.append('key', 'value');
const response = await fetch('/api/data', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData
});
// 不会触发预检请求
}
// 方案2:使用 GET 替代 POST(如果安全允许)
async function getInsteadOfPost() {
const params = new URLSearchParams({
data: JSON.stringify({ key: 'value' })
});
const response = await fetch(`/api/data?${params}`);
// GET 请求不会触发预检
}
// 方案3:服务器端设置合理的缓存时间
// 见上面的 Access-Control-Max-Age 配置
// 方案4:使用代理服务器
// 前端 -> 同域代理 -> 目标API
async function useProxy() {
// 前端请求同域
const response = await fetch('/api/proxy/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});
// 不会触发预检,因为同域
}
// Nginx 代理配置示例
// location /api/proxy/ {
// proxy_pass https://target-api.com/;
// proxy_set_header Host $host;
// }
5.2 服务器端最佳实践
// Spring Boot 配置示例
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://client.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600); // 1小时缓存
}
}
// 或者使用过滤器
@Component
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res,
FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) res;
HttpServletRequest request = (HttpServletRequest) req;
// 设置 CORS 头
response.setHeader("Access-Control-Allow-Origin", "https://client.example.com");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
// 处理预检请求
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
} else {
chain.doFilter(req, res);
}
}
}
5.3 前端优化方案
// 方案1:请求合并与批处理
class RequestBatcher {
constructor() {
this.pendingRequests = new Map();
this.batchTimeout = 100; // 100ms 批处理窗口
}
async sendRequest(url, config) {
// 检查是否已经有相同请求
const key = `${url}_${JSON.stringify(config)}`;
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key);
}
// 创建新的 Promise
const promise = this.makeRequest(url, config);
this.pendingRequests.set(key, promise);
// 清理已完成的请求
promise.finally(() => {
this.pendingRequests.delete(key);
});
return promise;
}
async makeRequest(url, config) {
// 实际的请求逻辑
return fetch(url, config);
}
}
// 方案2:使用 WebSocket 替代 HTTP
class WebSocketClient {
constructor(url) {
this.ws = new WebSocket(url);
this.callbacks = new Map();
this.requestId = 0;
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
const callback = this.callbacks.get(data.requestId);
if (callback) {
callback(data);
this.callbacks.delete(data.requestId);
}
};
}
sendRequest(method, path, data) {
const requestId = ++this.requestId;
return new Promise((resolve, reject) => {
this.callbacks.set(requestId, resolve);
this.ws.send(JSON.stringify({
requestId,
method,
path,
data
}));
// 设置超时
setTimeout(() => {
if (this.callbacks.has(requestId)) {
this.callbacks.delete(requestId);
reject(new Error('Request timeout'));
}
}, 5000);
});
}
}
// 使用示例
const wsClient = new WebSocketClient('wss://api.example.com/ws');
wsClient.sendRequest('POST', '/data', { key: 'value' })
.then(response => console.log(response));
六、实际案例分析
6.1 文件上传场景
// 文件上传会触发预检请求
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('metadata', JSON.stringify({
name: file.name,
size: file.size
}));
// 即使使用 FormData,Content-Type 会被自动设置
// 但如果有自定义头,还是会触发预检
const response = await fetch('/api/upload', {
method: 'POST',
headers: {
'X-Upload-Token': 'token123' // 自定义头触发预检
},
body: formData
// 注意:不要手动设置 Content-Type
// 浏览器会自动设置为 multipart/form-data
});
return response.json();
}
6.2 带认证的 API 调用
// 认证场景下的多次请求问题
class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.token = null;
this.preflightCache = new Map();
}
async request(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const cacheKey = `${options.method}_${endpoint}`;
// 检查是否需要发送预检请求
const needsPreflight = this.checkPreflightRequired(options);
if (needsPreflight && !this.preflightCache.has(cacheKey)) {
// 发送预检请求
await this.sendPreflightRequest(url, options);
this.preflightCache.set(cacheKey, true);
}
// 发送实际请求
return this.sendActualRequest(url, options);
}
checkPreflightRequired(options) {
// 判断是否为简单请求
const method = options.method || 'GET';
const contentType = options.headers?.['Content-Type'];
const isSimpleMethod = ['GET', 'POST', 'HEAD'].includes(method.toUpperCase());
const isSimpleContentType = !contentType ||
['application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain'].includes(contentType);
const hasCustomHeaders = options.headers &&
Object.keys(options.headers).some(header =>
!['accept', 'accept-language', 'content-language',
'content-type'].includes(header.toLowerCase()));
return !isSimpleMethod || !isSimpleContentType || hasCustomHeaders;
}
async sendPreflightRequest(url, options) {
const preflightResponse = await fetch(url, {
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': options.method || 'POST',
'Access-Control-Request-Headers': Object.keys(options.headers || {}).join(',')
}
});
if (!preflightResponse.ok) {
throw new Error('Preflight request failed');
}
}
}
七、性能影响与监控
7.1 性能影响分析
// 测量预检请求的性能影响
class PerformanceMonitor {
constructor() {
this.metrics = {
totalRequests: 0,
preflightRequests: 0,
totalTime: 0,
preflightTime: 0
};
}
async measureRequest(fetchFn) {
const startTime = performance.now();
const networkEntries = performance.getEntriesByType('resource');
const initialCount = networkEntries.length;
try {
const response = await fetchFn();
const endTime = performance.now();
const newNetworkEntries = performance.getEntriesByType('resource');
const requests = newNetworkEntries.slice(initialCount);
requests.forEach(entry => {
this.metrics.totalRequests++;
this.metrics.totalTime += entry.duration;
if (entry.name.includes('OPTIONS')) {
this.metrics.preflightRequests++;
this.metrics.preflightTime += entry.duration;
}
});
return {
success: true,
duration: endTime - startTime,
requestCount: requests.length,
metrics: { ...this.metrics }
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
getPerformanceReport() {
const avgTotalTime = this.metrics.totalTime / this.metrics.totalRequests;
const avgPreflightTime = this.metrics.preflightTime / this.metrics.preflightRequests;
return {
totalRequests: this.metrics.totalRequests,
preflightRequests: this.metrics.preflightRequests,
preflightPercentage: (this.metrics.preflightRequests / this.metrics.totalRequests * 100).toFixed(2) + '%',
averageTotalTime: avgTotalTime.toFixed(2) + 'ms',
averagePreflightTime: avgPreflightTime.toFixed(2) + 'ms',
preflightOverhead: ((avgPreflightTime / avgTotalTime) * 100).toFixed(2) + '%'
};
}
}
// 使用示例
const monitor = new PerformanceMonitor();
// 测试不同请求
const testCases = [
() => fetch('/api/simple', { method: 'POST' }),
() => fetch('/api/complex', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}),
() => fetch('/api/complex', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' }
})
];
for (const testCase of testCases) {
const result = await monitor.measureRequest(testCase);
console.log('测试结果:', result);
}
console.log('性能报告:', monitor.getPerformanceReport());
7.2 实时监控方案
// 使用 PerformanceObserver 监控请求
class RequestObserver {
constructor() {
this.observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
entries.forEach(entry => {
this.analyzeRequest(entry);
});
});
this.observer.observe({ entryTypes: ['resource'] });
this.stats = {
simple: { count: 0, totalTime: 0 },
preflight: { count: 0, totalTime: 0 },
actual: { count: 0, totalTime: 0 }
};
}
analyzeRequest(entry) {
const url = entry.name;
const method = this.extractMethod(entry);
const isPreflight = method === 'OPTIONS';
if (isPreflight) {
this.stats.preflight.count++;
this.stats.preflight.totalTime += entry.duration;
// 查找对应的实际请求
this.findActualRequest(url);
} else {
this.stats.actual.count++;
this.stats.actual.totalTime += entry.duration;
}
}
extractMethod(entry) {
// 从 PerformanceResourceTiming 提取请求方法
if (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') {
// 可能需要从服务器日志获取
return 'unknown';
}
return 'unknown';
}
findActualRequest(preflightUrl) {
// 实现逻辑:找到对应的实际请求
}
getStats() {
return this.stats;
}
}
八、总结与最佳实践
8.1 POST 发送两次请求的原因总结
| 场景 | 是否发送两次 | 原因 |
|---|---|---|
| 同域请求 | 否 | 不涉及 CORS |
| 简单跨域请求 | 否 | 满足 CORS 简单请求条件 |
| 复杂跨域请求 | 是 | 需要预检请求验证 |
| 带凭证的跨域请求 | 是 | 需要预检 |
| 非简单方法(PUT/DELETE) | 是 | 需要预检 |
| 自定义请求头 | 是 | 需要预检 |
| 非简单 Content-Type | 是 | 需要预检 |
8.2 优化建议
// 前端最佳实践
const bestPractices = {
// 1. 尽量保持请求为简单请求
useSimpleRequest: () => ({
method: 'POST', // 只使用 GET、POST、HEAD
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}),
// 2. 合理设置预检缓存
setPreflightCache: (maxAge = 86400) => {
// 服务器端设置 Access-Control-Max-Age
},
// 3. 合并请求头
combineHeaders: (headers) => {
// 避免过多的自定义头
const essentialHeaders = {
'Content-Type': 'application/json',
'Authorization': 'Bearer token'
};
return { ...essentialHeaders, ...headers };
},
// 4. 使用代理服务器
useProxy: (apiUrl) => {
// 同域代理避免预检
return `/api/proxy${apiUrl}`;
},
// 5. 考虑使用 WebSocket
considerWebSocket: () => {
// 对于频繁的请求,考虑 WebSocket
}
};
// 服务器端最佳实践
const serverBestPractices = {
// 1. 正确配置 CORS
configureCORS: () => `
Access-Control-Allow-Origin: https://client.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
`,
// 2. 处理 OPTIONS 请求
handleOptions: (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, headers);
res.end();
return true;
}
return false;
},
// 3. 监控预检请求
monitorPreflight: () => {
// 记录和监控 OPTIONS 请求
}
};
8.3 决策流程图
开始请求
↓
判断是否跨域?
├── 否 → 发送1次请求 → 结束
↓ 是
判断是否为简单请求?
├── 是 → 发送1次请求 → 结束
↓ 否
检查预检缓存是否有效?
├── 是 → 跳过预检 → 发送1次实际请求 → 结束
↓ 否
发送OPTIONS预检请求
↓
服务器响应是否允许?
├── 否 → 请求失败 → 结束
↓ 是
缓存预检结果
↓
发送实际请求
↓
结束
记住关键点:
- 预检请求是浏览器的安全机制,不是 bug
- 只有复杂跨域请求才需要预检
- 合理配置可以优化性能(如缓存预检结果)
- 理解原理有助于调试和优化
通过合理的设计和配置,可以在保证安全的前提下,最小化预检请求带来的性能影响。
2万+




被折叠的 条评论
为什么被折叠?



