Vue3 如何优雅地实现一个全局的 loading 组件

www.jswusn.com Other 2025-10-21 15:42:35 5次浏览


前言

其实大部分UI框架都有自己的Loading组件,有些朋友可能会说:“重复造轮子,没有意义”。

但在某些定制化需求或者想要自由度更高一点的情况下,UI框架的效果是无法满足的。同时我觉得对于很少封装组件的开发来说,学习一下这种思路也是没有什么坏处的,因为我就是这种开发。

组件代码量不多,这篇文章来深入的剖析一个Vue3全局加载组件,从设计思路到具体实现,一步步构建一个可配置、可复用、带动画、支持文本动态更新Loading组件,并且细说在路由、Axios中的使用,让你的项目体验瞬间提升一个档次!

效果预览:


微信图片_2025-10-21_154321_046.jpg

今天要做的这个组件,具备以下功能:

  • • 支持全屏遮罩
  • • 可自定义背景色、文字、Spinner 类型
  • • 支持锁屏(禁止滚动)
  • • 支持动态修改 Loading 文字
  • • 带平滑动画过渡
  • • 支持函数式调用($loading.show()

项目结构一览

我们这个组件主要由三个文件构成:

/components/GlobalLoading/
├── GlobalLoading.vue       # 核心 UI 组件
├── loading.ts              # 服务逻辑 & 函数式调用
└── index.ts                # 统一导出入口



一、搭建 Loading 的 UI 界面

1. GlobalLoading.vue 基础结构

<template>
  <Transition name="fade">
    <div v-if="visible" class="global-loading" :class="{ 'global-loading--fullscreen': fullscreen }">
      <!-- 遮罩层 -->
      <div class="loading-mask" :style="maskStyle"></div>
      <!-- 内容区 -->
      <div class="loading-content">
        <div class="loading-spinner">
          <!-- 不同类型的加载动画 -->
        </div>
        <div v-if="internalText" class="loading-text">{{ internalText }}</div>
      </div>
    </div>
  </Transition>
</template>

说明

  • • 使用Transition实现淡入淡出动画
  • • v-if="visible"控制显示隐藏
  • • loading-mask是半透明遮罩
  • • loading-content居中显示加载图标和文字

2. 支持多种加载动画(Spinner)

我们支持三种样式:圆形旋转、三点跳动、默认圆圈

<div class="loading-spinner">
  <!-- 圆形旋转 -->
  <div v-if="spinnerType === 'circle'" class="spinner-circle"></div>
  
  <!-- 三点跳动 -->
  <div v-else-if="spinnerType === 'dots'" class="spinner-dots">
    <div class="dot"></div>
    <div class="dot"></div>
    <div class="dot"></div>
  </div>
  
  <!-- 默认 -->
  <div v-else class="spinner-default">
    <div class="spinner"></div>
  </div>
</div>

每种动画都通过 CSS @keyframes 实现,比如旋转动画:

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

3. Props 配置项详解

通过defineProps定义丰富的配置项:

const props = defineProps({
  visible: { // 是否显示
    type: Boolean,
    default: false
  },
  text: { // 显示文字
    type: String,
    default: ''
  },
  background: { // 遮罩层颜色
    type: String,
    default: ''
  },
  spinnerType: { // 动画类型(circle/dots/default)
    type: String,
    default: 'circle',
    validator: (value) => ['circle', 'dots', 'default'].includes(value)
  },
  fullscreen: { // 是否全屏
    type: Boolean,
    default: true
  },
  lock: { // 是否锁定页面滚动
    type: Boolean,
    default: true
  },
  customClass: { // 自定义类名
    type: String,
    default: ''
  }
})

validator还对spinnerType做了合法性校验,避免传错值。


4. 动态更新文字

一个关键点:如何在显示后动态修改 Loading 文字?

我们使用 defineExpose 暴露一个 setText 方法:

const internalText = ref(props.text)

watch(() => props.text, (newText) => {
  internalText.value = newText
})

defineExpose({
  setText: (text) => {
    internalText.value = text
  }
})

这样外部就可以调用loadingInstance.setText('正在上传...')来实时更新文字!


二、实现函数式调用(loading.ts)

我们要实现像 Element Plus 那样的 $loading.show() 调用方式。

1. 核心思路

  • • 使用h()创建 VNode
  • • 使用render()动态渲染到页面
  • • 用一个LoadingService管理实例和计数
import { h, render } from 'vue'
import GlobalLoading from './GlobalLoading.vue'

class LoadingService {
  private instance: any = null
  private count = 0
  private container: HTMLElement | null = null
}

count是关键:支持多次调用show(),只有最后一次hide()才真正关闭,避免关早了的问题。


2. show()方法:创建并显示Loading

show(options: string | LoadingOptions = {}): LoadingInstance {
  this.count++

  if (this.count === 1) {
    const config = typeof options === 'string' ? { text: options } : options
    
    // 创建 VNode
    const vnode = h(GlobalLoading, {
      ...config,
      visible: true,
    })
    
    // 渲染到 body
    render(vnode, this.container)
    document.body.appendChild(this.container)
    
    // 保存暴露的方法(如 setText)
    this.instance = vnode.component?.exposed
  }

  return {
    close: () => this.hide()
  }
}


支持传字符串:show('加载中...')
支持传对象:show({ text: '请稍候', spinnerType: 'dots' })


3. hide()方法:安全关闭

hide() {
  if (this.count <= 0) return
  this.count--

  if (this.count === 0) {
    setTimeout(() => {
      render(null, this.container!)
      this.container?.remove()
      this.container = null
      this.instance = null
      this.toggleBodyLock(false) // 解锁滚动
    }, 300) // 等待动画结束
  }
}

使用setTimeout是为了让淡出动画播放完再移除DOM。


4. 锁屏功能:禁止页面滚动

我们通过给body添加loading-lock类来禁止滚动:

.loading-lock {
  overflow: hidden !important;
}
private toggleBodyLock(lock: boolean) {
  if (lock) {
    document.body.classList.add('loading-lock')
  } else {
    document.body.classList.remove('loading-lock')
  }
}

用户在Loading时无法滚动页面,体验更佳。

三、封装插件,全局可用(index.ts)

为了让组件更容易使用,我们导出为一个 Vue 插件:

// index.ts
import GlobalLoading from './GlobalLoading.vue'
import loadingService, { LoadingPlugin, useLoading } from './loading'

export {
  GlobalLoading,
  LoadingPlugin,
  useLoading
}

export default loadingService

并在main.ts中安装:

import { createApp } from 'vue'
import App from './App.vue'
import LoadingPlugin from '@/components/GlobalLoading'

const app = createApp(App)

app.use(LoadingPlugin) // 安装插件
app.mount('#app')


安装后,你可以在任何组件中这样使用:

// 方式1:通过 this
this.$loading.show('加载中...')

// 方式2:通过 useLoading()
import { useLoading } from '@/components/GlobalLoading'
const loading = useLoading().show('正在提交...')
loading.close()


实际使用示例

1. 在组合式 API 中使用(推荐)

<!-- 任何 .vue 文件 -->
<template>
  <div class="demo">
    <button @click="showBasicLoading">基本 Loading</button>
    <button @click="showTextLoading">带文字 Loading</button>
    <button @click="showCustomLoading">自定义 Loading</button>
  </div>
</template>

<script setup lang="ts">
import { useLoading } from '@/components/GlobalLoading'

// 获取 loading 实例
const loading = useLoading()

// 基本用法
const showBasicLoading = () => {
  const instance = loading.show()
  // 3秒后自动关闭
  setTimeout(() => {
    instance.close()
  }, 3000)
}

// 带文字提示
const showTextLoading = () => {
  const instance = loading.show('正在加载中...')
  setTimeout(() => {
    instance.close()
  }, 2000)
}

// 自定义配置
const showCustomLoading = () => {
  const instance = loading.show({
    text: '自定义加载效果',
    background: 'rgba(0, 0, 0, 0.7)',
    spinnerType: 'dots',
    lock: true
  })
  
  setTimeout(() => {
    instance.close()
  }, 2500)
}
</script>


2. 在选项式 API 中使用

<template>
  <div>
    <button @click="showLoading">选项式 API 使用</button>
  </div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
  name: 'OptionsApiDemo',
  methods: {
    showLoading() {
      // 通过 this.$loading 访问
      const instance = (this as any).$loading.show('选项式 API 示例')
      setTimeout(() => {
        instance.close()
      }, 2000)
    },
    async fetchData() {
      try {
        (this as any).$loading.show('加载数据中...')
        // 模拟 API 调用
        await new Promise(resolve => setTimeout(resolve, 1500))
        console.log('数据加载成功')
      } finally {
        (this as any).$loading.hide()
      }
    }
  },
  mounted() {
    // 在生命周期中使用
    this.fetchData()
  }
})
</script>


