ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

POST请求发送两次?一文讲透CORS预检与前端防重机制

POST请求发送两次?一文讲透CORS预检与前端防重机制 做前端或者全栈的兄弟多半都遇到过这个诡异场景打开浏览器Network面板明明只点了一次提交按钮结果列表里冒出两个请求——前面的那个还是OPTIONS方法后面跟着一个POST甚至有时候连续POST了两三次。第一次遇到的人大概率会怀疑自己代码写错了反复检查事件绑定、检查Ajax调用结果代码干干净净请求却照旧发两次。这个事情背后其实藏着HTTP协议、浏览器安全策略和前端事件机制三套逻辑。把它彻底搞清楚比单纯记住POST会发两次要有用得多。这篇文章不绕弯子直接给你拆明白。我会把POST请求发送两次的几类典型原因全部列出来重点讲清楚最常见也最容易误判的CORS预检机制再带上重定向、前端重复触发、服务器配置等场景的排查思路和解决方案最后附上一张高频问题速查表。不管你是刚入门的前端、写接口的后端还是一个人包全栈的杂工看完之后应该都能独立定位自己项目里POST发两次的根因。1. 先搞清楚POST请求两次到底长什么样1.1 两种截然不同的发两次很多人一开口就说POST发了两次请求但真实的两次可能完全是两码事。第一次遇到这类问题的同学建议先打开开发者工具的Network面板看清楚到底是哪种形态第一种形态一个OPTIONS请求 一个POST请求。OPTIONS回显的是Provisional headers are shown状态码是204或者200后面紧跟一个真正的POST。这种情况网络请求其实只发了一个真实POSTOPTIONS是浏览器的安全检查专业术语叫预检请求。第二种形态两个或更多的POST请求方法完全一样状态码也几乎一样。这种情况才是真正意义上的POST发了两遍多半是前端事件重复触发、接口被重复调用或者是重定向链导致的重复提交。判断是哪种形态直接决定了你接下来的排查方向。选错了方向会在后端代码里翻半天也找不到毛病。1.2 为什么这个问题始终是热门话题post为什么会发送两次请求这个疑问差不多每隔一段时间就会在技术社区里刷一轮。原因是它横跨了前端、浏览器、后端三层知识任何一层出点小状况表象都一模一样。另外这两年前后端分离的项目越来越普遍跨域接口一多预检请求的出现频率明显上升。只要前端用了application/json这种Content-Type或者加了自定义请求头跨域POST必发预检。加之后端如果CORS配置不完整前端报错信息还常常语焉不详你只能在控制台看到一句CORS policy: No Access-Control-Allow-Origin header很难直接把问题关联到预检失败上。所以这个题目的价值不在于知道有这回事而在于你能形成一套从现象到根因的完整判断链。2. 最大元凶浏览器CORS预检机制2.1 什么是预检请求预检请求是浏览器基于CORS同源策略在发起真正的跨域请求之前先发一个OPTIONS请求去探测服务器允不允许这次跨域访问。你可以把它理解成先敲门确认主人在不在家再推门进去避免一个真实的POST已经打到了服务器结果因为跨域限制白白执行了一遍还可能引发数据写入等副作用。这个机制是浏览器主动做的不是你的代码主动发的。所以你在Network面板里看到OPTIONS请求时不要急着去找前端代码里是不是写了method: OPTIONS大概率没有。2.2 什么样的POST会触发预检要弄懂这件事必须先分清简单请求和非简单请求。只有非简单请求才会触发预检。一个POST请求要被称为简单请求需要同时满足下面几个硬性条件请求方法是GET、HEAD、POST三者之一Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain中的一种没有设置自定义请求头比如X-Token、Authorization这类不使用XMLHttpRequest的upload事件监听器也没有在请求上挂ReadableStream对象。只要有一条不满足浏览器就会把这次POST判定为非简单请求先发OPTIONS探路。很多前端项目里大家习惯直接写fetch(/api/login, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username: admin }) })这一个application/json就让POST从简单请求变成了非简单请求跨域环境下必然带出OPTIONS预检请求。2.3 预检请求的完整流程和响应头解析一次典型预检流程是这样的浏览器觉得跨域请求不简单先发出一个OPTIONS请求OPTIONS请求头部里带有两个关键字段Access-Control-Request-Method: POST告诉服务器我接下来要用POST方法Access-Control-Request-Headers: content-type告诉服务器我接下来要带哪些请求头。服务器收到OPTIONS后需要返回一组CORS响应头表示我允许你这样跨域访问浏览器检查这组响应头发现服务器允许才会真正发出POST请求真正POST的响应同样也要带上CORS响应头浏览器才会把结果交给你的前端代码。关键响应头通常包括响应头作用Access-Control-Allow-Origin允许的跨域来源可以指定域名或*Access-Control-Allow-Methods允许的HTTP方法如POST, GET, OPTIONSAccess-Control-Allow-Headers允许的请求头字段如Content-Type, AuthorizationAccess-Control-Max-Age预检结果缓存时间单位秒缓存期间不再重复发OPTIONSAccess-Control-Allow-Credentials是否允许携带Cookie值为true时Allow-Origin不能是*这里有个非常容易踩的坑如果后端没有正确配置Access-Control-Allow-Headers预检请求会直接失败真正的POST根本不会发出。可是在部分浏览器里Network面板上留给你的报错信息很模糊你只会看到Failed to load resource紧接着业务报错说接口不通。我见过不少同事在这个环节绕了很久最后才意识到是后端漏配了content-type。2.4 一个真实案例登录接口跨域预检举个最常见的例子。前端站点跑在http://localhost:8080后端接口在http://api.example.com前端要做一个JSON格式的POST登录OPTIONS /api/login HTTP/1.1 Host: api.example.com Origin: http://localhost:8080 Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type这时后端必须正确返回HTTP/1.1 204 No Content Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: Content-Type Access-Control-Max-Age: 86400如果后端返回里少了Access-Control-Allow-Headers: Content-Type浏览器就会在控制台报类似CORS error的信息真正的POST请求永远发不出去。从用户视角看就是点登录没反应从Network面板看就是多了一个OPTIONS请求POST没有发出去容易被误读成POST发两次。提示预检请求本身不是两次POST它是一次额外的OPTIONS探路请求。真正受影响的是接口延迟和服务器额外的请求开销。后端如果把OPTIONS请求当作普通业务请求处理还可能在日志里平白多出一堆记录。3. 除了预检还有哪些偷发场景3.1 重定向链导致的POST重复发送有时你看Network面板发现POST真的发了两次而且都是相同的URL状态码也都正常。这种情况很大概率是重定向导致的。举个例子浏览器请求http://api.example.com/submit但服务器返回了302响应头里的Location指向http://api.example.com/submit或者https://api.example.com/submit。浏览器看到重定向后会重新发起请求。如果服务器没有明确处理重定向后的请求方法某些情况下浏览器会再次以POST方式提交。结果就是服务器同一份表单数据被处理了两次订单重复下、评论重复发问题往往比预检严重得多。特别注意301、302、307、308这些状态码的语义差异状态码语义POST重发行为301永久重定向历史上多数浏览器会把POST改为GET但现在许多实现会保持POST重发302临时重定向同样存在POST变GET或POST重发两种实现行为依赖浏览器307临时重定向保持方法明确保持POST方法一定会再次POST308永久重定向保持方法明确保持POST方法一定会再次POST最常见的重定向来源有几个一是http跳https二是域名带www和不带www互跳三是老域名301到新域名。如果接口地址本身有跳转链路POST很可能被重复发送。3.2 前端逻辑重复触发这个场景特别容易出现在React、Vue这类组件化开发里。典型问题之一事件绑定写在了组件的render或setup阶段组件每次重新渲染都重新绑定一次事件导致点击一次按钮回调执行了两次甚至更多。另一个典型问题是弹窗组件被多次初始化关闭再打开后同样的事件监听了多遍。我调试过一个场景一个Modal里放了一个保存按钮每次打开Modal都往按钮上叠加一个click监听。用户第一次打开点一次只发一个POST第二次打开点一次就变成发两个POST第三次打开点一次发三个。说白了是事件监听器没有在组件销毁时移除或者使用了addEventListener却忘了removeEventListener。还有一种情况是框架的严格模式导致的。早期React的StrictMode在开发环境下会对某些周期函数执行两次如果你在useEffect里直接调接口生产环境可能没问题开发环境一刷新页面就会看到接口被调用两遍。这类问题要和用户点了两次按钮区分开。3.3 表单提交的隐藏坑传统HTML表单如果直接使用原生提交比如form action/api/submit methodpost button typesubmit提交/button /form点击按钮时浏览器会提交表单页面通常会跳转。如果开发者在按钮上还绑了一个Ajax提交事件又忘记把type改成button就会发生原生表单提交 Ajax提交双重POST。这类问题在直觉上很隐蔽因为你看到的代码里可能只有一个fetch调用点一下按钮却发两次请求。根源就是button默认的submit行为没有阻止。3.4 UI框架的查询即提交陷阱部分UI组件库特别是表格组件的参数变化会自动触发刷新事件。如果你在监听查询参数变化的回调里调用了POST接口而表格又同时因为内部状态变化而刷新也会出现POST请求重复发出。这种情况和代码里的业务逻辑耦合很深光看Network面板很难快速定位得顺着页面的交互链路一步步排查。4. 实战排查从接到问题到定位根因4.1 Network面板的四步观察法遇到POST发两次先别急着改代码打开Chrome开发者工具按照下面的顺序看四步第一步看请求列表里方法的全貌。是OPTIONS POST还是POST POST或者POST GET混着出现。第二步看每个请求的时间线。如果OPTIONS先返回POST随后发出时间线上有明显的前后依赖基本可以锁定是预检机制。如果两个POST几乎同时发起时间间隔极小更像前端事件被触发了多次。第三步看请求的Initiator列。Chrome会显示这个请求是由哪个脚本文件、哪个函数发起的。点进去直接跳到前端代码位置能帮你快速确认是不是事件重复绑定。第四步看请求头和请求体。如果两个POST的请求体完全一样说明数据被重复提交了如果请求体一个空一个满可能是其中一个请求本身就有问题。这个观察过程不需要任何额外工具浏览器自带功能就能完成大部分判断。4.2 用服务器日志做二次确认浏览器端的观察只能看到请求发出去了但不知道请求是不是真的到达了服务器也不知道服务器处理了哪些方法。所以扎实的排查一定要看服务器日志。你在服务器日志里主要看两个点是否有OPTIONS方法的日志。如果记录了OPTIONS /api/login HTTP/1.1 204说明预检请求已经到达后端。随后是否跟着POST的日志决定了预检阶段是否被正确放行。同一个会话IDSession下是否出现了两条POST记录。如果有两条说明前端确实发了两个相同方法、相同参数的POST请求问题大概率在前端或重定向链路上。有些后端框架默认会拦截所有带请求体的请求不做方法区分把OPTIONS也当普通接口处理导致预检请求直接返回业务逻辑错误。服务器日志里就会看到一堆奇怪的POST和OPTIONS混杂记录这也是排查时要特别留意的点。4.3 借助抓包工具侧写完整链路当浏览器Network面板和服务器日志对不上或者请求经过了CDN、网关等中间层直接看浏览器面板就不够了。这时候可以用专业的抓包工具或者直接在命令行里模拟请求。比如你想复现POST发两次的场景可以用curl发一个带自定义头部的POSTcurl -i -X OPTIONS http://api.example.com/api/login \ -H Origin: http://localhost:8080 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type这样能看到服务器对预检请求的原生响应头排除浏览器干扰。再看返回的Access-Control-Allow-*头到底少了哪些字段。另外很多后端框架的路由都会对方法做严格限制。如果你用curl直接发POST得到405 Method Not Allowed说明路径没问题但方法被限制了。如果得到request method post not supported类似的报错通常是服务端方法白名单配置问题比如只注册了GET却发了POST这种报错和请求发两次是两码事但很容易被混在一起讨论。提示有些时候你看到POST发两次其实是两条不同来源的请求比如一个是page tracking埋点一个是业务提交。先看Initiator列再谈优化。5. 解决方案与工程实践5.1 后端正确配置CORS预检响应跨域场景下最干净的方案是后端统一配置CORS策略把预检请求一次性处理好。以常见的Spring Boot为例可以在配置类里加一个过滤器Component public class CorsFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Access-Control-Allow-Origin, http://localhost:8080); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-Requested-With); resp.setHeader(Access-Control-Max-Age, 86400); if (OPTIONS.equalsIgnoreCase(request.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(request, response); } }注意几个细节Access-Control-Allow-Origin尽量不要直接用*因为一旦需要携带CookieAccess-Control-Allow-Credentials: true*就会失效必须指定具体的来源。Access-Control-Allow-Headers要把前端实际会用到的请求头都列出来。如果你不确定前端会传什么头可以先抓取OPTIONS请求里的Access-Control-Request-Headers字段照着补。对OPTIONS请求直接返回204不要再走下面的业务过滤器链或鉴权逻辑否则预检请求被拦截真正的POST永远发不出去。Nginx层也可以在最外层先兜住OPTIONS请求location /api/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Content-Type, Authorization always; add_header Access-Control-Max-Age 86400 always; return 204; } proxy_pass http://backend; }这种做法能提前终结大多数预检请求后端服务就不需要每次都在业务逻辑里处理OPTIONS了。5.2 前端防重复提交策略前端防止重复POST核心是同一时间只允许一次有效提交常见做法有三种。第一种是按钮防抖。点击提交后立刻把按钮置灰或者改成loading状态同时用一个标记变量拦截后续点击let submitting false; async function handleSubmit() { if (submitting) { console.warn(请求进行中请勿重复提交); return; } submitting true; submitBtn.disabled true; try { await fetch(/api/submit, { method: POST, body: payload }); } finally { submitting false; submitBtn.disabled false; } }第二种是请求层去重。把当前请求的url 参数拼成一个key放进Map里请求完成后再删除。这样即使多个按钮都触发了同一个接口也能保证同一时刻只发一次。第三种是表单提交场景下阻止默认行为并统一走Ajaxform idmyForm button typesubmit idsubmitBtn提交/button /formdocument.getElementById(myForm).addEventListener(submit, function (e) { e.preventDefault(); // 关键阻止原生表单提交 submitData(); });同时把按钮的type显式设为submit或button避免浏览器和脚本的事件各发一次。5.3 重定向场景的应对方案服务器端尽量避免对POST接口使用301/302重定向。尤其是RESTful设计里POST用于创建资源重定向会带来语义混乱和重复提交风险。如果确有必要做跳转比如http到https应在网关层用307/308来保持POST方法或者在重定向返回后让前端通过GET去拉取最终结果不要让浏览器自动重发POST。另一种做法是直接在后端内部转发而不是返回302。5.4 加预检缓存减少OPTIONS请求当你的接口跨域且使用JSON提交每次请求都要先飞一个OPTIONS网络开销确实大。可以在后端设置Access-Control-Max-Age来让浏览器缓存预检结果Access-Control-Max-Age: 86400表示一天之内同一个来源、同一种方法、同一组请求头的POST请求不需要再发OPTIONS。这样可以显著减少额外请求的出现频率也减轻服务器压力。6. 常见问题速查与独家心得6.1 高频问题速查表现象可能原因排查方向OPTIONS POSTCORS预检后端CORS响应头配置是否完整POST POST请求体一致前端重复触发或重定向检查事件绑定、按钮防抖、服务器重定向POST GET请求体一致302/301重定向导致方法改写改成307/308或后端内部转发只有OPTIONSPOST没发出预检失败检查Access-Control-Allow-Headers控制台报request method post not supported后端路由只支持GET等方法检查后端方法白名单配置开发环境接口走两遍生产环境正常框架StrictMode等开发模式行为看具体调用方和框架机制弹窗打开越久POST越多事件监听器重复注册检查addEventListener是否泄漏6.2 实测中容易忽略的细节第一个细节很多同学看到OPTIONS请求的第一反应是我是不是被攻击了或者是谁偷偷发了请求。不要慌绝大多数OPTIONS是浏览器自发的CORS预检不是黑客行为。判断标准很简单OPTIONS请求头里如果带了Access-Control-Request-Method和Access-Control-Request-Headers就可以认定是预检。第二个细节排查时不要只盯着Network面板看POST的次数还要看Fetch/XHR和Doc等不同请求类型。如果是页面跳转导致的表单重复提交Network类型会是Doc而不是Fetch/XHR。类型不一样排查方向完全不同。第三个细节如果后端有网关层比如Nginx、Kong、Gateway要确认网关是否对OPTIONS做了拦截。许多网关默认鉴权中间件会拦掉所有非业务请求导致OPTIONS返回401预检失败真正的POST一直发不出去。这种问题定位起来很费时间我建议直接把网关放行OPTIONS写进CORS整改清单里。第四个细节Docker拉镜像时如果遇到类似failed to fetch oauth token: post https://auth.docker.io/...的报错看起来是POST请求出了问题但本质上是客户端在向认证服务请求授权令牌时网络连通性失败或返回异常和后端业务里POST发两次是两码事。处理上要先检查认证服务是否可达、网络策略是否放行不要按请求重复的方向去查。最后一个心得处理这个问题的关键不是背答案而是建立自己的排查顺序。我现在的习惯是遇到POST发两次第一反应永远是先看方法序列——OPTIONS POST归CORS管POST POST归事件归重定向管POST GET归重定向方法改写管。这个三分法看起来很朴素实际用起来命中率特别高在十几个项目里帮我和团队省下了大量排错时间。希望兄弟们下次再遇到这个问题也能少绕几个弯。
返回列表