UniApp必备技能之通用可拓展自定义加载动画组件实战
书接上篇,我们今天来聊聊一个每个App都必不可少的组件:灵活可拓展的自定义加载动画(Loading)。支持跨平台(H5/小程序/App),附带实现思路和所有源码示例,以及常见避坑指南和开发实践。
此系列文章将带领你从移动端跨平台开发入门到精通,如果你也喜欢关注APP、小程序、公众号、H5等等应用的开发,可以持续关注后续更新,避免错过宝贵的知识分享。
你可能觉得loading很简单,不就是转个圈吗?但在真实项目中,loading的设计和使用远比想象中复杂:
-
既要全局遮罩,又要局部加载。
-
多个请求并发时,loading怎么只开一次、等所有请求结束才关闭?
-
怎么让开发者用最简单的方式调用,比如
showLoading()和hideLoading(),同时支持自动拦截请求? -
怎么兼容小程序和App,避免样式错乱?
今天我就带你从零实现一个功能完整、跨平台的自定义加载动画组件。它支持:
-
多种动画风格(菊花、圆圈、进度条等)
-
全局遮罩或局部遮罩
-
JS主动触发和关闭
-
自动拦截请求,在请求发起前打开loading,请求结束后自动关闭(支持防重/限流)
一、需求分析
先明确我们要实现的组件功能:
1.1 基本功能
-
显示/隐藏:通过JS调用
show()和hide()方法。 -
遮罩类型:可配置为全局遮罩(覆盖全屏,阻止点击)或局部遮罩(仅覆盖指定区域)。
-
动画风格:支持多种内置样式(如旋转菊花、圆环进度、点阵跳动),也可自定义。
1.2 高级功能
-
请求拦截:封装一个
request函数,发起请求时自动打开loading,请求结束后自动关闭。 -
防重处理:当多个请求同时进行时,只打开一个loading,在所有请求完成后关闭。
-
手动控制:开发者可以手动调用
showLoading和hideLoading,与自动拦截互不干扰。 -
可配置参数:每个请求可以单独控制是否显示loading、遮罩类型等。
二、组件设计思路
2.1 组件结构
我们设计一个Loading组件,它本身不负责逻辑控制,只负责渲染。同时提供一个loadingManager模块,负责管理loading的显示状态和请求计数器。最后封装一个request函数,集成loading管理。
2.2 跨平台注意事项
-
遮罩层:使用
position: fixed实现全局遮罩,小程序和H5/App都支持。 -
动画实现:CSS动画最可靠,所有平台都支持。可以用
animation实现旋转、跳动等效果。 -
组件通信:使用Pinia或
uni.$on/$emit管理全局状态。这里我们使用Pinia(推荐)来管理loading的显示状态,因为它响应式且跨组件。 -
小程序兼容:小程序中,
position: fixed元素如果包含在scroll-view内可能表现异常,所以全局遮罩应放在页面最外层(如App.vue)。
三、核心难点与解决方案
3.1 并发请求的loading管理
问题:两个请求先后发出,第一个打开loading,第二个又打开,结果第一个结束就关闭loading,但第二个还在进行,导致loading提前消失。
解决方案:维护一个请求计数器requestCount。每次请求前计数器+1,打开loading;请求结束后计数器-1,当计数器归零时关闭loading。这样无论多少个并发,loading只会在第一个请求开始时打开,最后一个请求结束时关闭。
3.2 手动控制与自动控制的冲突
问题:开发者手动调用了showLoading(),此时请求自动拦截又打开了loading,手动关闭时可能错误地关掉了请求的loading。
解决方案:区分两种模式:
-
自动模式:通过计数器管理。
-
手动模式:直接调用
show/hide,不受计数器影响。但手动打开后,如果请求自动拦截也触发,可能导致计数器混乱。我们可以约定:手动打开的loading具有更高优先级,自动拦截期间不能手动关闭?更好的做法是让两者共享一个状态,手动调用时直接设置manualShow为true,计数器仍可累加,但关闭逻辑需判断:如果manualShow为true,即使计数器归零也不关闭,只有手动调用hide时才关闭。这样手动和自动可以共存。
3.3 遮罩层层级与穿透
问题:遮罩层z-index不够高,被其他元素遮挡;或者遮罩层本身挡住了所有点击,但我们需要允许点击导航栏等。
解决方案:
-
设置足够大的
z-index(如9999)。 -
如果希望遮罩层只挡住内容区,不挡住导航栏,可以将遮罩层设为半透明黑色,但将导航栏放在更高的层级,或者将遮罩层覆盖区域控制在内容区(局部遮罩)。
3.4 跨平台动画兼容
所有平台都支持CSS动画,但不同平台对某些CSS属性支持度有细微差别(如transform在小程序中支持良好)。建议使用最基础的transform和opacity实现动画,避免使用box-shadow等可能影响性能的属性。
四、完整代码实现
下面我们分步骤实现。
4.1 创建Loading组件
新建components/Loading.vue,支持多种动画风格,通过type属性切换。
<template><viewv-if="visible"class="loading-container":class="{ 'global-mask': maskType === 'global' }":style="customStyle"@touchmove.prevent><viewclass="loading-content"><!-- 根据type显示不同动画 --><viewv-if="type === 'spinner'"class="spinner"></view><viewv-else-if="type === 'circle'"class="circle"></view><viewv-else-if="type === 'dots'"class="dots"><viewclass="dot"></view><viewclass="dot"></view><viewclass="dot"></view></view><textv-if="text"class="loading-text">{{ text }}</text></view></view></template><scriptsetup>defineProps({visible: {type: Boolean,default: false},type: {type: String,default: 'spinner' // spinner, circle, dots},maskType: {type: String,default: 'global' // global, none},text: {type: String,default: ''},customStyle: {type: Object,default: () => ({})}})</script><stylescoped>.loading-container {display: flex;align-items: center;justify-content: center;position: relative;z-index: 9999;}.global-mask {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background-color: rgba(0, 0, 0, 0.3);}.loading-content {display: flex;flex-direction: column;align-items: center;justify-content: center;background-color: rgba(0, 0, 0, 0.7);border-radius: 16rpx;padding: 30rpx;min-width: 200rpx;}.loading-text {color: #fff;font-size: 28rpx;margin-top: 20rpx;}/* 旋转菊花 */.spinner {width: 60rpx;height: 60rpx;border: 6rpx solid #f3f3f3;border-top: 6rpx solid #3498db;border-radius: 50%;animation: spin 1s linear infinite;}@keyframes spin {0% { transform: rotate(0deg); }100% { transform: rotate(360deg); }}/* 圆环进度 */.circle {width: 60rpx;height: 60rpx;border: 6rpx solid #f3f3f3;border-top: 6rpx solid #3498db;border-radius: 50%;animation: circle 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;}@keyframes circle {0% { transform: rotate(0deg); }100% { transform: rotate(360deg); }}/* 点阵跳动 */.dots {display: flex;align-items: center;justify-content: center;}.dot {width: 16rpx;height: 16rpx;margin: 0 6rpx;background-color: #fff;border-radius: 50%;animation: dot-pulse 1.5s infinite;}.dot:nth-child(2) { animation-delay: 0.2s; }.dot:nth-child(3) { animation-delay: 0.4s; }@keyframes dot-pulse {0%, 100% { transform: scale(0.5); opacity: 0.5; }50% { transform: scale(1); opacity: 1; }}</style>
4.2 创建loading管理器(Pinia store)
安装Pinia(略),创建stores/loading.js。
// stores/loading.jsimport { defineStore } from 'pinia'export const useLoadingStore = defineStore('loading', {state: () => ({visible: false, // 是否显示type: 'spinner', // 动画类型maskType: 'global', // 遮罩类型text: '', // 提示文字requestCount: 0, // 当前进行中的请求数manualShow: false // 是否手动打开(手动打开时,请求结束不会自动关闭)}),actions: {// 显示loading(手动调用)show(options = {}) {this.visible = truethis.type = options.type || this.typethis.maskType = options.maskType || this.maskTypethis.text = options.text || this.textthis.manualShow = true},// 隐藏loading(手动调用)hide() {this.visible = falsethis.manualShow = falsethis.requestCount = 0 // 重置计数器},// 请求开始(由request拦截器调用)showForRequest() {this.requestCount++if (!this.manualShow) {this.visible = true}},// 请求结束(由request拦截器调用)hideForRequest() {if (this.requestCount > 0) {this.requestCount--}if (this.requestCount === 0 && !this.manualShow) {this.visible = false}},// 重置(用于退出登录等场景)reset() {this.visible = falsethis.requestCount = 0this.manualShow = false}}})
4.3 在App.vue中放置Loading组件
<!-- App.vue --><template><view><!-- 其他内容 --><Loading:visible="loadingStore.visible":type="loadingStore.type":maskType="loadingStore.maskType":text="loadingStore.text"/></view></template><scriptsetup>import { useLoadingStore } from '@/stores/loading'import Loading from '@/components/Loading.vue'const loadingStore = useLoadingStore()</script>
4.4 封装request函数(集成loading)
新建utils/request.js。
// utils/request.jsimport { useLoadingStore } from '@/stores/loading'// 请求配置const baseURL = 'https://api.example.com'// 封装请求函数function request(options) {return new Promise((resolve, reject) => {// 是否显示loading(默认根据options决定,也可全局配置)const showLoading = options.showLoading !== false// 请求开始:如果需要显示loading,调用store方法if (showLoading) {const loadingStore = useLoadingStore()loadingStore.showForRequest()}// 发起请求uni.request({url: baseURL + options.url,method: options.method || 'GET',data: options.data,header: options.header || {},success: (res) => {// 业务成功判断(根据实际接口调整)if (res.statusCode === 200) {resolve(res.data)} else {reject(res)}},fail: (err) => {reject(err)},complete: () => {// 请求结束:关闭loadingif (showLoading) {const loadingStore = useLoadingStore()loadingStore.hideForRequest()}}})})}export default request
4.5 在页面中使用
手动控制loading
<template><view><button @click="showManualLoading">手动打开loading</button><button @click="hideManualLoading">手动关闭loading</button></view></template><scriptsetup>import { useLoadingStore } from '@/stores/loading'const loadingStore = useLoadingStore()const showManualLoading = () => {loadingStore.show({ type: 'dots', text: '加载中...' })}const hideManualLoading = () => {loadingStore.hide()}</script>
自动拦截请求
<template><view><button @click="fetchData">发起请求(自动显示loading)</button></view></template><scriptsetup>import request from '@/utils/request'const fetchData = async () => {try {const res = await request({url: '/user/info',method: 'GET',showLoading: true // 默认true,可省略})console.log(res)} catch (err) {console.error(err)}}</script>
并发请求测试
// 同时发起两个请求Promise.all([request({ url: '/api1' }),request({ url: '/api2' })]).then(() => {console.log('所有请求完成')})// 结果:loading只在第一个请求开始显示,最后一个请求完成后关闭
4.6 防重/限流增强(可选)
上面的计数器已经实现了基本的防重。如果希望更精细控制,可以添加请求队列,记录每个请求的标识,避免重复请求显示loading。
五、跨平台测试与问题解决
5.1 小程序测试
-
遮罩层正常,但需注意:
position: fixed在小程序中的元素,如果父容器有transform属性,可能导致fixed失效。因此全局遮罩最好放在App.vue的根节点下,不要嵌套在有transform的容器中。 -
动画效果:CSS动画在微信小程序中表现良好,但某些复杂动画可能需要用
animation属性,我们使用的都是基础动画,没问题。
5.2 H5测试
-
一切正常,注意
uni.request在H5中同样可用。
5.3 App测试(Android/iOS)
-
与H5类似,但需注意:App中如果使用
plus扩展,可能影响z-index。但我们的组件使用fixed定位,通常没问题。
5.4 已知问题及解决方案
❌ 问题1:遮罩层挡住导航栏点击
现象:全局遮罩时,顶部的返回按钮无法点击。解决方案:将导航栏放在更高的层级,或者让遮罩层不覆盖导航栏区域。可以通过给遮罩层设置top: 44px(假设导航栏高度)来实现局部遮罩。
❌ 问题2:手动打开loading后,请求自动关闭冲突
现象:手动打开loading,然后发起请求,请求结束后loading自动关闭(我们不希望这样)。解决方案:我们在hideForRequest中加了判断:只有manualShow为false时才自动关闭。手动打开时manualShow为true,所以请求结束不会自动关闭,只有手动调用hide才会关闭。
❌ 问题3:请求失败时loading未关闭
现象:请求失败进入fail回调,但complete中已经调用了hideForRequest,应该没问题。但如果在fail中又调用了reject,可能代码提前终止,但complete始终会执行。
❌ 问题4:多个页面同时使用loading,状态混乱
原因:loading store是全局的,所有页面共享。如果页面A手动打开了loading,切换到页面B,loading还显示着,不符合预期。解决方案:可以在页面切换时自动关闭loading,或者在onHide生命周期中调用reset。但更好的做法是:手动打开的loading应明确生命周期,通常是在当前页面内,页面离开时自动关闭。我们可以在页面onUnload中调用reset:
// 在页面中onUnload(() => {const loadingStore = useLoadingStore()loadingStore.reset()})
六、总结与扩展
今天我们实现了一个功能完善的跨平台加载动画组件,它具备:
-
多种动画风格
-
全局/局部遮罩
-
手动控制和自动请求拦截
-
并发请求的loading管理
这个组件可以直接用于你的UniApp项目中。你还可以在此基础上扩展更多功能,比如:
-
添加进度条样式
-
支持自定义图片动画
-
集成到路由守卫中,页面切换时自动显示loading
最后送你一句话:好的组件不是功能越多越好,而是能优雅地解决核心问题,并且让使用者感到舒心。希望这个loading组件能让你的项目体验更上一层楼。
如果在实际使用中遇到问题,欢迎带着代码来找我。
—— 一个在loading组件上死磕过的老前辈 ⏳
加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!


往期相关文章推荐
夜雨聆风