3. 在 js/ts 文件中使用

// utils/api.ts
import loadingService from '@/components/GlobalLoading'

export class ApiService {
  static async request(url: string, options?: RequestInit) {
    // 显示 loading
    loadingService.show('请求中...')
    
    try {
      const response = await fetch(url, options)
      const data = await response.json()
      return data
    } catch (error) {
      console.error('请求失败:', error)
      throw error
    } finally {
      // 隐藏 loading
      loadingService.hide()
    }
  }
}

// 在普通 TS/JS 文件中使用
export function someUtilityFunction() {
  const loading = loadingService.show('处理中...')
  
  // 执行一些操作
  setTimeout(() => {
    loading.close()
  }, 1000)
}


4. 在 Vuex/Pinia 中使用

// stores/userStore.ts
import { defineStore } from 'pinia'
import loadingService from '@/components/GlobalLoading'

export const useUserStore = defineStore('user', {
  actions: {
    async fetchUsers() {
      loadingService.show('加载用户列表...')
      
      try {
        // 模拟 API 调用
        await new Promise(resolve => setTimeout(resolve, 2000))
        return [{ id: 1, name: '用户1' }]
      } finally {
        loadingService.hide()
      }
    },
    
    async createUser(userData: any) {
      const instance = loadingService.show('创建用户中...')
      
      try {
        await new Promise(resolve => setTimeout(resolve, 1500))
        console.log('用户创建成功')
      } finally {
        instance.close()
      }
    }
  }
})


