必须用 button.disabled = true 禁用按钮,因其能真正阻止点击、键盘操作和表单序列化;配合 spinner-border 插入、finally 统一恢复状态、同步处理 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');,设 className = 'spinner-border spinner-border-sm',加 role="status" 和 aria-hidden="true"
button.insertAdjacentText('beforeend', '提交中')
<span><span><span>...)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",否则在表单内会默认触发表单提交,导致逻辑执行两次。