一、设计背景与核心目标
1.1 场景痛点
- 并发冲突:多个业务请求(如表格刷新 + 表单提交)同时触发 Loading,样式配置不一致导致视觉混乱。
- 配置维护难:如果每个调用处都传一堆参数,后期修改默认行为(如全局最小显示时间)将是一场灾难。
- 闪烁问题:请求过快完成时,Loading 瞬间消失会产生视觉闪烁,影响体验。
1.2 核心设计决策
| 设计点 | 解决方案 | 意图 |
|---|---|---|
| 预设模板化 | 导出 LOADING_PRESETS | 实现“零参数”调用,统一常用场景配置。 |
| 首次注册锁定 | 相同 type 仅首次生效配置 | 避免同一业务场景下配置打架,简化逻辑。 |
| 优先级队列 | processQueue 动态决策 | 确保高优先级(如弹窗)能覆盖低优先级(如表格)。 |
| 全局计时起点 | globalStartTime | 保证 minTime 从 Loading 首次出现开始计算,而非最后一次。 |
二、完整实现代码(带 JSDoc)
import { ref, shallowRef } from "vue";
// 优先级映射表:将语义化字符串转换为数字,方便比较
const LOADING_PRIORITY = { low: 1, medium: 2, high: 3 };
/**
* @typedef {Object} LoadingConfig
* @property {string} type - 唯一标识符,用于区分不同的 Loading 实例
* @property {'low'|'medium'|'high'} priority - 优先级,决定多 Loading 并存时的展示顺序
* @property {number} minTime - 最小显示时长(毫秒),避免闪烁
* @property {string} loadingType - 视觉样式标识(如 'transparent', 'fullscreen')
*/
/**
* 通用预定义配置模板
* 仅根据“优先级”和“视觉样式”组合
* @type {Record<string, LoadingConfig>}
*/
export const LOADING_PRESETS = {
// 基础透明类
transparent_low: { type: 'trans_low', priority: 'low', minTime: 0, loadingType: 'transparent' },
transparent_medium: { type: 'trans_med', priority: 'medium', minTime: 300, loadingType: 'transparent' },
transparent_high: { type: 'trans_high', priority: 'high', minTime: 500, loadingType: 'transparent' },
// 基础全屏类
fullscreen_low: { type: 'full_low', priority: 'low', minTime: 0, loadingType: 'fullscreen' },
fullscreen_medium: { type: 'full_med', priority: 'medium', minTime: 300, loadingType: 'fullscreen' },
fullscreen_high: { type: 'full_high', priority: 'high', minTime: 500, loadingType: 'fullscreen' },
};
/**
* 通用 Loading 状态管理器
* 支持并发计数、优先级决策及最短显示时间控制
*/
export function useLoading() {
// 存储各 type 的实时状态:Map<type, LoadingEntry>
// LoadingEntry 结构: { priority: number, minTime: number, loadingType: string, count: number }
const typeMap = shallowRef(new Map());
const loading = ref(false); // 全局 Loading 开关
const loadingType = ref('transparent'); // 当前应展示的视觉样式类型
let globalStartTime = null; // Loading 首次开启的时间戳(闭包变量)
/**
* 配置解析器:支持“预设 Key”或“动态对象”
* @param {string|LoadingConfig} configOrKey
* @returns {LoadingConfig}
*/
const resolveConfig = (configOrKey) => {
if (typeof configOrKey === 'string') {
// 优先匹配通用预设,若无则退化为默认的低优先级透明 Loading
return LOADING_PRESETS[configOrKey] || { type: configOrKey, priority: 'low', minTime: 0, loadingType: 'transparent' };
}
// 动态传入对象时,以传入值为准,未传字段给默认值
return { type: 'dynamic', priority: 'low', minTime: 0, loadingType: 'transparent', ...configOrKey };
};
/**
* 开启 Loading
* @param {string|LoadingConfig} [configOrKey='transparent_medium'] - 预设 Key 或配置对象
*/
const showLoading = (configOrKey = 'transparent_medium') => {
const config = resolveConfig(configOrKey);
const { type } = config;
if (!type) return console.warn('[useLoading] type is required');
const entry = typeMap.value.get(type);
if (entry) {
// 【首次注册锁定】已存在该 type,忽略新配置,仅增加并发计数
entry.count += 1;
} else {
// 首次出现,以当前配置进行“注册”
typeMap.value.set(type, {
priority: LOADING_PRIORITY[config.priority],
minTime: config.minTime,
loadingType: config.loadingType,
count: 1
});
// 记录全局起点(仅在没有任何 Loading 时记录)
if (globalStartTime === null) globalStartTime = Date.now();
}
processQueue();
};
/**
* 关闭 Loading
* @param {string} type - 业务标识,需与 showLoading 时使用的 type 一致
*/
const hideLoading = (type) => {
if (!type) return console.warn('[useLoading] type is required');
const entry = typeMap.value.get(type);
if (!entry) return;
if (entry.count <= 1) {
// 该 type 最后一个请求完成,计算剩余显示时间
const timeLeft = Math.max(0, entry.minTime - (Date.now() - globalStartTime));
setTimeout(() => {
typeMap.value.delete(type);
// 若 Map 清空,重置全局起点
if (typeMap.value.size === 0) globalStartTime = null;
processQueue();
}, timeLeft);
} else {
// 仍有并发请求,仅减少计数
entry.count -= 1;
}
};
/**
* UI 决策逻辑:根据优先级和插入顺序决定展示哪个 Loading
* 规则:高优先级 > 低优先级;同优先级 > 先来后到
*/
const processQueue = () => {
if (typeMap.value.size === 0) {
loading.value = false;
return;
}
// 1. 寻找最高优先级
let maxPriority = 0;
for (const e of typeMap.value.values()) {
if (e.priority > maxPriority) maxPriority = e.priority;
}
// 2. 在同优先级中,选择最先插入的那个 type(Map 迭代顺序保证)
for (const [type, e] of typeMap.value.entries()) {
if (e.priority === maxPriority) {
loading.value = true;
loadingType.value = e.loadingType; // 更新为获胜者的视觉类型
return;
}
}
};
return { showLoading, hideLoading, loadingType, loading };
}
三、数据结构与关键变量
3.1 核心状态
typeMap:Map<type, Entry>。管理所有活跃 Loading 的生命周期。loadingType: 响应式变量,UI 组件通过它判断是渲染“透明遮罩”还是“全屏旋转”。globalStartTime: 闭包变量。解决连续请求时minTime累加的问题,确保最短时长从第一次亮起开始算。
3.2 Map 条目结构 (Entry)
{
priority: number, // 数字化后的优先级 (1-3)
minTime: number, // 最小显示毫秒数
loadingType: string, // 视觉样式标识 (如 'transparent')
count: number // 该 type 下的并发请求计数
}
四、核心流程详解
4.1 开启逻辑 (showLoading)
-
解析配置:将传入的字符串或对象转换为标准配置。
-
查重与注册:
- 若
type已存在:静默忽略新配置,count++。 - 若
type不存在:写入 Map,锁定配置,并初始化globalStartTime。
- 若
-
触发决策:调用
processQueue重新评估当前应该展示谁。
4.2 关闭逻辑 (hideLoading)
- 计数递减:若
count > 1,说明还有兄弟请求在跑,直接返回。 - 延迟清理:若
count === 1,计算timeLeft。即使接口 10ms 就完了,也要等够minTime才从 Map 删除。 - 状态重置:删除条目后,若 Map 为空,关闭全局
loading并清除时间戳。
4.3 决策逻辑 (processQueue)
- 原则:高优先级 > 低优先级;同优先级 > 先来后到。
- 执行:遍历 Map 找到优先级最高的条目,将其
loadingType赋值给全局状态。
五、典型使用场景
场景 1:极简调用(推荐)
// 自动套用预设:medium 优先级,300ms 最短时间,透明样式
showLoading('transparent_medium');
场景 2:特殊定制
// 临时需要一个全屏且至少显示 1s 的 Loading
showLoading({ type: 'special-report', priority: 'high', loadingType: 'fullscreen', minTime: 1000 });
场景 3:并发覆盖
showLoading({ type: 't1', priority: 'low', loadingType: 'transparent' }); // 此时展示 transparent
setTimeout(() => {
showLoading({ type: 't2', priority: 'high', loadingType: 'fullscreen' }); // 优先级更高,UI 自动切换为 fullscreen
}, 100);
六、总结与建议
- 通用性优先:预设只包含视觉和行为维度(透明度、优先级)。
- 约定优于配置:尽量使用导出的
LOADING_PRESETS,减少手动传参带来的配置不一致风险。 - 静默处理冲突:对于相同
type的不同配置,采用“首次锁定”策略,保持逻辑轻量化。