5. 在路由守卫中使用

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import loadingService from '@/components/GlobalLoading'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'Home',
      component: () => import('@/views/Home.vue')
    },
    ...更多配置
  ]
})

// 路由守卫中使用 loading
let loadingShown = false

router.beforeEach((to, from, next) => {
  // 避免重复显示
  if (!loadingShown) {
    loadingService.show('页面加载中...')
    loadingShown = true
  }
  next()
})

router.afterEach(() => {
  // 等待路由准备就绪后再隐藏 loading
  router.isReady().then(() => {
    setTimeout(() => {
      if (loadingShown) {
        loadingService.hide()
        loadingShown = false
      }
    }, 100)
  })
})

// 处理路由错误
router.onError((error) => {
  if (loadingShown) {
    loadingService.hide()
    loadingShown = false
  }
  console.error('路由错误:', error)
})

export default router


6. 在 axios 拦截器中使用

// utils/request.ts
import axios from 'axios'
import loadingService from '@/components/GlobalLoading'

// 请求计数器,避免多个请求时重复显示 loading
let requestCount = 0

const instance = axios.create({
  baseURL: '/api',
  timeout: 10000
})

// 请求拦截器
instance.interceptors.request.use(config => {
  // 如果是 GET 请求或需要显示 loading 的请求
  if (config.method?.toUpperCase() === 'GET' || config.showLoading !== false) {
    requestCount++
    if (requestCount === 1) {
      loadingService.show(config.loadingText || '加载中...')
    }
  }
  return config
})

// 响应拦截器
instance.interceptors.response.use(
  response => {
    if (response.config.method?.toUpperCase() === 'GET' || response.config.showLoading !== false) {
      requestCount--
      if (requestCount === 0) {
        loadingService.hide()
      }
    }
    return response
  },
  error => {
    if (error.config?.method?.toUpperCase() === 'GET' || error.config?.showLoading !== false) {
      requestCount--
      if (requestCount === 0) {
        loadingService.hide()
      }
    }
    return Promise.reject(error)
  }
)

export default instance

总结

特性
实现方式
函数式调用h() + render()
 动态渲染
防重复关闭count
 计数器机制
动态更新文本defineExpose
 暴露方法
锁屏滚动body.loading-lock { overflow: hidden }
多种动画
CSS @keyframes + 条件渲染
平滑过渡<Transition>
 组件
类型安全
TypeScript 定义接口

上一篇:没有了!

Other

下一篇:50条Vue指令使用小技巧

技术分享

苏南名片

  • 联系人:吴经理
  • 电话:152-1887-1916
  • 邮箱:message@jswusn.com
  • 地址:江苏省苏州市相城区

热门文章

Copyright © 2018-2025 jswusn.com 版权所有

技术支持:苏州网站建设  苏ICP备18036849号