app {
Vue3项目里的Loading组件到底怎么选怎么用才顺手不踩坑?
为什么现在做Vue3项目越来越重视Loading体验?
不知道你有没有过这种感觉:点了某个按钮,等了3秒页面没反应,就忍不住连点好几次,最后多出来好几个重复请求;或者刷长列表时,新内容加载过程中间全是空白,手指滑半天不知道有没有在动,直接滑到底部退出了,这种“等待焦虑”其实是影响用户留存的关键小细节,根据用户体验领域的公开数据,网页或者APP的首屏加载如果超过3秒,超过一半的用户会直接离开,而操作反馈类的Loading如果延迟超过1秒,就有用户开始怀疑功能是否正常。
放到Vue3项目里,Loading的重要性就更明显了,Vue3的响应式数据和组件化设计,让我们可以在很多动态节点插入Loading——比如路由切换、表单提交、异步API调用、图片懒加载预加载、长列表分页、甚至是某个单独的按钮操作都能加,要是Loading加得不对,要么挡住用户视线太久,要么覆盖范围太窄没提示到,要么闪烁太频繁反而影响体验,甚至还可能和Vue3的响应式生命周期冲突,导致组件卸载了Loading还在屏幕上飘着,或者直接渲染异常。
Vue3原生有没有自带好用的Loading?
很多刚学Vue3的同学可能会去找Vue2里的vue-loading或者Element UI2的el-loading对应原生版本,但其实Vue3核心库本身没有内置通用的Loading组件——核心库只负责响应式、虚拟DOM、组件系统这些底层能力,UI交互组件是交给官方生态或者第三方库来做的,不过Vue3官方配套的工具链和UI库其实给了不少选择,比如VitePress文档用的组件库虽然小,但有基础的Loading;还有官方维护的Pinia状态管理虽然不直接做UI,但可以用来管理全局Loading的状态,避免组件间传参传得乱七八糟。
做Vue3项目时,Loading到底选官方生态库还是自己写?
这得看你的项目需求和团队情况,不能一概而论,我之前帮几个朋友和小公司做过项目,总结下来大概可以分成三种情况:
第一种是项目赶工期、或者对Loading的视觉风格要求和某个成熟UI库一致——比如你整个项目都用Element Plus、Ant Design Vue、Naive UI这些主流Vue3 UI库的话,直接用它们自带的Loading就好,这些UI库的Loading组件覆盖了全局、局部、指令调用、组件调用等几乎所有场景,还能自定义颜色、大小、文字、遮罩层透明度,甚至动画效果,完全能满足90%以上的常规需求,而且稳定性很高,有官方社区维护,踩坑概率比自己写低太多,比如Element Plus的v-loading指令,绑定到一个DOM元素上就能直接出局部Loading,非常顺手;Ant Design Vue的Spin组件支持把内容包裹起来,自动在内容区域加上Loading和遮罩,不用自己处理定位问题。
第二种是项目有完全定制化的UI风格,或者只需要极简的Loading不想引入整个大UI库——这时候可以选轻量级的第三方Loading库,或者自己写一个极简的全局/局部Loading,轻量级第三方库比如vue3-loading-overlay,压缩后可能只有几KB,用法也很灵活,既可以全局调用,也可以作为组件使用,支持多种预设动画,还能完全自定义HTML和CSS,自己写的话也不难,利用Vue3的Teleport组件把Loading的DOM节点直接插到body的最后,避免和父组件的定位、层级冲突,再用响应式变量控制显示隐藏就行,后面我会给一个自己写全局Loading的具体例子。
第三种是项目有特殊的Loading需求——比如Loading的动画需要和业务场景结合,比如电商的购物车添加Loading是一个小推车在跑,游戏类的Loading有进度条和关卡提示,这时候不管用官方库还是轻量级第三方库都不够灵活,必须自己写,自己写这种特殊Loading的时候,要注意和Vue3的生命周期结合,比如组件挂载前先初始化状态,组件卸载前取消所有相关的动画和异步请求,防止内存泄漏或者Loading残留。
自己写Vue3全局Loading时,最容易踩的坑是什么?怎么避免?
自己写全局Loading踩坑最多的,就是Teleport的用法、响应式变量的管理、还有组件卸载时的清理这三个点。
先说Teleport的用法,很多同学一开始会把Loading的DOM节点写在App.vue的根组件里,这样如果父组件有transform、perspective、filter这些CSS属性的话,Loading的position: fixed定位就会失效,变成相对于父组件定位,覆盖范围不够,这时候必须用Vue3的Teleport组件,把Loading的DOM节点“传送”到body或者其他不受父组件样式影响的节点里,正确的用法是在App.vue里加一个<Teleport to="body"><GlobalLoading /></Teleport>,GlobalLoading组件里的遮罩层和动画元素再用position: fixed定位。
然后是响应式变量的管理,如果只用一个ref或者reactive变量来控制全局Loading的显示隐藏,那如果有多个异步请求同时触发,第一个请求完成时就会把Loading关掉,其他请求还在加载就没有提示了,这时候可以用一个计数器来管理,每触发一个异步请求就把计数器加1,每完成一个就减1,只有当计数器为0的时候才关掉Loading,计数器最好用Pinia或者Vuex(不过Vue3更推荐Pinia)来管理,这样所有组件都能方便地调用,不用一层层传参。
组件卸载时的清理,如果某个异步请求是在某个子组件里触发的,这个子组件在请求完成前就被用户关掉了(比如路由切换走了),那这个请求的减1操作就不会执行,计数器会一直大于0,Loading就会一直飘在屏幕上,这时候要用到Vue3的onUnmounted生命周期钩子,在组件卸载前检查有没有未完成的请求,如果有,就取消这些请求,同时把计数器减回去,取消请求的话,可以用Axios的CancelToken或者AbortController,现在更推荐AbortController,因为它是原生的API,兼容性也很好。
给一个自己写Vue3极简全局Loading的完整代码例子?
好的,我给一个基于Pinia和Teleport的极简全局Loading代码例子,你可以直接复制到项目里用,或者根据自己的需求修改。
Pinia的store代码,用来管理全局Loading的计数器:
// stores/loading.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useLoadingStore = defineStore('loading', () => {
// 全局Loading计数器
const loadingCount = ref(0)
// 自定义Loading文字,默认是“加载中”
const loadingText = ref('加载中')
// 开始Loading的方法
const startLoading = (text = '加载中') => {
loadingText.value = text
loadingCount.value++
}
// 结束Loading的方法
const endLoading = () => {
if (loadingCount.value > 0) {
loadingCount.value--
}
}
// 重置Loading的方法,比如页面刷新或者跳转到首页时用
const resetLoading = () => {
loadingCount.value = 0
loadingText.value = '加载中'
}
return {
loadingCount,
loadingText,
startLoading,
endLoading,
resetLoading
}
})
然后是GlobalLoading组件的代码:
<!-- components/GlobalLoading.vue -->
<template>
<Teleport to="body">
<transition name="loading-fade">
<div v-if="loadingStore.loadingCount > 0" class="global-loading-mask">
<div class="global-loading-content">
<div class="loading-spinner"></div>
<p class="loading-text">{{ loadingStore.loadingText }}</p>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup>
import { useLoadingStore } from '@/stores/loading'
const loadingStore = useLoadingStore()
</script>
<style scoped>
.global-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.global-loading-content {
background-color: #fff;
padding: 20px 30px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
margin-top: 12px;
font-size: 14px;
color: #606266;
}
/* 过渡动画 */
.loading-fade-enter-active,
.loading-fade-leave-active {
transition: opacity 0.3s ease;
}
.loading-fade-enter-from,
.loading-fade-leave-to {
opacity: 0;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
接下来是在App.vue里引入GlobalLoading组件:
<!-- App.vue -->
<template>
<div id="app">
<router-view />
<GlobalLoading />
</div>
</template>
<script setup>
import GlobalLoading from '@/components/GlobalLoading.vue'
</script>
<style>
/* 全局样式,比如重置一下默认样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #333;
}
</style>
在一个异步API调用的组件里的用法,这里还加了AbortController来取消未完成的请求:
<!-- views/Home.vue -->
<template>
<div class="home">
<h1>这是首页</h1>
<button @click="fetchData">获取数据</button>
<div v-if="data" class="data-container">{{ data }}</div>
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { useLoadingStore } from '@/stores/loading'
import axios from 'axios'
const loadingStore = useLoadingStore()
const data = ref(null)
// 创建一个AbortController实例
const controller = ref(new AbortController())
const fetchData = async () => {
// 重置AbortController,避免之前的请求干扰
controller.value.abort()
controller.value = new AbortController()
const signal = controller.value.signal
try {
// 开始Loading,自定义文字
loadingStore.startLoading('正在获取首页数据...')
const res = await axios.get('https://jsonplaceholder.typicode.com/posts/1', { signal })
data.value = res.data.title
} catch (error) {
// 如果是用户取消请求的话,不处理错误
if (!axios.isCancel(error)) {
console.error('获取数据失败:', error)
}
} finally {
// 不管请求成功还是失败,都结束Loading
loadingStore.endLoading()
}
}
// 组件卸载前取消未完成的请求
onUnmounted(() => {
controller.value.abort()
})
</script>
<style scoped>
.home {
padding: 20px;
}
button {
margin-top: 20px;
padding: 8px 16px;
background-color: #409eff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background-color: #66b1ff;
}
.data-container {
margin-top: 20px;
padding: 12px;
background-color: #f5f7fa;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
}
</style>
除了全局Loading,还有哪些Loading场景需要注意?
除了全局Loading,还有几个常见的场景容易被忽略,但做好了能大大提升用户体验:
第一个是按钮级别的Loading,比如表单提交按钮,点击后直接禁用并显示Loading,防止用户连点多次提交重复数据,主流UI库的按钮组件一般都自带这个功能,如果自己写的话,可以给按钮加一个loading的prop,或者用一个响应式变量控制按钮的disabled属性和Loading的显示隐藏。
第二个是路由切换的Loading,很多Vue3项目用Vue Router做路由管理,可以在router.beforeEach里开始Loading,在router.afterEach或者router.onError里结束Loading,这样用户就能知道页面正在切换,不会一直点导航栏。
第三个是图片的懒加载预加载Loading,图片懒加载可以用Vue3的IntersectionObserverAPI,或者第三方库比如vue-lazyload,在图片加载前显示一个占位的Loading,加载完成后再显示原图,预加载的话,可以在页面加载完成后,用JavaScript提前加载下一页可能用到的图片,这样用户切换到下一页时图片就能直接显示,不用等待。
第四个是长列表分页的Loading,长列表分页有两种,一种是分页器,另一种是无限滚动,分页器的话,可以在切换页码时显示全局或者局部的Loading;无限滚动的话,可以在列表底部显示一个“加载更多”的Loading,加载完成后自动消失,如果没有更多数据了,就显示“没有更多内容了”。
(全文完,约2200字)
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


