ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端Loading加载页面实践:从CSS动画到SPA框架的完整方案

前端Loading加载页面实践:从CSS动画到SPA框架的完整方案 网页加载慢是常态白屏更是用户流失的第一杀手。不管你是做电商页面、后台管理系统还是套了 iframe 的子页面loading 都不是一个转圈圈那么简单的事。这篇文章我会把 loading 页面的实现方式按从手写到工程化、从静态到动态的顺序完整拆开讲一遍包括纯 CSS 动画、JavaScript 动态控制、第三方库选型、Vue/React 框架里的做法以及路由切换和 iframe 嵌套这两个最容易出问题的场景。适合正在做前端页面开发、需要优化用户体验的开发者参考。1. loading 体验的底层逻辑为什么加载状态必须认真做很多人觉得 loading 就是一个转圈图标这其实是对加载状态最大的误解。加载状态本质上是用户与系统之间的一种交互协议当页面或数据还在路上时它告诉用户系统没死正在干活请稍等。如果没有这层反馈用户面对白屏的第一反应就是刷新页面而刷新又会引发新的请求又带来更长的等待形成恶性循环。从交互设计角度来说loading 解决的是不确定性焦虑。人眼对超过 300ms 的等待就会产生感知超过 1 秒就会开始焦躁超过 3 秒基本判定这个网站坏了。而 loading 起到的缓冲作用实际上是在帮系统争取时间和信任。我见过不少开发者在联调阶段不重视 loading等到线上用户反馈页面打不开时才发现——其实只是接口慢但因为没有任何加载提示用户误判为故障。落实到技术实现上loading 形态通常可以分成三类指示型最常见的转圈、进度条告诉用户正在发生某事。骨架型用占位块模拟页面真实结构让用户对即将看到的内容有预期。反馈型操作反馈类的加载比如按钮点击后变为 loading 状态防止重复提交。这三类形态适用于不同场景。接口数据初始化适合骨架屏按钮提交适合按钮级 loading页面间跳转适合顶部进度条或全屏遮罩。我后面讲的每一种实现方法都会标注它适合的形态和场景方便你直接对号入座。还有一点容易被忽略loading 的出现时机比动画本身更重要。一个飞快到肉眼看不清的 loading 没有意义一个在接口 10ms 就返回时还要强制展示 500ms 的 loading 反而是拖累。合理做法是给 loading 加一个最小展示时间或者延迟出现时间后面我会给到具体的控制写法。2. 纯 CSS 动画一个 Spinner 背后的完整思路先看最基础也最常见的做法纯 CSS 手写 loading 动画。这种方式没有任何依赖性能最好也不需要操作 DOM适合静态页面、简单弹窗、按钮等待等轻量场景。2.1 旋转圆环最通用的 loading 图标一个标准的旋转 Spinner核心原理是利用border的不对称颜色加上border-radius再配合rotate动画。CSS 代码如下.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #409eff; border-radius: 50%; animation: spinner-rotate 1s linear infinite; } keyframes spinner-rotate { to { transform: rotate(360deg); } }这里有个细节值得说明border: 4px solid #e0e0e0是底色圆环border-top-color单独把上边框改成主色这样旋转时看起来就像有一个色块在追着跑。如果想更精致一点可以把边框改成半透明底色或者用conic-gradient做一个渐变圆环。不过conic-gradient的兼容性在一些老浏览器上还是有问题追求稳的话用 border 方案就足够。2.2 三点跳动适合小组件和按钮内嵌三点跳动适合空间有限的场景比如按钮内部、列表底部的加载提示。实现上依赖animation-delay让三个点错峰运动.loading-dots { display: inline-flex; gap: 6px; } .loading-dots span { width: 8px; height: 8px; border-radius: 50%; background: #409eff; animation: dot-bounce 1.2s ease-in-out infinite; } .loading-dots span:nth-child(2) { animation-delay: 0.2s; } .loading-dots span:nth-child(3) { animation-delay: 0.4s; } keyframes dot-bounce { 0%, 80%, 100% { transform: scale(0.6); opacity: 0.6; } 40% { transform: scale(1); opacity: 1; } }2.3 进度条动画线性的确定感如果加载过程有明确的进度预期用进度条比转圈更合适。纯 CSS 可以实现两种进度条一种是不确定时长的跑马灯式来回扫一种是配合 JS 更新宽度的确定式。先看不依赖 JS 的跑马灯.progress-bar { height: 4px; background: #e0e0e0; overflow: hidden; } .progress-bar::after { content: ; display: block; height: 100%; width: 40%; background: #409eff; border-radius: 2px; animation: progress-slide 1.5s ease-in-out infinite; } keyframes progress-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }跑马灯进度条适合整体页面的 init 加载它传达的意思是系统正在初始化时间不确定比转圈更具视觉动感。确定式进度条我放到后面配合 JS 一起讲因为那需要真实的加载进度数据支撑。2.4 全屏遮罩给页面加一层等待锁实际项目中全屏 loading 往往不是单独一个图标而是一个遮罩层 图标组合。遮罩层的作用除了视觉提示还有阻止用户在加载期间点击页面避免触发多余请求。.loading-mask { position: fixed; inset: 0; z-index: 9999; background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; justify-content: center; transition: opacity 0.3s ease; }需要注意inset: 0是top/right/bottom/left: 0的简写兼容性在现代浏览器都没问题。遮罩层加transition: opacity是为了让消失时有个淡出效果不会突然闪没。3. JavaScript 动态控制把 loading 变成可编程的组件纯 CSS 只是把样子做好了真正让 loading 发挥作用的是它的生命周期管理——什么时候出现、什么时候消失、多个请求并发时怎么处理。这些都得靠 JavaScript。3.1 手动创建和销毁 loading 节点最朴素的 JS 做法是动态创建 DOM 节点。我不建议在 HTML 里写死一个隐藏的 loading 节点然后 toggle 显隐因为这样初始就会渲染 DOM白白增加一次渲染开销。更好的做法是按需创建、用完即毁function showLoading() { if (document.getElementById(global-loading)) { return; } const mask document.createElement(div); mask.className loading-mask; mask.id global-loading; mask.innerHTML div classspinner/div; document.body.appendChild(mask); } function hideLoading() { const mask document.getElementById(global-loading); if (mask) { mask.style.opacity 0; setTimeout(() mask.remove(), 300); } }这里我加了个防重复判断getElementById存在就直接 return避免连续调用showLoading导致堆了多个遮罩层。hideLoading里用 300ms 延时和 opacity 过渡配合等淡出动画播完再移除节点观感上会舒服很多。3.2 请求并发时不能让 loading 闪烁这是实际开发里踩坑最集中的地方。如果你在请求开始时showLoading、请求结束时hideLoading一旦有两个接口并发就会出现第一个请求结束把 loading 关了第二个请求还没结束的情况遮罩提前消失用户体验打折。解法是维护一个请求计数器let pendingCount 0; function showGlobalLoading() { pendingCount; if (pendingCount 1) { showLoading(); } } function hideGlobalLoading() { if (pendingCount 0) { pendingCount--; } if (pendingCount 0) { hideLoading(); } }逻辑很简单只有计数器从 0 变 1 时才真正显示只有回到 0 时才真正隐藏。这样无论并发多少个请求loading 只跟第一个开始和最后一个结束挂钩不会有中间闪烁。3.3 拦截器统一接入axios 和 fetch 的两种做法在实际项目中我强烈不建议在每个业务请求里手动调用showGlobalLoading和hideGlobalLoading那既啰嗦又容易漏。更工程化的方式是统一封装。axios 的拦截器是最顺手的接入点import axios from axios; const service axios.create({ baseURL: /api }); service.interceptors.request.use(config { showGlobalLoading(); return config; }); service.interceptors.response.use( response { hideGlobalLoading(); return response.data; }, error { hideGlobalLoading(); return Promise.reject(error); } );如果用原生 fetch就需要自己包一层async function request(url, options) { showGlobalLoading(); try { const res await fetch(url, options); return await res.json(); } finally { hideGlobalLoading(); } }finally在 fetch 方案里比catch更好用因为它能保证无论成功失败都会执行hideGlobalLoading不会让 loading 卡死。3.4 延迟出现策略短请求不闪加载回到我在开头提到的加载时机问题。如果一个接口只要 50ms 就返回你还显示 loading用户反而会觉得页面闪了一下观感更差。合理策略是让 loading 延迟 200ms 再出现如果请求在 200ms 内结束了就不显示。let loadingTimer null; function showLoadingDelay() { loadingTimer setTimeout(() { showLoading(); }, 200); } function hideLoadingDelay() { clearTimeout(loadingTimer); hideLoading(); }这个优化很小但对体验提升非常明显。尤其是本地开发环境下接口响应极快如果没做延迟策略每次请求都会看到 loading 一闪而过那种闪烁感很掉档次。4. 第三方库选型什么场景该用什么别盲目引库方案到这儿其实已经能覆盖绝大多数需求了。不过社区里仍然有不少成熟的 loading 库有些场景下引库反而比自己写更划算——前提是你清楚每个库的定位和适用边界。4.1 适合全屏 loadingspin.jsspin.js 是一个老牌 loading 库专做配置化的转圈动画。它支持通过 JS 参数调整大小、颜色、线宽、速度等适合需要动态生成不同样式 loading 的场景。它的 API 非常简洁import { Spinner } from spin.js; const target document.getElementById(loading-container); const spinner new Spinner({ color: #409eff, lines: 12 }).spin(target);spin.js 最大的优势是零 CSS 依赖所有动画都通过 canvas 或 DOM 生成样式都在 JS 里配置。但它的缺点也在这里——如果项目里 UI 风格已经统一用 spin.js 反而和整体样式不一致。4.2 适合顶部进度条nprogressnprogress 是路由器转跳时用的标配库那种固定在浏览器顶部的细长进度条就是它的杰作。它适合 SPA 路由切换、页面跳转这类整页刷新场景视觉干扰小不需要遮罩用户可以在加载过程中继续浏览页面的其他部分。import NProgress from nprogress; import nprogress/nprogress.css; NProgress.configure({ showSpinner: false }); router.beforeEach(() { NProgress.start(); }); router.afterEach(() { NProgress.done(); });一个小建议showSpinner: false通常值得配置因为右上角的转圈图标在顶部进度条场景里有点多余去掉后视觉更干净。4.3 框架组件库内置Element Plus / Ant Design如果你用的是 Element Plus 或 Ant Design 这类完整组件库通常不需要额外引库。Element Plus 内置了v-loading指令Ant Design 内置了Spin组件和message.loading、notification等 API。Element Plus 的指令方式用起来非常顺手template div v-loadingloading element-loading-text拼命加载中... !-- 业务内容 -- /div /templateAnt Design 的 Spin 则支持包裹任意区域import { Spin } from antd; div classNamecontent Spin spinning{loading} tip加载中... {/* 业务内容 */} /Spin /div引库的决策依据其实可以总结成一句话你的项目有没有统一 UI 体系如果有直接用组件库内置方案如果没有只是偶尔需要一个轻量 loading就用纯 CSS 或 spin.js如果是做后台管理系统、路由切换频率高nprogress 是最佳选择。下表可以帮你快速决策场景推荐方案理由按钮点击、小组件纯 CSS 手写轻量无依赖可控性最强全屏页面初始化spin.js / 遮罩CSS配置灵活样式可定制SPA 路由切换nprogress视觉干扰小符合跳转直觉使用 Element Plus 的项目v-loading 指令与组件库风格统一使用 Ant Design 的项目Spin / message与组件库风格统一内容区域加载骨架屏结构预期明确体验最好5. SPA 框架下的 loadingVue 和 React 的工程化套路到了 SPA单页应用时代loading 的实现就不再是简单的显示/隐藏了而是要对接路由、组件、状态管理处理异步渲染和代码分割。这里我分别讲 Vue 和 React 两种生态里最常见的做法。5.1 Vue 路由切换与页面级 loadingVue Router 的路由切换默认是同步的但你没法预知下一个页面组件的初始数据要加载多久。常见套路是在路由守卫里配合一个全局 loading 状态控制。配合 Vuex/Pinia 或者一个简单的响应式变量import { ref } from vue; const loading ref(false); router.beforeEach((to, from, next) { loading.value true; next(); }); router.afterEach(() { loading.value false; });然后在 App.vue 里放一个全局遮罩绑定这个 loading 状态template div classapp-container router-view v-if!loading / div v-else classloading-mask div classspinner/div /div /div /template这里有个更细致的经验用v-if切换router-view虽然简单但会重新销毁和重建组件导致页面切回来时状态丢失。更稳妥的方案是让router-view始终渲染只用遮罩盖在上面template div classapp-container router-view / div v-showloading classloading-mask div classspinner/div /div /div /templatev-show只是控制 CSS 显隐不会销毁组件。这样既有 loading 提示又不影响组件缓存。5.2 Vue 3 异步组件与 SuspenseVue 3 引入了Suspense组件专门处理异步依赖。定义一个异步组件template Suspense template #default AsyncComponent / /template template #fallback div classloading-mask加载中.../div /template /Suspense /templateSuspense会等默认插槽里的异步组件 resolve 之后才渲染真实内容在等待期间显示 fallback 插槽的内容。这是 React 生态里已经成熟、Vue 3 才开始原生支持的机制适合页面中存在多个异步组件需要统一等待的场景。5.3 React 的 Suspense 与 React.lazyReact 里做页面级 loading 最早熟也是印象最深的套路就是React.lazySuspenseimport React, { Suspense, lazy } from react; const UserList lazy(() import(./pages/UserList)); const Dashboard lazy(() import(./pages/Dashboard)); function App() { return ( Routes Route path/users element{ Suspense fallback{div classNamepage-loading页面加载中.../div} UserList / /Suspense } / /Routes ); }React.lazy做的是代码分割也就是把路由对应的组件拆成独立 chunk首屏只加载必需的 JS访问到某个路由时才拉取对应 chunk。这个拉 chunk的过程是异步的恰好就是 loading 展示的窗口期。5.4 组件内数据请求与 loading 状态管理除了路由级别的整页 loading业务页面内部往往还有局部数据加载。我推荐用统一的组合式逻辑来管理避免每个页面都重复写 loading 声明。在 Vue 3 里可以抽一个 composable// useAsync.js import { ref } from vue; export function useAsync(asyncFn) { const loading ref(false); const data ref(null); const error ref(null); const run async (...args) { loading.value true; try { data.value await asyncFn(...args); } catch (e) { error.value e; } finally { loading.value false; } }; return { loading, data, error, run }; }组件里直接使用template div div v-ifloading数据加载中.../div div v-else-iferror加载失败{{ error.message }}/div ul v-else li v-foritem in data{{ item.name }}/li /ul /div /template script setup import { useAsync } from /composables/useAsync; import { getUserList } from /api/user; const { loading, data, error, run } useAsync(getUserList); run(); /script这套做法的核心价值是强制统一了加载、成功、失败三态页面 UI 始终对着loading、error、data三个状态渲染不会出现接口报错了页面还停在 loading的情况。6. 路由切换与 iframe 嵌套两个高频场景的 loading 处理这两类场景在普通页面开发中不太起眼但在电商后台、门户网站、第三方页面嵌套这类实际业务里它们就是 loading 处理的难点重灾区。这里单独拿出来讲。6.1 iframe 子页面加载onload 不是全部iframe 嵌套页面的 loading 处理最直接的想法是在 iframe 的load事件里关闭 loading但这只对同源页面有效。跨域 iframe 存在两个困扰无法监听 iframe 内部的具体资源加载进度只能拿到load事件。load事件触发时机可能比实际渲染晚尤其当内部页面还有大量图片和异步请求时。一个务实的兜底方案是——展示 loading监听load事件关闭但同时设置一个最长等待时间。到了最长时间还没触发load就主动关掉 loading 显示加载失败/超时的提示。const iframe document.createElement(iframe); iframe.src https://partner-site.com/embedded-page; iframe.style.width 100%; iframe.style.height 100%; showLoading(); const timer setTimeout(() { hideLoading(); showError(页面加载超时请刷新重试); }, 8000); iframe.onload () { clearTimeout(timer); hideLoading(); }; document.getElementById(iframe-container).appendChild(iframe);这里需要注意的是如果 iframe 页面是跨域的onload之后你仍然无法通过iframe.contentDocument去操作内部 DOM这是浏览器安全策略不要尝试绕过。6.2 iframe 加载期间的骨架屏方案如果你能控制 iframe 内部页面的代码那我强烈建议父子配合父页面显示主骨架屏iframe 内部在数据就绪后通过postMessage通知父页面关闭 loading。// 父页面 window.addEventListener(message, (event) { if (event.data inner-ready) { hideLoading(); } });// iframe 内部页面 window.parent.postMessage(inner-ready, *);这比单纯监听load准得多因为它在内部页面的业务数据真正拿到后才通知父页面用户不会看到框架出来了、内容还在转的尴尬状态。6.3 keep-alive 页面返回为什么 loading 会卡住还在纠结keep-alive 回到页面滚动条回到顶部问题的同学其实和 loading 也有关系。页面被keep-alive缓存后再次进入时不会重新走created/mounted生命周期而是走activated。如果你是在mounted里发数据请求并控制 loading第二次进入页面你会发现 loading 不会出现也不会重新加载数据。正确做法是把数据请求和 loading 逻辑挪到onActivated里处理script setup import { onActivated } from vue; onActivated(async () { loading.value true; await fetchData(); loading.value false; }); /script同时如果页面里有初始化滚动条到顶部的需求也要在onActivated里处理而不是mounted。这两件事最好放在一起做避免进入缓存页时看到旧内容 新 loading的混乱状态。6.4 路由懒加载和自动刷新页面时的 loadingSPA 路由懒加载本身就会引入一段加载时间有些路由 chunk 很大几百 KB 到几 MB 都有可能。这时候顶部进度条nprogress体验最好因为页面当前内容还在用户能看出来在跳转不是卡死。而页面自动刷新比如 setInterval 轮询场景我见过不少开发者在轮询时无脑 showLoading结果页面每 30 秒闪一次遮罩用户几乎没法操作。轮询请求应该用无提示模式或者只在后台轮询时用一个不阻塞交互的小图标提示即可。如果接口报错需要提示用 toast/notification 比遮罩更合适。7. 骨架屏和真实进度比转圈更好的加载反馈如果说前面几种方法解决了有 loading的问题那这一节解决的是更好的 loading的问题。骨架屏和真实进度条在用户体验上明显优于转圈因为它们给了用户确定性和预期。7.1 骨架屏怎么实现纯 CSS 摇光动画骨架屏的核心是让用户提前看到页面结构哪怕只是灰色块。实现方式不复杂核心是摇光shimmer动画.skeleton-wrapper { padding: 16px; } .skeleton-item { background: linear-gradient(90deg, #f2f2f2 25%, #e6e6e6 37%, #f2f2f2 63%); background-size: 400% 100%; animation: skeleton-loading 1.4s ease infinite; border-radius: 4px; margin-bottom: 12px; } .skeleton-title { width: 40%; height: 20px; } .skeleton-line { width: 100%; height: 14px; } .skeleton-line.short { width: 70%; } keyframes skeleton-loading { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }HTML 结构就是按真实页面的布局放几个带skeleton-title、skeleton-line类的占位块div classskeleton-wrapper div classskeleton-item skeleton-title/div div classskeleton-item skeleton-line/div div classskeleton-item skeleton-line short/div div classskeleton-item skeleton-line/div /div骨架屏适合页面主体内容来自接口、且布局相对固定的场景。比如电商页面商品列表、个人中心的信息展示、后台系统的数据表格都非常适合。7.2 确定式进度条用 Promise 状态模拟进度真实进度条只有在你有明确进度来源时才用比如文件上传、打包构建。普通页面请求很难拿到精确进度所以实际工程里用的更多是模拟进度 兜底方案function simulateProgress() { const bar document.getElementById(real-progress-bar); let progress 0; const timer setInterval(() { progress Math.random() * 10; if (progress 90) { clearInterval(timer); return; } bar.style.width progress %; }, 300); return { finish() { clearInterval(timer); bar.style.width 100%; setTimeout(() { bar.style.opacity 0; }, 300); }, }; }这个做法的关键是永远不要真正到达 100%只接近到 90% 左右等真实请求结束后一次性拉满。这样用户感觉页面一直在推进但又不会出现进度条满了页面还没出来的翻车。7.3 首屏加载策略在 HTML 里直接内联如果做的是首屏 loading一个很重要的优化位置是 HTML 模板本身。在 body 里第一件事放一个静态的 loading 节点而不是等 JS 执行完再创建这样用户在最早期就能看到反馈!DOCTYPE html html langzh-CN head style .boot-loading { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; background: #fff; z-index: 10000; } .boot-spinner { width: 32px; height: 32px; border: 3px solid #eee; border-top-color: #409eff; border-radius: 50%; animation: spin 1s linear infinite; } keyframes spin { to { transform: rotate(360deg); } } /style /head body div idboot-loading classboot-loading div classboot-spinner/div /div div idapp/div script // 应用挂载完成后再移除 boot-loading window.addEventListener(DOMContentLoaded, () { // 这里是给 SPA 框架保留的钩子 }); /script /body /html这种方式在 SPA 项目里特别实用。因为createApp或 ReactDOM 的挂载过程本身需要加载和解析主 JS bundle如果等框架运行后再显示 loading首屏会有很大一段白屏时间。内联一个静态 loading 节点让用户从 HTML 下载完成的那一瞬间就能看到反馈。8. 我实践中踩过的 loading 坑和优化心得最后这部分不按套路讲技术了说几个我在真实项目里碰到过的问题和最终结论都是文档里通常不会写的东西。8.1 loading 遮罩层级混乱一个实际踩过的坑页面里有弹窗z-index 2000、有抽屉z-index 3000结果全屏 loading 的 z-index 只设了 1000。接口请求时 loading 遮罩被弹窗盖住用户按关闭按钮时又触发了新请求遮罩时隐时现用户体验非常割裂。我的结论是全局 loading 的 z-index 应该比项目里最高层级的组件还要高。通常设成9999或者用 CSS 变量统一管理不要拍脑袋写一个看着合理的数值。特别是和第三方弹窗组件混用时一定要核对组件库的 z-index 文档。8.2 iframe 页面加载卡住的兜底策略如果 iframe 内部页面迟迟不加载完毕前端没有完美方案只能做合理兜底。我的做法是用进度黑洞逻辑展示 3 秒后把文字提示从加载中切换成正在连接服务器可能需要较长时间超过 8 秒显示加载超时并提供刷新按钮。至少用户知道发生了什么不会对着一个永远转圈的页面发呆。8.3 不要让 loading 变成另一个白屏有段时间我在项目里看到一种反模式整页 loading 遮罩用纯白背景spinner 也是浅灰色在亮度高的显示器上几乎看不见等于用户面对的还是白屏只是多个了几乎透明的转圈。loading 的设计不能只注重功能而忽略可见性。建议全屏遮罩背景色和 spinner 主色要有一定对比度比如白底 主色蓝或者黑色半透明遮罩 白色 spinner。8.4 别在静态资源加载上过度使用 loading有些页面本身就引了很多静态资源比如图片、字体、第三方 SDK。如果在这些资源加载时都挂 loading体验会非常嘈杂。我的经验是loading 只服务核心业务数据静态资源的加载用loadinglazy或者渐进式渲染先模糊后清晰的方式处理把用户的注意力留给真正需要等待的业务逻辑。8.5 测试时记得模拟慢网速和异常场景做完了 loading 优化一定记得在浏览器 DevTools 里把网络切到 Slow 3G 或 Fast 3G 测一遍重点看这几个点loading 是否及时出现、能否正常消失、接口超时后会不会卡死、并发请求时是否闪断。很多本地好好的线上一直转圈的问题都是因为没测过弱网环境。还有一个很容易忽略的点接口返回的 HTTP 状态码如果是 204 或 304你的拦截器逻辑是否能正确处理我之前就有个项目因为后端习惯性返回 204导致hideGlobalLoading没被触发遮罩卡了一整天。做 loading 这件事本质上是在替系统向用户汇报工作进度。手段可以很简单一个 CSS 转圈就能用但真正负责的做法是把它的生命周期、异常分支、并发场景都考虑完整。希望这篇文章的思路能帮你少踩几个坑做出既顺眼又可靠的加载体验。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进