必须用 button.disabled = true 禁用按钮,因其真正阻断交互;旋转图标应使用 bootstrap 5 的 spinner-border 元素;状态恢复须在 finally 中成套还原 disabled、文案、aria-disabled 和 tabindex。

必须用 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'); spinner.className = 'spinner-border spinner-border-sm'; spinner.setAttribute('role', 'status'); spinner.setAttribute('aria-hidden', 'true'); button.appendChild(spinner); - 再追加文案:
button.insertAdjacentText('beforeend', '提交中'); - 避免多次点击后 DOM 嵌套爆炸(比如
<span><span><span>...</span></span></span>)
状态恢复必须写在 finally 块里,且要成套还原
只在 .then() 里恢复,接口返回 400/500 就卡死;只在 .catch() 里恢复,成功后反而漏掉。正确姿势是:
- 提前缓存原始文案:
const originalText = button.textContent; - 请求链末尾统一清理:
.finally(() => { button.removeAttribute('disabled'); button.textContent = originalText; }) - 别用
button.disabled = false—— 某些旧环境不触发样式重绘 - 如果用了
AbortController,finally里也要调abortController.abort()
移动端和读屏器需要额外加固
部分安卓 WebView 和老版 Safari 对 disabled 的 pointer-events 拦截不彻底,手指轻点可能穿透到背后元素;NVDA + Firefox 等读屏器有时不播报纯 disabled 状态。所以还得补两笔:
- 禁用时:
button.setAttribute('aria-disabled', 'true'); button.tabIndex = -1; - 还原时:
button.removeAttribute('aria-disabled'); button.tabIndex = 0; - 若仍有点击穿透,临时加:
button.style.pointerEvents = 'none',还原时用button.style.removeProperty('pointer-events')
innerHTML 直接拼字符串(XSS 风险),也不能只靠 textContent 忽略图标;恢复时漏掉 aria-disabled 或 tabIndex,对视障用户就是功能缺失。











