自定义指令
组件上不推荐使用自定义指令
自定义指令种类
-
注册全局指令
const app = createApp({}) // 使 v-focus 在所有组件中都可用 app.directive('focus', { /* ... */ })
-
注册局部指令(很少使用)
const focus = { mounted: (el) => el.focus() } export default { directives: { // 在模板中启用 v-focus focus } }
钩子函数
-
一个指令的定义对象可以提供几种钩子函数 (都是可选的):
const myDirective = { // 在绑定元素的 attribute 前 // 或事件监听器应用前调用 created(el, binding, vnode, prevVnode) { }, // 在元素被插入到 DOM 前调用 beforeMount(el, binding, vnode, prevVnode) { }, // 在绑定元素的父组件 // 及他自己的所有子节点都挂载完成后调用 mounted(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件更新前调用 beforeUpdate(el, binding, vnode, prevVnode) { }, // 在绑定元素的父组件 // 及他自己的所有子节点都更新后调用 updated(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件卸载前调用 beforeUnmount(el, binding, vnode, prevVnode) { }, // 绑定元素的父组件卸载后调用 unmounted(el, binding, vnode, prevVnode) { } }
-
钩子函数参数:
- el:指令所绑定的元素,可以用来直接操作 DOM;
- binding:一个对象,包含以下 property:
- value:指令的绑定值,例如:v-my-directive=“1 + 1” 中,绑定值为 2;
- oldValue:指令绑定的前一个值,仅在 update 和 componentUpdated 钩子中可用,无论值是否改变都可用;
- arg:传给指令的参数,可选,例如 v-my-directive:foo 中,参数为 “foo”。
- modifiers:一个包含修饰符的对象,例如:v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true };
- instance:使用该指令的组件实例;
- dir:指令的定义对象;
- vnode:Vue 编译生成的虚拟节点;
- prevNode:之前的渲染中代表指令所绑定元素的 VNode,仅在 update 和 componentUpdated 钩子中可用;
-
举例:
<!-- 像下面这样使用指令 --> <div v-example:foo.bar="baz"> // binding 参数会是一个这样的对象 { arg: 'foo', modifiers: { bar: true }, value: /* `baz` 的值 */, oldValue: /* 上一次更新时 `baz` 的值 */ }
批量挂载自定义指令
-
手动方式
JavaScriptJavaScript// main.js import { createApp } from 'vue'; import App from '@/App'; import Directives from '/directives/index.ts'; const app = createApp(App); app.use(Directives); app.mount('#app');
// directives/index.js import copy from './copy'; import screenfull from './screenfull'; import ellipsis from './ellipsis'; import backtop from './backtop'; import resize from './resize'; import debounce from './debounce'; import permission from './permission'; import waterMarker from './waterMarker'; // 自定义指令 const directives = { copy, screenfull, ellipsis, backtop, resize, debounce, permission, waterMarker, }; export default { install(Vue) { Object.keys(directives).forEach((key) => { Vue.directive(key, directives[key]); }); }, };
案例
点击外部指令 v-click-outside
import type { Directive, DirectiveBinding } from "vue";
interface ClickOutsideBinding extends DirectiveBinding {
value: (e: Event) => void; // 回调函数
}
const vClickOutside: Directive = {
beforeMount(el: HTMLElement, binding: ClickOutsideBinding) {
el._clickOutsideHandler = (event: Event) => {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value(event);
}
};
document.addEventListener("click", el._clickOutsideHandler);
},
unmounted(el: HTMLElement) {
if (el._clickOutsideHandler) {
document.removeEventListener("click", el._clickOutsideHandler);
}
},
};
declare global {
interface HTMLElement {
_clickOutsideHandler?: (e: Event) => void;
}
}
export default vClickOutside;
响应缩放指令 v-resize
可以监听绑定了 v-resize 指令的元素的尺寸
import type { Directive, DirectiveBinding } from "vue";
interface ResizeObserverEntry {
target: Element;
contentRect: DOMRectReadOnly;
}
interface ResizeDirectiveBinding extends DirectiveBinding {
value: (entry: ResizeObserverEntry, el: HTMLElement) => void;
}
function checkResize(entry: ResizeObserverEntry) {
let flag = true;
const {width, height} = entry.contentRect;
const {width:lastWidth, height:lastHeight} = (entry.target as HTMLElement)._lastContentRect ?? {};
if (width == lastWidth && height == lastHeight) {
flag = true;
} else {
(entry.target as HTMLElement)._lastContentRect = {width: entry.contentRect.width, height: entry.contentRect.height} as DOMRectReadOnly;
flag = false;
}
return flag;
}
const vResize: Directive = {
mounted(el: HTMLElement, binding: ResizeDirectiveBinding) {
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
console.log("新尺寸:", entry.contentRect.width, entry.contentRect.height);
if (!checkResize(entry) && typeof binding.value == "function") {
binding.value(entry, el);
}
}
});
observer.observe(el as Element);
el._resizeObserver = observer;
},
unmounted(el: HTMLElement) {
el._resizeObserver?.unobserve(el); // 适用于只需要停止监听某个特定的元素时
// el._resizeObserver?.disconnect(); // 适用于需要完全停止所有元素的监听时
},
};
declare global {
interface HTMLElement {
_resizeObserver?: ResizeObserver;
_lastContentRect?: DOMRectReadOnly;
}
}
export default vResize;
拖拽指令 (v-draggable)
import type { Directive } from "vue";
interface DraggableOptions {
axis?: "x" | "y" | "both";
handle?: HTMLElement | null;
}
const vDraggable: Directive<HTMLElement, DraggableOptions | undefined> = {
mounted(el, binding) {
const options = binding.value || {};
const handle = options.handle || el;
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
handle.style.cursor = "move";
handle.style.position = "absolute";
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
initialX = el.offsetLeft;
initialY = el.offsetTop;
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
requestAnimationFrame(() => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (options.axis !== "y") {
el.style.left = `${initialX + dx}px`;
}
if (options.axis !== "x") {
el.style.top = `${initialY + dy}px`;
}
});
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
handle.addEventListener("mousedown", onMouseDown);
el._draggableCleanup = () => {
handle.removeEventListener("mousedown", onMouseDown);
};
},
unmounted(el) {
el._draggableCleanup?.();
},
};
declare global {
interface HTMLElement {
_draggableCleanup?: () => void;
}
}
export default vDraggable;
权限控制指令 v-permission
import { useAccount } from "@/pinia/modules/account";
import type { Directive, DirectiveBinding } from "vue";
interface PermissionBinding extends DirectiveBinding {
value: string | string[];
}
const isHasPermission = (el: HTMLElement, binding: PermissionBinding) => {
const { permissionList } = useAccount();
const permissions = Array.isArray(binding.value)
? binding.value
: [binding.value];
const hasPermission = permissions.some((permission) =>
permissionList.includes(permission)
);
if (!hasPermission) {
nextTick(() => {
el.parentNode?.removeChild(el);
});
}
};
const vPermission: Directive = {
beforeMount(el: HTMLElement, binding: PermissionBinding) {
isHasPermission(el, binding);
},
updated(el: HTMLElement, binding: PermissionBinding) {
isHasPermission(el, binding);
},
};
declare global {
interface HTMLElement {
_permissionOriginalDisplay?: string;
}
}
export default vPermission;
复制文本指令 v-copy
navigator.clipboard 有兼容性问题,低版本浏览器不能使用,可使用 clipboard 库实现
import Clipboard from "clipboard";
import { ElMessage } from "element-plus";
import type { Directive, DirectiveBinding } from "vue";
interface CopyBinding extends DirectiveBinding {
value: string | (() => string);
}
const vCopy: Directive = {
mounted(el: HTMLElement, binding: CopyBinding) {
// 初始化时就创建Clipboard实例
const initializeClipboard = () => {
// 如果已有实例,先销毁
if (el._clipboardInstance) {
el._clipboardInstance.destroy();
}
// 设置复制相关属性
el.setAttribute("data-clipboard-action", "copy");
el.setAttribute(
"data-clipboard-text",
typeof binding.value === "function" ? binding.value() : binding.value
);
// 创建新的Clipboard实例
el._clipboardInstance = new Clipboard(el);
// 绑定成功和失败事件
el._clipboardInstance.on("success", () => {
ElMessage.success("复制成功");
});
el._clipboardInstance.on("error", () => {
ElMessage.error("当前浏览器不支持复制命令");
});
};
// 初始化Clipboard
initializeClipboard();
// 点击事件直接触发复制
el._copyHandler = () => {
// 执行复制前更新可能变化的文本
const text =
typeof binding.value === "function" ? binding.value() : binding.value;
el.setAttribute("data-clipboard-text", text);
};
el.addEventListener("click", el._copyHandler);
el.style.cursor = "copy";
},
// 当绑定值变化时更新
updated(el: HTMLElement, binding: CopyBinding) {
if (binding.value !== binding.oldValue) {
const text =
typeof binding.value === "function" ? binding.value() : binding.value;
el.setAttribute("data-clipboard-text", text);
}
},
unmounted(el: HTMLElement) {
// 清理工作
el._clipboardInstance?.destroy();
delete el._clipboardInstance;
if (el._copyHandler) {
el.removeEventListener("click", el._copyHandler);
delete el._copyHandler;
}
},
};
declare global {
interface HTMLElement {
_copyHandler?: () => void;
_clipboardInstance?: Clipboard;
}
}
export default vCopy;
防抖指令 v-debounce
import type { Directive, DirectiveBinding } from "vue";
interface DebounceOptions {
event?: string;
delay?: number;
}
const vDebounce: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding<Function>) {
const defaultEvent = "click";
const defaultDelay = 500;
const options: DebounceOptions = {
event: binding.arg?.split("_")?.[0] ?? defaultEvent,
delay: binding.arg ? parseInt(binding.arg.split("_")[1]) : defaultDelay,
};
let timeout: number;
el._debounceHandler = (e: Event) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
binding.value(e);
}, options.delay);
};
el.addEventListener(options.event!, el._debounceHandler);
},
unmounted(el: HTMLElement) {
if (el._debounceHandler) {
const event = el._debounceHandler?.event || "click";
el.removeEventListener(event, el._debounceHandler);
}
},
};
declare global {
interface HTMLElement {
_debounceHandler?: ((e: Event) => void) & { event?: string };
}
}
export default vDebounce;
聚焦指令 v-focus
import type { Directive, DirectiveBinding } from "vue";
/**
* 查找 elementUI 的 input 组件
* @param el
* @returns
*/
const findInputEl = (el: HTMLElement) => {
if (el.nodeName == "INPUT") {
el.focus();
return;
}
for (const key in el.childNodes) {
const currEl = el.childNodes[key];
findInputEl(currEl as HTMLElement);
}
};
const vFocus: Directive = {
mounted(el: HTMLElement) {
el.nodeName == "INPUT" ? el.focus() : findInputEl(el);
},
updated(el: HTMLElement, binding: DirectiveBinding<boolean>) {
if (binding.value) {
el.focus();
}
},
};
export default vFocus;
无限滚动指令 v-infinite-scroll
import { DirectiveBinding, Ref } from "vue";
// 定义指令的选项类型
interface InfiniteScrollOptions {
// 加载数据的回调函数
loadMore: () => Promise<void>;
// 列表数据的引用
dataSource: Ref<any[]>;
// 加载状态的引用
loading: Ref<boolean>;
// 是否还有更多数据
hasMore: Ref<boolean>;
// 触发加载的阈值(像素)
threshold?: number;
}
// 存储每个元素的观察者实例
const observerMap = new WeakMap<Element, IntersectionObserver>();
// 自定义无限滚动指令
export const vInfiniteScroll = {
mounted(el: Element, binding: DirectiveBinding<InfiniteScrollOptions>) {
const {
loadMore,
dataSource,
loading,
hasMore,
threshold = 100,
} = binding.value;
const observer = new IntersectionObserver(
async (entries) => {
// 检查最后一个元素是否可见,并且不在加载中,且还有更多数据
if (
entries[0].isIntersecting &&
!loading.value &&
hasMore.value &&
dataSource.value.length > 0
) {
// 标记为加载中
loading.value = true;
try {
await loadMore();
} catch (error) {
console.error("加载数据失败:", error);
} finally {
loading.value = false;
}
}
},
{
rootMargin: `0px 0px ${threshold}px 0px`,
}
);
// 存储观察者实例,便于后续清理
observerMap.set(el, observer);
// 观察最后一个元素
observer.observe(el);
},
updated(el: Element, binding: DirectiveBinding<InfiniteScrollOptions>) {
const { dataSource, hasMore } = binding.value;
const observer = observerMap.get(el);
if (observer) {
// 如果没有更多数据了,停止观察
if (!hasMore.value) {
observer.unobserve(el);
return;
}
// 如果数据有更新,重新观察最后一个元素
// 先停止观察旧元素
observer.unobserve(el);
// 再观察更新后的元素
observer.observe(el);
}
},
// 卸载绑定元素的父组件时调用
unmounted(el: Element) {
const observer = observerMap.get(el);
if (observer) {
// 停止观察并清理
observer.unobserve(el);
observerMap.delete(el);
}
},
};
export default vInfiniteScroll;
图片懒加载指令 v-lazy
import type { Directive, DirectiveBinding } from "vue";
const vLazy: Directive = {
mounted(el: HTMLImageElement, binding: DirectiveBinding<string>) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
el.src = binding.value;
observer.unobserve(el);
}
});
},
{
rootMargin: "0px",
threshold: 0.1,
}
);
observer.observe(el);
el._lazyObserver = observer;
},
unmounted(el: HTMLImageElement) {
el._lazyObserver?.unobserve(el);
},
};
declare global {
interface HTMLElement {
_lazyObserver?: IntersectionObserver;
}
}
export default vLazy;
全屏指令 v-screenfull
使用 screenfull 库实现,并处理兼容性问题
import screenfull from "screenfull";
import type { Directive, DirectiveBinding } from "vue";
interface ScreenfullElement extends HTMLElement {
_screenfullHandler?: () => void;
}
interface ScreenfullOptions {
isFullscreen?: boolean; // 初始值是否全屏
container: HTMLElement | string;
}
const vScreenfull: Directive<ScreenfullElement, ScreenfullOptions> = {
mounted(el, binding) {
if (!screenfull.isEnabled) {
console.warn("浏览器不支持全屏API");
return;
}
let { isFullscreen = false, container } = binding.value ?? {};
container = (typeof container === "string" ? document.querySelector(container) : container) as HTMLElement;
try {
if (!container) throw new Error("container元素不存在");
} catch (error) {
console.error("container选择器无效:", error);
}
const toggleFullscreen = () => {
if (screenfull.isFullscreen) {
screenfull.exit().catch((err) => {
console.error("退出全屏失败:", err);
});
} else {
screenfull.request(container).catch((err) => {
console.error("全屏失败:", err);
});
}
};
el._screenfullHandler = toggleFullscreen;
el.addEventListener("click", el._screenfullHandler);
// 初始状态设置
if (isFullscreen && !screenfull.isFullscreen) {
screenfull.request(container).catch((err) => {
console.error("初始全屏失败:", err);
});
}
},
unmounted(el: ScreenfullElement) {
if (el._screenfullHandler) {
el.removeEventListener("click", el._screenfullHandler);
}
},
};
export default vScreenfull;
文字超出省略指令 v-ellipsis
import type { Directive } from "vue";
interface EllipsisOptions {
line?: number; // 行数,默认1行
content?: string; // 自定义内容
}
const vEllipsis: Directive<HTMLElement, EllipsisOptions | string | undefined> = {
mounted(el, binding) {
const options: EllipsisOptions =
typeof binding.value === "object"
? binding.value
: { content: binding.value };
const line = options.line || 1;
const content = options.content || el.textContent || "";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
el.style.display = "-webkit-box";
el.style.webkitLineClamp = String(line);
el.style.webkitBoxOrient = "vertical";
el.style.whiteSpace = "nowrap";
if (options.content) {
el.textContent = content;
}
},
updated(el, binding) {
const options: EllipsisOptions =
typeof binding.value === "object"
? binding.value
: { content: binding.value };
if (options.content) {
el.textContent = options.content;
}
},
};
export default vEllipsis;
回到顶部指令 v-backtop
import type { Directive } from "vue";
interface BacktopOptions {
visibilityHeight?: number; // 滚动多少距离显示按钮
right?: string; // 距离右侧距离
bottom?: string; // 距离底部距离
transition?: string; // 过渡效果
scrollContainer?: HTMLElement | string; // 滚动条容器
}
interface BacktopElement extends HTMLElement {
_backtopHandler?: () => void;
_backtopScrollHandler?: () => void;
_scrollContainer: HTMLElement;
}
const vBacktop: Directive<BacktopElement, BacktopOptions | undefined> = {
mounted(el, binding) {
const options: BacktopOptions = binding.value || {};
const {
visibilityHeight = 200,
right = "40px",
bottom = "40px",
transition = "all .3s",
} = options;
const scrollContainer =
(typeof options.scrollContainer == "string"
? document.getElementById(options.scrollContainer)
: options.scrollContainer) ?? window;
// 设置按钮样式
Object.assign(el.style, {
position: "fixed",
right,
bottom,
cursor: "pointer",
transition,
opacity: "0",
visibility: "hidden",
'z-index': 1
});
// 滚动事件处理
const scrollHandler = () => {
const scrollTop = scrollContainer==window?(document.documentElement || document.body).scrollTop:(scrollContainer as HTMLElement).scrollTop;
if (scrollTop > visibilityHeight) {
el.style.opacity = "1";
el.style.visibility = "visible";
} else {
el.style.opacity = "0";
el.style.visibility = "hidden";
}
};
// 点击返回顶部
const clickHandler = () => {
scrollContainer.scrollTo({
top: 0,
behavior: "smooth",
});
};
el._scrollContainer = scrollContainer as HTMLElement;
el._backtopScrollHandler = scrollHandler;
el._backtopHandler = clickHandler;
scrollContainer.addEventListener("scroll", scrollHandler);
el.addEventListener("click", clickHandler);
// 初始检查
scrollHandler();
},
unmounted(el: BacktopElement) {
if (el._backtopScrollHandler && el._scrollContainer) {
el._scrollContainer.removeEventListener(
"scroll",
el._backtopScrollHandler
);
}
if (el._backtopHandler) {
el.removeEventListener("click", el._backtopHandler);
}
},
};
export default vBacktop;
水印指令 vue-waterMarker
import type { Directive, DirectiveBinding } from "vue";
interface WatermarkOptions {
text?: string | string[]; // 水印文字
font?: string; // 字体样式
color?: string; // 字体颜色
opacity?: number; // 透明度
angle?: number; // 旋转角度
gap?: [number, number]; // 水印间距 [水平, 垂直]
}
const vWatermark: Directive<HTMLElement, WatermarkOptions | string> = {
mounted(el, binding) {
createWatermark(el, binding);
},
updated(el, binding) {
createWatermark(el, binding);
},
};
function createWatermark(
el: HTMLElement,
binding: DirectiveBinding<WatermarkOptions | string>
) {
// 清除现有水印
const existingWatermark = el.querySelector(".vue-watermark");
if (existingWatermark) {
el.removeChild(existingWatermark);
}
const options: WatermarkOptions =
typeof binding.value === "string"
? { text: binding.value }
: binding.value || {};
const {
text = "Watermark",
font = "16px Microsoft YaHei",
color = "rgba(128, 128, 128, 0.3)",
opacity = 0.3,
angle = -20,
gap = [100, 100],
} = options;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 测量文本宽度
ctx.font = font;
const texts = Array.isArray(text) ? text : [text];
let maxWidth = 0;
texts.forEach((t) => {
const metrics = ctx.measureText(t);
maxWidth = Math.max(maxWidth, metrics.width);
});
const canvasSize = Math.max(maxWidth, 200) * 1.5;
canvas.width = canvasSize + gap[0];
canvas.height = canvasSize * texts.length + gap[1];
// 绘制水印
ctx.font = font;
ctx.fillStyle = color;
ctx.globalAlpha = opacity;
ctx.rotate((angle * Math.PI) / 180);
texts.forEach((t, i) => {
ctx.fillText(t, 0, (i + 1) * (canvasSize / texts.length));
});
// 创建水印背景
const watermark = document.createElement("div");
watermark.className = "vue-watermark";
watermark.style.position = "absolute";
watermark.style.top = "0";
watermark.style.left = "0";
watermark.style.width = "100%";
watermark.style.height = "100%";
watermark.style.pointerEvents = "none";
watermark.style.backgroundImage = `url(${canvas.toDataURL()})`;
watermark.style.backgroundRepeat = "repeat";
el.style.position = "relative";
el.appendChild(watermark);
}
export default vWatermark;
快速引入外部页面指令 v-iframe
import type { Directive, DirectiveBinding } from "vue";
interface IframeOptions {
src: string;
width?: string;
height?: string;
loading?: "eager" | "lazy";
sandbox?: string[];
allow?: string[];
onLoad?: (iframe: HTMLIFrameElement) => void;
onError?: (error: string | Event) => void;
}
interface IframeElement extends HTMLElement {
_iframe?: HTMLIFrameElement;
_iframeResizeObserver?: ResizeObserver;
}
const vIframe: Directive<IframeElement, string | IframeOptions> = {
mounted(el, binding) {
createIframe(el, binding);
},
updated(el, binding) {
createIframe(el, binding);
},
unmounted(el) {
cleanupIframe(el);
},
};
function createIframe(
el: IframeElement,
binding: DirectiveBinding<string | IframeOptions>
) {
// 清理现有 iframe
cleanupIframe(el);
// 解析配置
const options: IframeOptions =
typeof binding.value === "string" ? { src: binding.value } : binding.value;
if (!options.src) {
console.error("v-iframe: src is required");
return;
}
// 创建 iframe 元素
const iframe = document.createElement("iframe");
iframe.src = options.src;
iframe.width = options.width || "100%";
iframe.height = options.height || "100%";
iframe.style.border = "none";
iframe.loading = options.loading || "lazy";
// 设置安全沙箱属性
if (options.sandbox) {
iframe.sandbox.add(...options.sandbox);
} else {
// 默认沙箱限制
iframe.sandbox.add("allow-same-origin", "allow-scripts", "allow-popups");
}
// 设置允许权限
if (options.allow) {
iframe.allow = options.allow.join("; ");
}
// 事件处理
iframe.onload = () => {
options.onLoad?.(iframe);
// setupAutoHeight(el, iframe);
};
iframe.onerror = (error) => {
options.onError?.(error);
console.error("v-iframe: Failed to load", options.src, error);
};
el._iframe = iframe;
el.appendChild(iframe);
}
function setupAutoHeight(el: IframeElement, iframe: HTMLIFrameElement) {
try {
// 尝试自动调整高度
const resizeObserver = new ResizeObserver(() => {
if (iframe.contentWindow?.document?.body) {
iframe.style.height = `${iframe.contentWindow.document.body.scrollHeight}px`;
}
});
resizeObserver.observe(iframe);
el._iframeResizeObserver = resizeObserver;
} catch (error) {
console.warn(
"v-iframe: Auto height adjustment failed due to cross-origin restrictions"
);
}
}
function cleanupIframe(el: IframeElement) {
if (el._iframe) {
el.removeChild(el._iframe);
delete el._iframe;
}
if (el._iframeResizeObserver) {
el._iframeResizeObserver.disconnect();
delete el._iframeResizeObserver;
}
}
export default vIframe;
tooltip 指令 v-tooltip
指令设计:
- 支持两种传参方式:字符串简写和完整配置对象
- 自动处理实例的生命周期管理
- 响应式更新内容和配置
性能优化:
- 使用 WeakMap 存储实例避免内存泄漏
- 只在必要时更新实例
兼容性处理:
- 修复 Vue 3 teleport 与 Tippy.js 的冲突
- 完善的 TypeScript 类型支持
可扩展性:
- 预留了插件集成接口
- 支持自定义主题和动画
- 这个实现提供了生产环境可用的 Tippy.js 自定义指令,包含了类型安全、响应式更新和良好的内存管理。
import tippy, { Instance, Props } from "tippy.js";
import "tippy.js/dist/tippy.css";
import { DirectiveBinding, ObjectDirective } from "vue";
// 定义指令值的类型
interface TippyDirectiveValue {
content: string; // 提示内容
options?: Partial<Props>; // Tippy.js 配置选项
isEnabled?: boolean; // 是否启用提示
}
// 存储 Tippy 实例的 WeakMap
const tippyInstances = new WeakMap<HTMLElement, Instance>();
const vTippy: ObjectDirective<HTMLElement, TippyDirectiveValue | string> = {
mounted(el, binding) {
initTippy(el, binding);
},
updated(el, binding) {
const instance = tippyInstances.get(el);
if (!instance) return;
// 获取标准化后的配置
const { content, options = {}, isEnabled }: TippyDirectiveValue = typeof binding.value === "string"
? { content: binding.value }
: binding.value;
// 更新内容
if (instance.props.content !== content) {
instance.setContent(content);
}
// 更新启用状态
if (isEnabled !== undefined) {
isEnabled ? instance.enable() : instance.disable();
}
// 更新其他选项
instance.setProps(options || {});
},
unmounted(el) {
const instance = tippyInstances.get(el);
if (instance) {
instance.destroy();
tippyInstances.delete(el);
}
},
};
// 初始化 Tippy 实例
function initTippy(el: HTMLElement, binding: DirectiveBinding<TippyDirectiveValue | string>) {
const { content, options = {}, isEnabled }: TippyDirectiveValue = typeof binding.value === "string"
? { content: binding.value }
: binding.value;
// 创建 Tippy 实例
const instance = tippy(el, {
content,
...options,
onHidden(instance) {
// 修复 Vue 3 的 teleport 与 Tippy 的冲突
if (instance.popper.firstChild) {
document.body.appendChild(instance.popper);
}
},
});
// 设置初始启用状态
if (isEnabled !== undefined) {
isEnabled ? instance.enable() : instance.disable();
}
// 存储实例
tippyInstances.set(el, instance);
}
export default vTippy;
动画滚动指令 v-slide-in
import type { Directive, DirectiveBinding } from "vue";
const DISTANCE = 200;
const DURATION = 500;
const map = new WeakMap();
const ob = new IntersectionObserver((entries) => {
for (const entrie of entries) {
console.log(entrie);
if (entrie.isIntersecting) {
const animation = map.get(entrie.target);
animation.play();
ob.unobserve(entrie.target);
}
}
});
function isBelowViewport(el) {
const rect = el.getBoundingClientRect();
return rect.top > window.innerHeight;
}
const slideIn: Directive = {
mounted(el: HTMLElement) {
if (!isBelowViewport(el)) return;
const animation = el.animate(
[
{
transform: `translateY(${DISTANCE}px)`,
opacity: 0.5,
},
{
transform: `translateY(0)`,
opacity: 1,
},
],
{
duration: DURATION,
easing: "ease-out",
fill: "forwards",
}
);
map.set(el, animation);
animation.pause();
ob.observe(el);
},
unmounted(el: HTMLElement) {
ob.unobserve(el);
},
};
export default slideIn;
自定义滚动条指令 v-scrollbar
el-scrollbar 内部的元素不能实现百分比高度,如果要实现需要 css 穿透;
import { DirectiveBinding, ObjectDirective } from "vue";
// 定义滚动条样式配置的类型
interface ScrollbarStyleOptions {
/** 滚动条宽度 */
width?: string;
/** 滚动条轨道颜色 */
trackColor?: string;
/** 滚动条滑块颜色 */
thumbColor?: string;
/** 滚动条滑块悬停颜色 */
thumbHoverColor?: string;
/** 滚动条滑块圆角 */
borderRadius?: string;
/** 滚动条滑块最小高度 */
minThumbHeight?: string;
/** 是否在非hover状态下隐藏滚动条 */
autoHide?: boolean;
/** 纵向滚动条是否启用 */
vertical?: boolean;
/** 横向滚动条是否启用 */
horizontal?: boolean;
}
// 默认配置
const defaultOptions: ScrollbarStyleOptions = {
width: "6px",
trackColor: "transparent",
thumbColor: "#c1c1c1",
thumbHoverColor: "#a8a8a8",
borderRadius: "3px",
minThumbHeight: "30px",
autoHide: true,
vertical: true,
horizontal: false,
};
// 扩展HTMLElement类型,添加自定义属性
declare global {
interface HTMLElement {
_scrollbarStyle?: {
styleElement: HTMLStyleElement;
uniqueClass: string;
};
}
}
// 生成唯一类名
const generateUniqueClass = () =>
`scrollbar-${Math.random().toString(36).substring(2, 12)}`;
const generateStyle = (
el: HTMLElement,
binding: DirectiveBinding<ScrollbarStyleOptions>
) => {
// 合并配置
const options: ScrollbarStyleOptions = {
...defaultOptions,
...binding.value,
};
// 生成唯一类名,避免样式冲突
const uniqueClass = generateUniqueClass();
el.classList.add(uniqueClass);
// 创建样式元素
const style = document.createElement("style");
// 基础样式
let styleContent = `.${uniqueClass} {
<!-- width: calc(${el.offsetWidth}px + ${options.width} + 4px) !important; -->
${options.vertical && "overflow-y: auto;"}
${options.horizontal && "overflow-x: auto;"}
scrollbar-width: thin; // Firefox
scrollbar-color: ${options.thumbColor} ${options.trackColor};
}`;
// WebKit内核浏览器滚动条样式 (Chrome, Safari等)
styleContent += `.${uniqueClass}::-webkit-scrollbar {
width: ${options.width};
height: ${options.width};
}`;
// 轨道样式
styleContent += `.${uniqueClass}::-webkit-scrollbar-track {
background: ${options.trackColor};
border-radius: ${options.borderRadius};
}`;
// 滑块样式
styleContent += `.${uniqueClass}::-webkit-scrollbar-thumb {
background-color: ${options.thumbColor};
border-radius: ${options.borderRadius};
min-height: ${options.minThumbHeight};
transition: background-color 0.2s ease;
}`;
// 滑块悬停样式
styleContent += `.${uniqueClass}::-webkit-scrollbar-thumb:hover {
background-color: ${options.thumbHoverColor};
}`;
// 自动隐藏功能
if (options.autoHide) {
styleContent += `.${uniqueClass} {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.3s;
}`;
styleContent += `.${uniqueClass}:hover {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 transparent;
}`;
}
style.textContent = styleContent;
document.head.appendChild(style);
// 存储引用以便后续清理
el._scrollbarStyle = {
styleElement: style,
uniqueClass,
};
};
// 自定义滚动条指令
const customScrollbarDirective: ObjectDirective<
HTMLElement,
ScrollbarStyleOptions
> = {
mounted(el, binding) {
generateStyle(el, binding);
},
// 当指令参数更新时
updated(el, binding) {
// 先清理旧样式
if (el._scrollbarStyle) {
const { styleElement, uniqueClass } = el._scrollbarStyle;
if (styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
el.classList.remove(uniqueClass);
}
generateStyle(el, binding);
},
// 指令与元素解绑时
unmounted(el: HTMLElement) {
if (el._scrollbarStyle) {
const { styleElement, uniqueClass } = el._scrollbarStyle;
// 移除样式元素
if (styleElement.parentNode) {
styleElement.parentNode.removeChild(styleElement);
}
// 移除元素上的类名
el.classList.remove(uniqueClass);
// 清除引用
delete el._scrollbarStyle;
}
},
};
// 注册指令
export default customScrollbarDirective;
滑动动画指令 v-slideIn
import type { Directive, DirectiveBinding } from "vue";
const DISTANCE = 100;
const DURATION = 500;
const map = new WeakMap();
const ob = new IntersectionObserver((entries) => {
for (const entrie of entries) {
console.log(entrie);
if (entrie.isIntersecting) {
const animation = map.get(entrie.target);
animation.play();
ob.unobserve(entrie.target);
}
}
});
function isBelowViewport(el) {
const rect = el.getBoundingClientRect();
return rect.top > window.innerHeight;
}
const slideIn: Directive = {
mounted(el: HTMLElement) {
if (!isBelowViewport(el)) return;
const animation = el.animate(
[
{
transform: `translateY(${DISTANCE}px)`,
opacity: 0.5,
},
{
transform: `translateY(0)`,
opacity: 1,
},
],
{
duration: DURATION,
easing: "ease-out",
fill: "forwards",
}
);
map.set(el, animation);
ob.observe(el);
animation.pause();
},
unmounted(el: HTMLElement) {
ob.unobserve(el);
},
};
export default slideIn;
多组件按帧加载指令 v-frame-load
真正的按帧加载:
- 不是简单的视觉渐显,而是实际控制组件的创建和挂载时机
- 使用 requestAnimationFrame 确保与浏览器渲染周期同步
性能优化:
- 支持批量加载 (batchSize),平衡性能和流畅度
- 内置 IntersectionObserver 实现懒加载
- 完善的资源清理机制,避免内存泄漏
灵活配置:
- 可控制加载方向 (正向/反向)
- 可调整加载速度和延迟
- 可设置视口触发阈值
TypeScript 支持:
- 完整的类型定义
- 配置选项类型检查
import { DirectiveBinding, ObjectDirective, Component, createApp } from "vue";
interface FrameLoadOptions {
components: Component[]; // 需要按帧加载的组件数组
interval?: number; // 每帧间隔时间(ms),默认16ms(约60fps)
batchSize?: number; // 每批加载的组件数量,默认1
startDelay?: number; // 开始延迟(ms),默认0
reverse?: boolean; // 是否反向加载,默认false
observe?: boolean; // 是否使用IntersectionObserver,默认true
threshold?: number; // IntersectionObserver的阈值,默认0.1
}
interface FrameLoadElement extends HTMLElement {
_frameLoad?: {
cleanup: () => void;
loaded: boolean;
observer?: IntersectionObserver;
};
}
const FrameLoad: ObjectDirective<FrameLoadElement> = {
mounted(el: FrameLoadElement, binding: DirectiveBinding<FrameLoadOptions>) {
const options: FrameLoadOptions = {
interval: 16,
batchSize: 1,
startDelay: 0,
reverse: false,
observe: true,
threshold: 0.1,
...binding.value,
};
if (!options.components || options.components.length === 0) {
console.warn("v-frame-load: No components provided");
return;
}
// 创建容器用于挂载组件
const container = document.createElement("div");
el.appendChild(container);
let lastRequestId: number | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const loadComponents = () => {
if (el._frameLoad?.loaded) return;
const components = options.reverse
? [...options.components].reverse()
: options.components;
let loadedCount = 0;
const total = components.length;
const loadNextBatch = () => {
if (loadedCount >= total) {
if (el._frameLoad) el._frameLoad.loaded = true;
return;
}
const batchEnd = Math.min(
loadedCount + (options.batchSize || 1),
total
);
// 使用 requestAnimationFrame 确保在渲染前执行
lastRequestId = requestAnimationFrame(() => {
// 创建并挂载当前批次的组件
for (let i = loadedCount; i < batchEnd; i++) {
const component = components[i];
const app = createApp(component);
const instance = app.mount(document.createElement("div"));
container.appendChild(instance.$el);
}
loadedCount = batchEnd;
// 使用 setTimeout 控制批次间隔
timeoutId = setTimeout(() => {
lastRequestId = requestAnimationFrame(loadNextBatch);
}, options.interval);
});
};
// 初始延迟
timeoutId = setTimeout(() => {
lastRequestId = requestAnimationFrame(loadNextBatch);
}, options.startDelay);
};
const cleanup = () => {
if (lastRequestId) cancelAnimationFrame(lastRequestId);
if (timeoutId) clearTimeout(timeoutId);
if (el._frameLoad?.observer) el._frameLoad.observer.disconnect();
// 清空容器
while (container.firstChild) {
container.removeChild(container.firstChild);
}
el.removeChild(container);
};
// 初始化状态
el._frameLoad = {
cleanup,
loaded: false,
};
if (options.observe) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadComponents();
observer.unobserve(el);
if (el._frameLoad) el._frameLoad.observer = undefined;
}
});
},
{ threshold: options.threshold }
);
observer.observe(el);
el._frameLoad.observer = observer;
} else {
loadComponents();
}
},
unmounted(el: FrameLoadElement) {
if (el._frameLoad) {
el._frameLoad.cleanup();
delete el._frameLoad;
}
},
};
export default FrameLoad;
<template>
<div class="frame-load-container">
<div style="height: 600px;"></div>
<!-- 使用自定义指令 -->
<div v-frame-load="frameLoadOptions" class="component-wrapper"></div>
</div>
</template>
<script setup lang="ts">
import Component1 from './components/Component1.vue';
import Component2 from './components/Component2.vue';
import Component3 from './components/Component3.vue';
import Component4 from './components/Component4.vue';
import Component5 from './components/Component5.vue';
import Component6 from './components/Component6.vue';
import Component7 from './components/Component7.vue';
import Component8 from './components/Component8.vue';
const frameLoadOptions = {
components: [
Component1,
Component2,
Component3,
Component4,
Component5,
Component6,
Component7,
Component8,
],
interval: 100, // 每100ms加载一批
batchSize: 1, // 每次加载1个组件
startDelay: 500, // 延迟500ms开始
observe: true, // 使用IntersectionObserver
threshold: 0.2 // 当20%元素可见时触发
}
</script>
css 工程化👉 抽离 css 文件
上一篇
目录
- 1. 自定义指令
- 2. 案例
- 2.1. 点击外部指令 v-click-outside
- 2.2. 响应缩放指令 v-resize
- 2.3. 拖拽指令 (v-draggable)
- 2.4. 权限控制指令 v-permission
- 2.5. 复制文本指令 v-copy
- 2.6. 防抖指令 v-debounce
- 2.7. 聚焦指令 v-focus
- 2.8. 无限滚动指令 v-infinite-scroll
- 2.9. 图片懒加载指令 v-lazy
- 2.10. 全屏指令 v-screenfull
- 2.11. 文字超出省略指令 v-ellipsis
- 2.12. 回到顶部指令 v-backtop
- 2.13. 水印指令 vue-waterMarker
- 2.14. 快速引入外部页面指令 v-iframe
- 2.15. tooltip 指令 v-tooltip
- 2.16. 动画滚动指令 v-slide-in
- 2.17. 自定义滚动条指令 v-scrollbar
- 2.18. 滑动动画指令 v-slideIn
- 2.19. 多组件按帧加载指令 v-frame-load