大家有没有遇到过这种情况:页面大部分内容都已经出来了,唯独关键区域一片空白,用户不知道系统在干活,只能一遍遍刷新页面。我早先做过一个后台管理页面,某个统计表格在老设备上要跑将近三秒,线上演示时,老板当着客户的面问“这页面是不是卡了”。从那次之后,我把 Bootstrap 5 的加载效果整体梳理了一遍,把列表、表单、图片、首屏几类场景统一做了兜底,同样是等三秒,用户反馈完全不一样。这篇就把我的做法和踩过的坑整理出来,聊透 Bootstrap 5 的 spinners、进度条、骨架屏和按钮加载态。
1. 加载效果这件事,为什么在Bootstrap 5里值得单独梳理
1.1 我们讨论的不只是一个“转圈动画”
加载效果的本质,是给用户一个“系统还在工作”的反馈。反馈缺失时,用户会怀疑操作失败,于是重复点击、刷新、重新提交;有了反馈,哪怕只是转圈,用户也知道系统在干活。这和等电梯看楼层数字是一个道理:电梯门一关,如果屏幕没有任何数字变化,你很快就会焦虑,会反复按开门键。
加载反馈还有一个容易被忽略的价值,就是降低重复操作带来的脏数据。尤其是表单提交场景,没有按钮禁用和加载状态,用户双击提交就会产生两条订单、两条留言,后面清理数据的成本远比你加一个加载状态高得多。所以这个问题不只是“好不好看”,它直接影响系统可靠性和数据质量。
从体验上讲,加载效果属于“系统透明度”的一部分。用户不关心你用了什么框架、接口返回是多快,他只关心当前状态是否明确。Bootstrap 5 把常见的加载反馈组件做得足够轻量,不依赖额外库,日常项目可以直接套用。
1.2 Bootstrap 5 提供了哪些加载组件
Bootstrap 5 中和加载相关的组件或工具类主要有下面这些,日常项目中基本够用:
| 组件/工具类 | 形态 | 适用场景 | 依赖 |
|---|---|---|---|
spinner-border | 旋转圆圈 | 短耗时、局部加载反馈 | 仅需 CSS |
spinner-grow | 呼吸式圆点 | 柔和视觉场景 | 仅需 CSS |
progress | 进度条 | 可计算百分比的上传/下载/批量处理 | CSS + JS 控制宽度 |
placeholder | 骨架屏占位块 | 首屏、列表布局未稳定时 | 仅需 CSS |
按钮内嵌spinner+disabled | 按钮加载态 | 表单提交、保存、防重复点击 | CSS + 少量 JS |
这里经常有人搞混一点:spinner 和 placeholder 都是纯 CSS 组件,你只需要引入 Bootstrap 的 CSS 文件就能跑,不一定需要 bootstrap.bundle.js。真正需要 JavaScript 的地方,是你根据接口状态去切换它们的显示和隐藏,这部分业务逻辑得你自己写。
1.3 和 Bootstrap 4 相比的关键差异
Bootstrap 5 不再依赖 jQuery,这是一次比较大的变化。以前的加载效果组件往往要配合 jQuery 插件或额外的 JS 才能做切换,现在可以直接和原生fetch、XMLHttpRequest配合,逻辑反而更清爽。
另一个差异是 CSS 变量的大面积铺开。Bootstrap 5 里 spinner 的宽高、边框粗细、progress 的高度、placeholder 的尺寸,大多可以通过--bs-*变量覆盖,做定制主题的时候不用再写一堆!important。新版还新增了placeholder组件,骨架屏成为官方内置能力。如果你还在维护老项目,这套思路也能帮你从 Bootstrap 4 平滑迁移。
2. 四种加载组件的用法与原理拆解:该选哪个、怎么写、为什么
2.1spinner-border和spinner-grow:旋转与呼吸的现实选择
最基础的加载效果是旋转圆圈。官方标准写法是这样:
<div class="d-flex justify-content-center align-items-center py-5"> <div class="spinner-border text-primary" role="status"> <span class="visually-hidden">加载中...</span> </div> </div>role="status"和visually-hidden不是装饰,读屏软件会把“加载中”播报给视障用户。视觉上你可以只看到圆圈在转,但无障碍层面还必须有一个文字说明。如果完全不需要读屏播报,也可以写成纯装饰性:
<div class="spinner-border text-primary" aria-hidden="true"></div>spinner-grow则是呼吸式圆点,通过透明度渐变模拟“生长”效果。它的视觉冲击力不如旋转圆圈,但在浅色背景、强调柔和氛围的场景下更合适。同一个接口加载提示,我一般用spinner-border做列表加载,用spinner-grow做自动保存之类的轻提示。
颜色可以通过文本工具类调整:text-primary、text-danger、text-success都能直接用。注意 spinner 的边框颜色继承当前文字颜色currentColor,所以如果你外层容器已经设了text-secondary,里面 spinner 也会跟着变灰。背景是深色遮罩时,记得用text-light或自定义颜色类。
尺寸方面,默认 spinner 是 1rem 乘 1rem,.spinner-border-sm缩小到适合放在按钮里的尺寸。想要更大或更小,推荐用 CSS 变量覆盖:
.spinner-border.spinner-lg { --bs-spinner-width: 3rem; --bs-spinner-height: 3rem; --bs-spinner-border-width: 0.35em; }需要注意:--bs-spinner-border-width是相对单位,同时它会受font-size影响。改尺寸时最好把宽度、高度、边框一起调整,避免圆圈变成细碎的转环。
多个 spinner 一起展示时,用gap系列工具类控制间距:
<div class="d-flex justify-content-center gap-2"> <div class="spinner-border text-primary" role="status"></div> <div class="spinner-grow text-secondary" role="status"></div> </div>2.2progress:能算百分比时别只转圈
进度条给用户的信息量远大于转圈。人脑对“还剩 30%”这种确定性反馈比“正在加载”更容易建立耐心。Bootstrap 5 的基础进度条写法:
<div class="progress" style="height: 8px;" role="progressbar" aria-label="加载进度" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100"> <div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 50%;"></div> </div>这里的逻辑分两层:最外层div.progress是轨道,内层div.progress-bar是视觉填充条。真正决定视觉宽度的是内层style="width: 50%",而aria-valuenow是给读屏软件用的。两个值必须保持一致,否则视觉上走到一半,读屏却说 100%,无障碍测试就是不合格的。
progress-bar-striped通过背景渐变在填充条上画条纹,progress-bar-animated让条纹沿背景位移动画流动。加了条纹之后,填充条的背景被锁定了,想直接改 background 渐变会覆盖掉条纹,导致动画失效,我后面会说到这个坑。
实际场景里,fetch 请求默认拿不到“下载百分比”这种东西,因为 fetch 的 Response 不提供进度事件。要真实展示上传或下载进度,用XMLHttpRequest的事件监听:
const xhr = new XMLHttpRequest(); xhr.open('POST', '/upload', true); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); bar.style.width = percent + '%'; bar.setAttribute('aria-valuenow', percent); } });如果你拿不到准确的百分比,我建议宁可只用 spinner,也不要让进度条先跳到 60% 然后卡住,那种假进度反而让用户更恼火。
2.3placeholder:骨架屏比转圈更适合“首屏等待”
Bootstrap 5.1 开始提供placeholder组件,本质是一块块灰色占位元素,模拟真实内容的形状。服务端数据还没回来时,用户看到的是内容轮廓正在“逐渐成型”,这种反馈比一个孤零零的旋转图标更有安全感,也有效避免数据回来后的布局跳动。
基础用法:
<div class="placeholder-glow"> <h1><span class="placeholder col-6"></span></h1> <p><span class="placeholder col-8"></span></p> <p><span class="placeholder col-4"></span></p> </div>宽度用col-*工具类,比如col-6表示父容器宽度的 50%。高度默认是 1em,可以用placeholder-lg或placeholder-sm调整。颜色走bg-*背景类,一般默认是bg-secondary。
需要特别记住:placeholder-glow和placeholder-wave这两个动画类要加在父容器上,不是加在占位元素自己身上。很多人第一次用都写错,结果占位块是出来了,但完全不会闪动。
骨架屏真正好用的地方在于模拟布局。比如一个统计卡片区域:
<div class="card"> <div class="card-body placeholder-glow"> <span class="placeholder col-6 mb-3"></span> <span class="placeholder col-10"></span> <span class="placeholder col-8"></span> </div> </div>等真实数据到了,直接把占位块替换成真实内容,视觉上非常自然。这种做法尤其适合后台管理系统首页,那些图表、数字卡片、列表的结构都是固定的,骨架屏能提前把位置占住。
2.4 按钮加载态与无障碍基础
按钮内嵌 spinner 是防重复提交最常见的做法:
<button class="btn btn-primary" type="button" disabled> <span class="spinner-border spinner-border-sm" aria-hidden="true"></span> <span class="visually-hidden">正在提交...</span> <span class="ms-1 d-none d-md-inline">提交中</span> </button>结构上我习惯拆三部分:spinner加aria-hidden="true",避免读屏把“正在提交”读两遍;visually-hidden的文字是给读屏用户准备的;可见的“提交中”是给明眼人看的。disabled属性则直接阻止按钮再被点击,这是防重复提交最硬的手段。
配合 JS 的逻辑通常是这样:
document.querySelector('#save-form').addEventListener('submit', async (e) => { e.preventDefault(); const btn = e.target.querySelector('button[type="submit"]'); toggleLoading(btn, true); try { await fetch('/api/save', { method: 'POST', body: new FormData(e.target) }); } finally { toggleLoading(btn, false); } }); function toggleLoading(btn, loading) { if (loading) { btn.disabled = true; btn.dataset.originalHtml = btn.dataset.originalHtml || btn.innerHTML; btn.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>' + '<span class="visually-hidden">保存中</span>'; } else { btn.disabled = false; btn.innerHTML = btn.dataset.originalHtml; } }用finally保证请求无论成功失败,按钮都能恢复可点击。这里有个小细节:先把原始innerHTML存到dataset里,请求成功或失败后原样恢复,避免按钮文案被反复覆盖。
3. 实战场景:表格、表单、图片、首页骨架屏是怎样用起来的
3.1 表格数据请求时的局部加载
后台管理表格是加载反馈的重灾区。搜索、翻页、筛选都要重新请求接口,如果不加加载状态,表格区域就是一块白板。我的做法是给表格区域设定最小高度,请求期间显示居中 spinner,请求结束后移除,这样页面不会因为内容的出现和消失而上下跳动。
<div id="tableWrap"> <table class="table table-hover"> <!-- 表格真实内容 --> </table> </div> <div id="tableLoading" class="d-flex justify-content-center align-items-center py-5 d-none"> <div class="spinner-border text-primary" role="status"> <span class="visually-hidden">数据加载中</span> </div> </div>JS 切换逻辑:
async function loadTable() { const wrap = document.getElementById('tableWrap'); const loading = document.getElementById('tableLoading'); wrap.classList.add('d-none'); loading.classList.remove('d-none'); try { const res = await fetch('/api/list'); const data = await res.json(); renderTable(data); } finally { wrap.classList.remove('d-none'); loading.classList.add('d-none'); } }还有一个小技巧:给#tableWrap设置一个min-height,比如style="min-height: 240px",这样加载框出现时,页面高度不会突然缩掉一大截。用户盯着同一位置,就不需要重新找内容,体感会好很多。
3.2 表单提交防重复
防重复提交是加载反馈里最有实际价值的部分。按钮一旦在提交后没有任何状态变化,用户几乎都会下意识多点几下。传统后端还能靠幂等性挡一挡,但前端的体验问题还是得前端解决。
以最常见的评论框为例,提交后应该立刻把按钮变成“发送中”并禁用,等接口返回后再恢复。这个过程中还有一点要注意:按钮禁用期间,表单的submit事件不会再次触发,这能兜住绝大多数重复提交。
如果业务要求提交后短时间内不允许再次点击,最好不要使用alert或toast这种打断性提示,而是把按钮文案从“提交”改成“提交成功”。用户看到按钮达到预期状态,自然就不会再点。这个“状态替代提示”的思路,在后台任务类的操作里非常实用。
3.3 图片懒加载占位与布局稳定
图片加载慢、加载失败,最常见的问题就是布局被撑爆或突然跳动。用 placeholder 做占位,配合固定宽高比,能很好控制布局偏移。一个典型写法:
<div class="position-relative ratio ratio-16x9 bg-light placeholder-glow overflow-hidden"> <span class="placeholder w-100 h-100"></span> <img src="real.jpg" class="position-absolute top-0 start-0 w-100 h-100" alt="" loading="lazy" onload="this.previousElementSibling.remove()"> </div>图片没有加载完时,用户看到的是一个 16:9 的灰色占位区域;图片加载完成后,占位块被移除,真实图片直接替换。因为有ratio ratio-16x9固定了高度比例,整个过程中布局不跳动。这就是前端经常说的 CLS(布局偏移)控制,对页面稳定性评分很重要。
3.4 后台首页的骨架屏组合
后台首页往往是多个接口并行或串行取数,整体用一个全屏 spinner 盖住,用户完全看不到页面结构,体验很差。骨架屏更适合这种场景:按区域切分占位块,数据一个个落到对应区域。
我常用的布局是左侧四个统计卡片、右侧一个最近订单列表。真实接口返回前,骨架屏长这样:
<div class="row g-4"> <div class="col-6 col-md-3"> <div class="card h-100"> <div class="card-body placeholder-glow"> <span class="placeholder col-5 mb-3"></span> <span class="placeholder col-8"></span> <span class="placeholder col-4"></span> </div> </div> </div> <!-- 重复 4 个卡片 --> </div> <div class="card mt-4"> <div class="card-body placeholder-glow"> <span class="placeholder col-3 mb-3"></span> <span class="placeholder col-12"></span> <span class="placeholder col-12"></span> <span class="placeholder col-12"></span> </div> </div>这样页面一开始就有完整结构,给用户的感觉是“系统正在组装内容”,而不是“页面打不开”。等接口返回后,按区域替换 DOM,骨架屏消失。
4. 调试过程中的坑与排查思路:不转的spinner、不走的进度条、不亮的骨架
4.1spinner不显示或不在预期位置的排查
我遇到过好几次 spinner 根本不出现的情况,排查下来基本都是这几个原因。建议按顺序查:
- 确认 Bootstrap CSS 真的加载了。spinner 是纯 CSS 组件,没有 CSS 就没有任何显示。
- 在浏览器开发者工具里查看 spinner 的
display属性。spinner 默认是inline-block,如果父容器既没有设宽高、又没有 flex 布局,你很难让它出现在预期位置。 - 想让 spinner 居中,最省事的方式是给父容器加
d-flex justify-content-center align-items-center,并且给一个最小高度,比如py-5或min-height: 200px。否则它虽然“在文档流里”,但视觉上就贴在上方,看起来像没居中。 - 颜色问题。spinner 的边框用
currentColor,如果你外层容器文字颜色和背景色接近,spinner 就像消失了一样。检查时直接看.spinner-border的color计算值。
还有一次项目里有人反馈“spinner 变成长条了”,最后发现是外部样式把border-radius和border-width改了。这种情况优先用 CSS 变量重置,不要直接覆盖边框属性。
4.2progress的宽度与 aria 值同步问题
进度条最常见的坑有两个。
一是只设置了aria-valuenow,内层width没设或者设了但被覆盖。aria-*是给读屏软件读的,视觉上真正起作用的是style="width: 60%",两者要一起改。只改一个,要么视觉不动,要么无障碍测试报错。
二是条纹动画“走了但没有走”。progress-bar-striped和progress-bar-animated组合时,动画的本质是背景位置的平移。如果外部样式给内层 progress-bar 覆盖了background属性,条纹就被背景图片顶掉了,动画自然看不见。想改颜色,用background-color,而不是background。想调动画速度,直接控制animation-duration:
.progress-bar.progress-bar-animated { animation-duration: 1.5s; }还有一个容易被忽略的问题:如果用户在系统里开启了“减弱动态效果”,Bootstrap 5 在高版本下会自动降级动画。这不是 bug,是设计预期,后面第 4.4 节详细说。
4.3placeholder动画失效与宽度问题
骨架屏项目里,我积累下来这几个高频坑:
- 把
placeholder-glow写在占位元素自己身上,结果没有动画。必须放在父容器上。 - 占位元素宽度不理想。
col-*是基于父容器宽度的百分比,如果父容器本身没有确定宽度,百分比就无从谈起。直接出现“占位块挤成一团”或“完全看不到宽度变化”,先检查父容器。 - 颜色覆盖失误。
placeholder默认背景色是bg-secondary,但如果你把bg-*换成bg-white,在白色背景上就完全看不出来。骨架屏类“幽灵元素”要和背景色拉开对比度。 - 做头像这类圆形占位时,纯用
placeholder类不好控制,我直接用工具类组合:
<span class="placeholder rounded-circle d-inline-block" style="width: 48px; height: 48px;"></span>对于有严格视觉要求的骨架屏,我更建议自定义一组.skeleton-block类,用 CSS 固定宽高,比在 HTML 里堆placeholder更可控。
4.4 无障碍与动效偏好
加载效果一定要考虑读屏用户。
- spinner 承载状态时加
role="status"和visually-hidden文字。 - 按钮内嵌 spinner 时,spinner 本身加
aria-hidden="true",状态文字单独提供。 - 进度条加
role="progressbar"、aria-valuenow、aria-valuemin、aria-valuemax。 - 装饰性的加载动画要能区分开,不能让读屏把“加载中”读好几遍。
动效偏好方面,很多人可能没注意:系统开启“减弱动态效果”后,Bootstrap 5.3 会自动关闭进度条条纹动画,但 spinner 的旋转默认不会停。如果你希望尊重用户偏好,可以加一段自定义样式:
@media (prefers-reduced-motion: reduce) { .spinner-border, .spinner-grow { animation-duration: 1.5s; } }想要更彻底,也可以直接关闭动画,显示静态圆圈加文字说明。加载效果的存在是为了减少焦虑,如果动画本身成为负担,就本末倒置了。
5. 把加载效果做成“有品牌感”的细节
5.1 用 CSS 变量覆盖尺寸与颜色
Bootstrap 5 的加载组件基本都支持变量覆盖。拿 spinner 来说,--bs-spinner-width、--bs-spinner-height、--bs-spinner-border-width可以整体控制规模;progress 则有--bs-progress-height、--bs-progress-bar-bg等变量。
比如想做一个品牌色的大号 spinner:
.spinner-brand { --bs-spinner-width: 3rem; --bs-spinner-height: 3rem; --bs-spinner-border-width: 0.3em; color: #6366f1; }进度条做成细条品牌色:
.progress-brand { --bs-progress-height: 6px; --bs-progress-bar-bg: #6366f1; }只要覆盖变量,不需要碰组件内部结构,也不用担心权重问题。比早期 Bootstrap 版本写一堆覆盖样式清爽很多。这个思路同样适用于 placeholder 的尺寸变量,主题化时非常方便。
5.2 全屏遮罩与业务加载状态
首屏数据加载、需要阻塞用户操作的场景,常常需要全屏遮罩。Bootstrap 5 没有内置遮罩组件,但工具类组合起来很方便:
<div id="globalLoading" class="position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center bg-white bg-opacity-75 d-none" style="z-index: 1100;"> <div class="text-center"> <div class="spinner-border text-primary" role="status"></div> <div class="mt-2 text-secondary">正在加载基础数据...</div> </div> </div>这里有一个细节:z-index要高于 Bootstrap 5 默认的导航和弹窗层级。默认 modal 的 z-index 是 1055,toast 是 1090,所以全屏遮罩我用 1100,避免被这些浮层压在下面。
还有配色:背景是浅色遮罩,spinner 用深色文字色(text-primary、text-dark);背景是深色遮罩,spinner 就要用text-light。我自己就犯过在深色导航背景上放一个text-primaryspinner,结果几乎看不出来,最后全改成text-light才算完。
5.3 什么时候该用、什么时候别用
加载效果不是越多越好,关键是匹配接口耗时。
- 接口响应在 300 毫秒以内,基本不需要加载反馈,加了反而一闪而过,制造闪烁感。
- 超过 1 秒,必须有反馈,spinner 或 placeholder 二选一。
- 超过 2 到 3 秒,仅转圈可能不够,尽量给文字提示,比如“数据较多,正在加载”,有条件就给进度。
- 拿不到真实百分比,不要用假进度条,spinner 或骨架屏更诚实。
- 能局部加载就别全屏遮罩,全局转圈会打断用户的阅读和操作流,显得每件事都特别重。
这些阈值是我在项目里实测出来比较舒服的节奏,不是标准答案,但可以作为启动加载效果的基准,至少不会再出现“页面卡死了吗”这种灵魂拷问。
最后再分享一点个人体会。加载效果这件事,我最初觉得只是加几个 spinner 的小事,后来发现它更像一个交互状态的枚举:列表加载用什么、表单提交用什么、首屏等待用什么、图片加载失败又该怎么兜底。建议第一次接触 Bootstrap 5 加载效果的同学,先打开浏览器的开发者工具,把网络切到 Slow 3G,然后把表格、按钮、首页几个场景各看一遍。哪一个位置会闪白、哪一个位置会跳布局,一眼就能看出来。效果这东西,平时感觉不到它的存在,等你把所有空白都补齐之后,你就知道用户嘴上不说,心里已经没那么毛躁了。