前言
其实大部分UI框架都有自己的Loading
组件,有些朋友可能会说:“重复造轮子,没有意义”。
但在某些定制化需求或者想要自由度更高一点的情况下,UI框架的效果是无法满足的。同时我觉得对于很少封装组件的开发来说,学习一下这种思路也是没有什么坏处的,因为我就是这种开发。
组件代码量不多,这篇文章来深入的剖析一个Vue3
全局加载组件,从设计思路到具体实现,一步步构建一个可配置、可复用、带动画、支持文本动态更新的Loading
组件,并且细说在路由、Axios中的使用,让你的项目体验瞬间提升一个档次!
效果预览:
今天要做的这个组件,具备以下功能:
• 支持全屏遮罩 • 可自定义背景色、文字、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 }
多种动画 @keyframes
+ 条件渲染平滑过渡 <Transition>
类型安全