乐于分享
好东西不私藏

UniApp必备技能之通用可拓展自定义加载动画组件实战

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,在所有请求完成后关闭。

  • 手动控制:开发者可以手动调用showLoadinghideLoading,与自动拦截互不干扰。

  • 可配置参数:每个请求可以单独控制是否显示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在小程序中支持良好)。建议使用最基础的transformopacity实现动画,避免使用box-shadow等可能影响性能的属性。


四、完整代码实现

下面我们分步骤实现。

4.1 创建Loading组件

新建components/Loading.vue,支持多种动画风格,通过type属性切换。

<template>  <view    v-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: {    typeBoolean,    defaultfalse  },  type: {    typeString,    default'spinner' // spinner, circle, dots  },  maskType: {    typeString,    default'global' // global, none  },  text: {    typeString,    default''  },  customStyle: {    typeObject,    default() => ({})  }})</script><stylescoped>.loading-container {  display: flex;  align-items: center;  justify-content: center;  position: relative;  z-index9999;}.global-mask {  position: fixed;  top0;  left0;  width100%;  height100%;  background-colorrgba(0000.3);}.loading-content {  display: flex;  flex-direction: column;  align-items: center;  justify-content: center;  background-colorrgba(0000.7);  border-radius16rpx;  padding30rpx;  min-width200rpx;}.loading-text {  color#fff;  font-size28rpx;  margin-top20rpx;}/* 旋转菊花 */.spinner {  width60rpx;  height60rpx;  border6rpx solid #f3f3f3;  border-top6rpx solid #3498db;  border-radius50%;  animation: spin 1s linear infinite;}@keyframes spin {  0% { transformrotate(0deg); }  100% { transformrotate(360deg); }}/* 圆环进度 */.circle {  width60rpx;  height60rpx;  border6rpx solid #f3f3f3;  border-top6rpx solid #3498db;  border-radius50%;  animation: circle 1.2s cubic-bezier(0.500.51) infinite;}@keyframes circle {  0% { transformrotate(0deg); }  100% { transformrotate(360deg); }}/* 点阵跳动 */.dots {  display: flex;  align-items: center;  justify-content: center;}.dot {  width16rpx;  height16rpx;  margin0 6rpx;  background-color#fff;  border-radius50%;  animation: dot-pulse 1.5s infinite;}.dot:nth-child(2) { animation-delay0.2s; }.dot:nth-child(3) { animation-delay0.4s; }@keyframes dot-pulse {  0%100% { transformscale(0.5); opacity0.5; }  50% { transformscale(1); opacity1; }}</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 = true      this.type = options.type || this.type      this.maskType = options.maskType || this.maskType      this.text = options.text || this.text      this.manualShow = true    },    // 隐藏loading(手动调用)    hide() {      this.visible = false      this.manualShow = false      this.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 = false      this.requestCount = 0      this.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() => {        // 请求结束:关闭loading        if (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',      showLoadingtrue // 默认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组件上死磕过的老前辈 ⏳

加油,未来的全栈大佬!💪如果你也对移动端跨端开发感兴趣,关注我,后续还有更多优质文章分享!

往期相关文章推荐

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » UniApp必备技能之通用可拓展自定义加载动画组件实战

猜你喜欢

  • 暂无文章