真正起作用的只有前端动态禁用提交控件和后端校验一次性submit_token并立即作废;必须监听form submit事件批量禁用按钮,fetch前用AbortController中止上一请求,token须由后端生成、绑定session且一次有效。

仅靠 HTML 属性无法阻止表单二次提交,disabled 静态写死、novalidate、autocomplete 全都无效。真正起作用的只有两件事:前端在 submit 事件中动态禁用所有提交控件 + 后端校验一次性 submit_token 并立即作废。
监听 form 的 submit 事件才是唯一可靠入口
别给按钮写 onclick="this.disabled=true",它漏掉回车提交、form.requestSubmit() 调用、多个 type="submit" 按钮等所有非点击路径。必须绑定到 form 元素本身:
- 用
form.addEventListener('submit', handler),不是onsubmit内联属性 - 在
e.preventDefault()后、发起请求前,批量禁用:form.querySelectorAll('button[type="submit"], input[type="submit"]') - 禁用前建议先调
form.checkValidity(),避免校验失败后按钮被永久锁死 - fetch 成功或失败后,必须在
finally中恢复按钮(btn.disabled = false),否则用户无法重试
隐藏字段里的 submit_token 必须由后端生成且一次有效
<input type="hidden" name="submit_token" value="..."> 这个字段本身不防重,它只是 token 的运输载体。关键在后端逻辑:
将调研报告、方案对比、数据分析、需求梳理、会议纪要、设计说明、代码评审、学习笔记等任务产物输出为可在浏览器直接打开的单文件 HTML,替代 Markdown 作为最终交付物。Markdown 在信息密度、视觉清晰度和可分享性上已跟不上 AI 产物的丰富度,HTML 能同时承载排版、表格、SVG 等。
- token 必须由服务端生成(如
bin2hex(random_bytes(16))或UUID),不能是Math.random()或md5(time()) - 生成后存入
$_SESSION['form_token'](PHP)或 Redis(带过期时间),并绑定当前用户 session 和表单类型 - 提交时比对
$_POST['submit_token'] === $_SESSION['form_token'],通过则立刻unset($_SESSION['form_token'])或 RedisDEL - 若用 fetch 提交,需手动取值:
document.querySelector('[name="submit_token"]').value,否则表单不会自动携带
fetch 场景下必须配 AbortController 中止上一个请求
按钮禁用只拦 UI,挡不住快速点击触发的多个并发 fetch 请求。尤其在慢网或接口延迟高时,用户点两下,后端就收到两个请求:
立即学习“前端免费学习笔记(深入)”;
- 声明一个全局变量
let currentController = null - 每次提交前执行
currentController?.abort(),再新建new AbortController()并赋值 - 把
currentController.signal传进 fetch 的options - 在
catch中显式判断err.name === 'AbortError',避免把中止当成业务错误上报 - IE 不支持 AbortController,老项目需降级用
Promise.race([fetch(), timeoutPromise])
最容易被忽略的是:token 校验通过后没 unset 或没做 htmlspecialchars() 输出,导致 XSS 或后续合法提交被拒;还有人把 token 存 localStorage 试图跨页面复用——这等于放弃防重能力。防重不是单点动作,是前端锁 UI + 中止请求 + 后端验 token + 数据库唯一约束四层咬合的结果。


















