禁用按钮时,务必使用 button.disabled = true 才是真正有效的做法。其他方式要么只改变外观不改变交互行为,要么仅防止鼠标点击却忽略键盘操作。即便按钮显示为灰色,用户仍能连续点击、通过 Tab 键聚焦并按回车触发提交,用户体验会严重受损。唯一可靠的方案就是直接操作按钮的原生属性。浏览器会自动为按钮应用相应样式,从而使语义、视觉与行为保持一致。

必须用 button.disabled = true,不是加 disabled 类
Bootstrap 5 不再响应 class="disabled" 来真正禁用按钮——它只改变视觉样式,不会阻止点击、键盘交互(空格/回车),也不会影响表单序列化。你看到的按钮变灰了,但用户仍能连续点击、通过 Tab 键聚焦并按回车提交。唯一稳妥的办法是直接操作原生属性:button.disabled = true 或 button.setAttribute('disabled', '')。浏览器会自动触发 button:disabled 样式规则(包含 opacity: .65 和 pointer-events: none),这样语义、外观与交互才能统一。
旋转图标要用 spinner-border 插入 DOM,别靠伪元素或 CSS 动画
通过伪元素旋转(如 ::after { animation: spin })容易导致文字偏移、按钮宽度变化,尤其在小尺寸按钮上更为明显;纯 CSS 动画也难以精确控制尺寸和居中。Bootstrap 5 内置的 spinner-border 组件更加可靠:
- 插入前先清空按钮内容:
button.innerHTML = '' - 创建并插入:
const spinner = document.createElement('span');,设置className = 'spinner-border spinner-border-sm',添加role="status"和aria-hidden="true" - 再追加文字:
button.insertAdjacentText('beforeend', '提交中') - 这样可以避免多次点击后 DOM 嵌套溢出(例如
...)
状态恢复必须写在 finally 块里,且要成套还原
只在 .then() 中恢复状态,接口返回 400/500 时就会卡住;只在 .catch() 中恢复,成功状态反而被遗漏。正确的做法是在请求链末尾统一清理:
.finally(() => { button.removeAttribute('disabled'); button.textContent = originalText; })- 不要用
button.disabled = false——某些旧浏览器不会触发样式重绘 - 若使用了
AbortController,finally块中也要调用abortController.abort() - 同步清除
aria-disabled和tabIndex:禁用时设置aria-disabled="true"和tabIndex = -1,还原时移除并恢复tabIndex = 0
移动端和读屏器需要额外加固
部分安卓 WebView 和老版 Safari 对 disabled 的 pointer-events 拦截不够彻底,手指轻点可能穿透到下层元素;NVDA + Firefox 等屏幕阅读器有时不会播报单纯的 disabled 状态。因此还需要补充两处代码:
- 禁用时:
button.setAttribute('aria-disabled', 'true')、button.tabIndex = -1、button.style.pointerEvents = 'none' - 还原时:
button.removeAttribute('aria-disabled')、button.tabIndex = 0、button.style.pointerEvents = '' - 若仍有点击穿透问题,临时添加
button.style.position = 'relative'防止层叠错乱
最容易忽略的一点是:原始文字必须提前缓存(例如存入 dataset.originalText),否则还原时图标或空格会被清空;另外按钮类型应设为 type="button",否则在表单内会默认触发提交,导致逻辑重复执行两次。
