Vue 3 自定义指令 v-loading:从原理到实战,打造轻量可复用的加载遮罩

Vue 3 自定义指令 v-loading:从原理到实战,打造轻量可复用的加载遮罩 摘要本文深入讲解 Vue 3 自定义指令v-loading的完整实现覆盖布尔值、对象配置与修饰符三种用法支持局部、全屏、指定节点三种挂载位置并自动适配 Arco Design 的 light/dark 主题。文章从效果图、用法示例到指令核心代码与样式实现重点剖析加载遮罩的创建、挂载、显隐切换、延迟显示、滚动锁定及卸载恢复等关键逻辑助你快速落地一套轻量、可复用的加载遮罩方案。标签Vue 3 自定义指令、v-loading、加载遮罩、Arco Design、前端组件前言在业务开发中加载状态loading是提升交互体验的关键环节。无论是列表刷新、表单提交还是页面初始化都需要一个清晰、可控的遮罩反馈避免用户重复操作或误判页面状态。本文实现的v-loading指令正是为 Vue 3 项目提供一套轻量、可复用的加载遮罩方案。适用场景局部区域加载如表单提交、卡片刷新、表格局部更新遮罩仅覆盖目标节点。全屏加载如路由切换、整页数据初始化遮罩挂载到body并锁定滚动。复杂交互场景需要加载文案、延迟防闪烁、自定义尺寸或背景色的场景。与 Element Plus 等现有 loading 指令的差异挂载位置更灵活Element Plus 的v-loading主要面向局部节点全屏需借助服务方式本指令通过.fullscreen修饰符或对象配置即可一键切换局部与全屏。配置方式更统一布尔值、对象配置、修饰符三种写法归一化为同一套LoadingOptions心智负担更低。主题适配更彻底自动跟随 Arco Design 的 light/dark 主题主色与遮罩底色随主题切换无需额外适配。滚动锁定更可控通过.lock修饰符显式控制滚动锁定并在卸载时可靠恢复避免页面卡死。阅读收获通过本文你将掌握v-loading的三种用法与挂载策略理解遮罩的创建、显隐切换、延迟显示与样式恢复等核心实现并能在自己的项目中直接复用或按需扩展。效果图局部全局用法示例代码!-- 1. 基础布尔值局部 loading挂在当前节点 -- div v-loadingisLoading内容区域/div !-- 2. 全屏 loading.fullscreen 修饰符挂到 body -- div v-loading.fullscreenisFetching / !-- 3. 锁定滚动.lock 修饰符 -- div v-loading.lockisLoading内容区域/div !-- 4. 对象配置 提示文案对象缺省 value 视为显示 -- div v-loading{ text: 加载中..., fullscreen: true, lock: true }内容/div !-- 5. 对象配置 显隐控制value 字段 -- div v-loading{ text: 加载中, value: someRef, delay: 300, size: large }内容/div !-- 6. 自定义遮罩背景色 -- div v-loading{ background: rgba(0,0,0,0.6) }内容/div !-- 7. 指定节点 loadingCSS 选择器遮罩挂到 .card-container -- div v-loading{ target: .card-container, text: 加载中... }触发按钮/div !-- 8. 指定节点 loadingHTMLElement 引用遮罩挂到 cardRef -- div v-loading{ target: cardRef, value: isLoading }触发按钮/div下表对比三种用法的核心差异便于按场景快速选择用法示例适用场景关键特性布尔值v-loadingisLoading简单的局部加载状态仅需显示或隐藏遮罩显隐由布尔值直接控制不支持文案、延迟、自定义背景默认挂载到当前节点对象配置v-loading{ text: 加载中, value: someRef, delay: 300, size: large }需要加载文案、延迟防闪烁、自定义尺寸或背景色的复杂场景通过value字段控制显隐缺省视为显示支持文案、延迟、尺寸、自定义背景可配合fullscreen挂载到 body修饰符v-loading.fullscreenisFetching、v-loading.lockisLoading需要全屏遮罩或锁定滚动等附加行为的场景通过.fullscreen挂载到 body、.lock锁定滚动可与布尔值或对象配置组合使用指令代码获取指令核心代码后还需要在 Vue 3 项目中完成注册才能在模板中直接使用v-loading。下面分别介绍局部注册与全局注册两种方式。局部注册如果只在某个组件内使用可以在该组件的directives选项中局部注册。在script setup语法中只需以vLoading命名导入指令对象Vue 会自动将其注册为v-loadingscript setup import { loading } from ./directives/loading; // 在 script setup 中以 vLoading 命名导入即可自动注册为 v-loading const vLoading loading; const isLoading ref(false); /script template div v-loadingisLoading内容区域/div /template若使用选项式 API则在directives选项中注册script import { loading } from ./directives/loading; export default { directives: { loading } }; /script全局注册如果多个组件都需要使用推荐在入口文件如main.ts中全局注册一次注册、全局可用import { createApp } from vue; import App from ./App.vue; import { loading } from ./directives/loading; const app createApp(App); app.directive(loading, loading); app.mount(#app);注册完成后即可在任意组件的模板中直接使用v-loading例如!-- 局部 loading -- div v-loadingisLoading内容区域/div !-- 全屏 loading -- div v-loading.fullscreenisFetching / !-- 对象配置 显隐控制 -- div v-loading{ text: 加载中, value: someRef, delay: 300 }内容/div指令代码/** * author lsy * Date 2026-03-28 10:00:00 */ /** v-loading 加载指令 v-loading支持布尔值 / 对象配置 / 修饰符 支持全屏 / 指定区域 / 指定节点三种挂载位置 自动适配 arco light/dark 主题主色跟随 --primary-6深色 --primary-5 用法示例 v-loadingisLoading // 局部 loading挂当前节点 v-loading.fullscreenisFetching // 全屏 loading挂 body v-loading.lockisLoading // 锁定滚动 v-loading{ text: 加载中..., fullscreen: true } // 对象配置默认显示 v-loading{ text: 加载中, value: false } // 对象配置 显隐控制 v-loading{ delay: 300 } // 延迟 300ms 显示避免闪烁 v-loading{ size: large, background: rgba(0,0,0,0.6) } v-loading{ target: .card-container } // 指定节点 loadingCSS 选择器 v-loading{ target: cardRef } // 指定节点 loadingHTMLElement */ import type { Directive, DirectiveBinding } from vue; export interface LoadingOptions { /** 是否显示对象配置下缺省视为 true / value?: boolean; /* 加载文案 / text?: string; /* 全屏 loading挂到 bodyposition: fixed / fullscreen?: boolean; /* 锁定滚动全屏锁 body局部锁目标元素 / lock?: boolean; /* 自定义遮罩背景色 / background?: string; /* 延迟显示毫秒数避免快速切换闪烁 / delay?: number; /* 尺寸small | default | large / size?: small | default | large; /* 指定挂载节点HTMLElement 或 CSS 选择器字符串遮罩挂到该节点缺省挂当前节点 */ target?: HTMLElement | string; } interface LoadingInstance { mask: HTMLElement; timer: ReturnTypetypeof setTimeout | null; options: LoadingOptions; /** 遮罩实际挂载的父节点body / target / 指令所在 el / mountParent: HTMLElement; /* 被指令改写前的 position卸载时恢复 / originalPosition: string; /* 被指令改写前的 overflow解锁时恢复 / originalOverflow: string; /* 是否实际改写了 mountParent 的 position用于卸载时判断是否需要恢复 / positionTouched: boolean; /* 滚动锁定是否已生效delay 期间未真正显示则未锁避免 hide 误恢复 overflow */ lockActive: boolean; } const CLASS_MASK im-loading-mask; const CLASS_SPINNER im-loading-spinner; const CLASS_DOT im-loading-dot; const CLASS_TEXT im-loading-text; const CLASS_FULLSCREEN is-fullscreen; const CLASS_HIDDEN is-hidden; /** 实例映射避免给 el 挂载自定义属性类型更干净 */ const instanceMap new WeakMapHTMLElement, LoadingInstance(); /** 局部遮罩层级高于普通浮层低于全屏遮罩与 nprogress / const Z_INDEX_LOCAL 1000; /* 全屏遮罩层级高于 nprogress 的 99999 */ const Z_INDEX_FULLSCREEN 100000; /** 把指令绑定值归一化为 LoadingOptions boolean → { value } object → { ...obj, value: obj.value ?? true }对象配置默认显示 空值 → { value: false } */ function resolveOptions(binding: DirectiveBindingboolean | LoadingOptions | undefined): LoadingOptions { const value binding.value; if (typeof value boolean) return { value }; if (value typeof value object) return { ...value, value: value.value ?? true }; return { value: false }; } /** 合并修饰符 .fullscreen / .lock 到 options */ function applyModifiers(options: LoadingOptions, binding: DirectiveBinding): LoadingOptions { const next { ...options }; if (binding.modifiers.fullscreen) next.fullscreen true; if (binding.modifiers.lock) next.lock true; return next; } /** 解析遮罩实际挂载的父节点 fullscreen → document.body target 为 string → querySelector 首个匹配失败降级到 el target 为 HTMLElement → 直接采用 其余 → 指令所在 el */ function resolveMountParent(options: LoadingOptions, el: HTMLElement): HTMLElement { if (options.fullscreen) return document.body; const target options.target; if (target) { if (typeof target string) { const found document.querySelectorHTMLElement(target); if (found) return found; } else { return target; } } return el; } /** 构造遮罩 DOM含 spinner 4 点 可选文案 */ function createMask(options: LoadingOptions): HTMLElement { const mask document.createElement(div); mask.className CLASS_MASK; if (options.fullscreen) mask.classList.add(CLASS_FULLSCREEN); if (options.background) mask.style.backgroundColor options.background; mask.style.zIndex String(options.fullscreen ? Z_INDEX_FULLSCREEN : Z_INDEX_LOCAL); const spinner document.createElement(span); spinner.className ${CLASS_SPINNER} is-size-${options.size ?? default}; // 4 点旋转容器参考 Ant Design Vue Spin dot 结构 const dot document.createElement(span); dot.className CLASS_DOT; dot.innerHTML i/ii/ii/ii/i; spinner.appendChild(dot); if (options.text) { const text document.createElement(span); text.className CLASS_TEXT; text.textContent options.text; spinner.appendChild(text); } mask.appendChild(spinner); return mask; } /** 挂载遮罩挂到 mountParent并按需把父节点 position 改为 relativebody 除外 */ function mountMask(instance: LoadingInstance): void { const parent instance.mountParent; if (parent ! document.body) { const pos getComputedStyle(parent).position; if (pos static || pos ) { instance.originalPosition parent.style.position; parent.style.position relative; instance.positionTouched true; } } parent.appendChild(instance.mask); } /** 卸载遮罩并恢复挂载父节点原始 position */ function unmountMask(instance: LoadingInstance): void { instance.mask.parentNode?.removeChild(instance.mask); if (instance.positionTouched) { instance.mountParent.style.position instance.originalPosition; instance.positionTouched false; } } /** 锁定挂载父节点滚动lockActive 守护避免重复锁覆盖 originalOverflow */ function lockScroll(instance: LoadingInstance): void { if (instance.lockActive) return; instance.originalOverflow instance.mountParent.style.overflow; instance.mountParent.style.overflow hidden; instance.lockActive true; } /** 解锁滚动仅在已锁时恢复避免未锁时把 overflow 误置空 */ function unlockScroll(instance: LoadingInstance): void { if (!instance.lockActive) return; instance.mountParent.style.overflow instance.originalOverflow; instance.lockActive false; } /** 显示遮罩 delay 0等待 delay 后才真正显示 锁滚动避免短任务闪烁与“未显示先锁滚动” delay 0立即显示 锁滚动 开头清掉未触发定时器避免 delay 等待中重复 show 导致定时器泄漏 */ function show(instance: LoadingInstance): void { if (instance.timer) { clearTimeout(instance.timer); instance.timer null; } const reveal (): void { instance.mask.classList.remove(CLASS_HIDDEN); if (instance.options.lock) lockScroll(instance); }; const delay instance.options.delay ?? 0; if (delay 0) { instance.timer setTimeout(() { reveal(); instance.timer null; }, delay); } else { reveal(); } } /** 隐藏遮罩 立即取消未触发的 delay 定时器修复 delay 等待中切 false 后定时器仍触发误显示 立即隐藏 解锁lockActive 守护未真正锁过则不解避免误改 overflow */ function hide(instance: LoadingInstance): void { if (instance.timer) { clearTimeout(instance.timer); instance.timer null; } instance.mask.classList.add(CLASS_HIDDEN); if (instance.options.lock) unlockScroll(instance); } /** 创建实例并挂载遮罩初始为 hidden由 show/hide 切换显隐 */ function createInstance(el: HTMLElement, options: LoadingOptions): LoadingInstance { const mask createMask(options); mask.classList.add(CLASS_HIDDEN); const instance: LoadingInstance { mask, timer: null, options, mountParent: resolveMountParent(options, el), originalPosition: , originalOverflow: , positionTouched: false, lockActive: false }; mountMask(instance); return instance; } /** 销毁实例隐藏 卸载遮罩 恢复样式 */ function destroyInstance(instance: LoadingInstance): void { if (instance.timer) { clearTimeout(instance.timer); instance.timer null; } unlockScroll(instance); unmountMask(instance); } /** target 标识string 用选择器文本HTMLElement 用固定标记引用变化由 updated 单独检测 */ function targetKey(target: LoadingOptions[target]): string { if (!target) return ; if (typeof target string) return s:${target}; return e; } /** 计算配置签名用于 updated 时判断是否需要重建遮罩文案/全屏/尺寸/背景/锁定/target 选择器变化 */ function signature(options: LoadingOptions): string { return [ options.text ?? , options.fullscreen ? 1 : 0, options.size ?? , options.background ?? , options.lock ? 1 : 0, targetKey(options.target) ].join(|); } export const loading: DirectiveHTMLElement, boolean | LoadingOptions | undefined { mounted(el, binding) { const options applyModifiers(resolveOptions(binding), binding); const instance createInstance(el, options); instanceMap.set(el, instance); if (options.value ! false) show(instance); }, updated(el, binding) { const instance instanceMap.get(el); if (!instance) return; const options applyModifiers(resolveOptions(binding), binding); const newParent resolveMountParent(options, el); // 挂载父节点引用变化或关键配置变化 → 重建遮罩保留显隐状态由 value 决定 if (newParent ! instance.mountParent || signature(options) ! signature(instance.options)) { const wasVisible !instance.mask.classList.contains(CLASS_HIDDEN); destroyInstance(instance); const next createInstance(el, options); instanceMap.set(el, next); if (options.value ! false amp;amp; (wasVisible || options.value true)) { show(next); } return; } // 仅显隐变化 → 切换显隐 instance.options options; const shouldShow options.value ! false; const isVisible !instance.mask.classList.contains(CLASS_HIDDEN); if (shouldShow amp;amp; !isVisible) { show(instance); } else if (!shouldShow amp;amp; (isVisible || instance.timer ! null)) { // valuefalse正在显示 或 delay 等待中timer 未触发→ 立即隐藏并取消定时器 // 修复 delaygt;0 频繁切换时 value 已 false 但定时器仍触发导致 loading 误显示 hide(instance); } }, unmounted(el) { const instance instanceMap.get(el); if (instance) { destroyInstance(instance); instanceMap.delete(el); } } };指令样式/** * v-loading 指令样式 * - 视觉 Spin4 点旋转 文案 * - 主色跟随 arco --primary-6深色 --primary-5遮罩底色 light/dark 自适配 * - 尺寸通过 .is-size-* 切换 dot 的 font-sizedot 内部用 em 单位 */ .im-loading-mask { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: auto; background-color: rgb(255 255 255 / 60%); border-radius: inherit; opacity: 1; transition: opacity 0.25s ease; .is-fullscreen { position: fixed; } .is-hidden { pointer-events: none; opacity: 0; } } .im-loading-spinner { display: inline-flex; flex-direction: column; gap: 8px; align-items: center; color: rgb(var(--primary-6)); } /* 4 点旋转容器参考 Ant Design Vue .ant-spin-dot */ .im-loading-dot { position: relative; display: inline-block; width: 1em; height: 1em; font-size: 20px; transform: rotate(45deg); animation: im-loading-rotate 1.2s infinite linear; i { position: absolute; display: block; width: 0.5em; height: 0.5em; background-color: currentcolor; border-radius: 100%; opacity: 0.3; transform: scale(0.75); transform-origin: center center; animation: im-loading-dot-move 1s infinite alternate; } i:nth-child(1) { top: 0; left: 0; } i:nth-child(2) { top: 0; right: 0; animation-delay: 0.4s; } i:nth-child(3) { bottom: 0; left: 0; animation-delay: 0.8s; } i:nth-child(4) { right: 0; bottom: 0; animation-delay: 1.2s; } } .im-loading-text { font-size: 14px; line-height: 1.5; color: var(--color-text-2); } /* 尺寸变体仅切 dot 的 font-size文案不变 */ .im-loading-spinner.is-size-small .im-loading-dot { font-size: 14px; } .im-loading-spinner.is-size-default .im-loading-dot { font-size: 20px; } .im-loading-spinner.is-size-large .im-loading-dot { font-size: 32px; } keyframes im-loading-rotate { to { transform: rotate(405deg); } } keyframes im-loading-dot-move { to { opacity: 1; } } /* 深色模式遮罩底色改深主色提亮一档与 var.scss/dark.scss 约定一致 */ body[arco-themedark] { .im-loading-mask { background-color: rgb(0 0 0 / 45%); } .im-loading-spinner { color: rgb(var(--primary-5)); } } /* 降载偏好关闭旋转与闪烁循环保留静态 4 点 */ media (prefers-reduced-motion: reduce) { .im-loading-dot { animation: none; i { opacity: 0.65; animation: none; } } }常见问题与排查以下汇总 v-loading 使用中常见的四类问题并给出原因分析与解决方案。1. 遮罩不显示原因分析对象配置下value字段被显式设为false指令按预期隐藏遮罩。绑定值传入空值undefined/nullresolveOptions会归一化为{ value: false }导致不显示。目标节点尺寸为 0如尚未渲染完成或display: none局部遮罩虽然挂载但不可见。解决方案确认绑定值确实为true或对象配置中value为true。检查目标节点是否有实际宽高必要时给容器设置最小高度。在mounted钩子中打印归一化后的 options快速定位问题mounted(el, binding) { const options applyModifiers(resolveOptions(binding), binding); console.log([v-loading] options:, options); // ... }2. 全屏遮罩层级被遮挡原因分析全屏遮罩的z-index为100000但页面中某些弹层或组件使用了更高的层级。遮罩挂载在body下若某个祖先元素创建了新的层叠上下文可能影响实际渲染层级。解决方案检查页面中是否有z-index高于100000的元素适当调低或调整全屏遮罩层级。确认body上没有transform、filter等会创建层叠上下文的样式。临时调试建议在浏览器控制台执行以下命令确认遮罩是否已挂载到bodydocument.querySelector(.im-loading-mask.is-fullscreen);3. 延迟显示不生效原因分析delay只在show时生效若遮罩已经显示再次更新value不会重新触发延迟。对象配置中delay字段拼写错误或传入的是字符串而非数字。解决方案确认delay为数字类型例如delay: 300。延迟显示适用于「从隐藏到显示」的切换若需要每次切换都延迟可在updated中先隐藏再显示。调试建议在show函数中打印delay值确认是否进入延迟分支function show(el, instance) { const delay instance.options.delay ?? 0; console.log([v-loading] show, delay:, delay); // ... }4. 滚动锁定未恢复原因分析组件在lock状态下被销毁但unmounted钩子未正确执行导致overflow未被还原。多个实例同时锁定滚动时后销毁的实例可能把overflow恢复为错误的值。解决方案确保组件销毁时unmounted钩子被触发检查是否有v-if或路由切换导致组件被意外卸载。在unlockScroll中打印恢复前后的overflow值便于定位function unlockScroll(el, instance) { const target instance.options.fullscreen ? document.body : el; console.log([v-loading] unlock, before:, target.style.overflow); target.style.overflow instance.originalOverflow; console.log([v-loading] unlock, after:, target.style.overflow); }若页面仍处于锁定状态可手动执行以下命令恢复滚动document.body.style.overflow ;总结本文完整实现了 Vue 3 自定义指令v-loading其核心设计可归纳为以下三点归一化配置通过resolveOptions将布尔值、对象配置、空值统一归一化为LoadingOptions再经applyModifiers合并.fullscreen/.lock修饰符保证三种写法行为一致、心智负担低。遮罩重建策略在updated钩子中通过signature计算配置签名仅当文案、全屏、尺寸、背景、锁定等关键配置变化时才重建遮罩纯显隐变化则只切换is-hidden类避免不必要的 DOM 重建兼顾性能与正确性。主题适配主色跟随 Arco Design 的--primary-6深色--primary-5遮罩底色在 light/dark 下自动切换并支持prefers-reduced-motion降载偏好兼顾视觉一致性与可访问性。推荐用法简单局部加载直接使用布尔值v-loadingisLoading零配置、开箱即用。需要文案或延迟防闪烁使用对象配置v-loading{ text: 加载中, value: someRef, delay: 300 }。全屏加载或锁定滚动使用修饰符v-loading.fullscreen.lockisFetching或对象配置中开启fullscreen/lock。整体而言v-loading在保持指令式简洁的同时兼顾了灵活性、性能与主题一致性。该指令已集成到项目 IM 管理系统IM Chat Core中作为前端加载反馈的统一方案覆盖会话列表刷新、消息发送、历史记录加载等高频交互场景。项目地址https://gitee.com/liu-comrade-nexus/im-chat-core如果潜在问题可留言反馈同时IM系统还在开发阶段