必须设 btn.disabled = true 防重复提交,因浏览器仅识别 disabled 属性;spinner 要绝对定位居中且保留占位;恢复状态须在 finally 块中还原原始 html 并移除 spinner;type="submit" 按钮需设 type="button" 或 preventdefault()。

点击瞬间必须设 btn.disabled = true,否则重复提交不可避免
只改文字、只加 spinner-border、只加 disabled 类,都不行。浏览器原生防重机制只认 disabled 属性——它同时禁用鼠标点击、键盘回车/空格、表单序列化、焦点进入和事件冒泡。
常见翻车点:
-
btn.classList.add('disabled'):按钮视觉变灰,但 Enter 键仍能触发 - 对
<a></a>标签加disabled属性:无效,disabled只对<button></button>、<input>等表单控件生效 - 没在
finally块里恢复:fetch报 400/500 或网络中断后,按钮永久卡死
用 spinner-border 时必须配合 position: relative 和绝对定位
直接往按钮里 append spinner-border 会导致高度突变、文字上移——因为它是行内元素,参与流式布局计算,不是“浮”在文字上的。
正确做法:
- 给按钮加
style="position: relative; overflow: hidden;" - 创建
spinner-border元素后,用style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 1.5em; height: 1.5em;"居中覆盖 - 原始文字别用
display: none,改用visibility: hidden或opacity: 0,保留占位防按钮宽度塌缩 - 移动端额外加
style="pointer-events: none;",防 touch 事件穿透
恢复状态必须写在 finally 块里,且要还原原始 HTML 结构
无论请求成功、失败、被取消,按钮都得恢复可点击状态。漏掉任意一种分支,按钮就永远卡住。
关键操作:
- 点击前存原始结构:
btn.dataset.originalHtml = btn.innerHTML(含图标、data-*属性等) - 加载时先清空再插入:
btn.innerHTML = '',再btn.appendChild(spinnerEl),避免多次点击后 DOM 嵌套爆炸 - 恢复时统一执行:
btn.disabled = false、移除 spinner 节点、btn.innerHTML = btn.dataset.originalHtml - 别用
btn.text('提交')硬写字符串——原始 HTML 里的空格、换行、图标都会丢
type="submit" 按钮会触发两次?必须显式设为 type="button" 或调 event.preventDefault()
这是表单内按钮最常踩的坑:默认 type="submit" 会先执行 JS 事件,再触发表单 submit,导致异步请求发两遍。
两种解法任选其一:
- HTML 中直接写
<button type="button" class="btn btn-primary">提交</button> - 保留
type="submit",但在事件处理函数第一行加event.preventDefault()
顺便补上可访问性:btn.setAttribute('aria-busy', 'true')、btn.setAttribute('aria-disabled', 'true'),spinner 加 role="status" 和 aria-hidden="true",否则读屏器会重复读 “loading loading”。
真正难的不是怎么动,而是动完怎么收——状态流转必须原子化,JS 控制权不能交给 CSS 或样式类。











