自定义指令

组件上不推荐使用自定义指令

自定义指令种类

  1. 注册全局指令

    const app = createApp({})
    
    // 使 v-focus 在所有组件中都可用
    app.directive('focus', {
      /* ... */
    })
    
  2. 注册局部指令(很少使用)

    const focus = {
      mounted: (el) => el.focus()
    }
    
    export default {
      directives: {
        // 在模板中启用 v-focus
        focus
      }
    }
    

钩子函数

  1. 一个指令的定义对象可以提供几种钩子函数 (都是可选的):

    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) { }
    }
    
  2. 钩子函数参数:

    • 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 钩子中可用;
  3. 举例:

    <!-- 像下面这样使用指令 -->
    <div v-example:foo.bar="baz">
    
    // binding  参数会是一个这样的对象
    {
      arg: 'foo',
      modifiers: { bar: true },
      value: /* `baz` 的值 */,
      oldValue: /* 上一次更新时 `baz` 的值 */
    }
    

批量挂载自定义指令

  1. 手动方式

    JavaScript
    JavaScript
    // 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]);
        });
      },
    };
    
  2. 自动方式

案例

点击外部指令 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

  1. 指令设计:

    1. 支持两种传参方式:字符串简写和完整配置对象
    2. 自动处理实例的生命周期管理
    3. 响应式更新内容和配置
  2. 性能优化:

    1. 使用 WeakMap 存储实例避免内存泄漏
    2. 只在必要时更新实例
  3. 兼容性处理:

    1. 修复 Vue 3 teleport 与 Tippy.js 的冲突
    2. 完善的 TypeScript 类型支持
  4. 可扩展性:

    1. 预留了插件集成接口
    2. 支持自定义主题和动画
    3. 这个实现提供了生产环境可用的 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

  1. 真正的按帧加载:

    1. 不是简单的视觉渐显,而是实际控制组件的创建和挂载时机
    2. 使用 requestAnimationFrame 确保与浏览器渲染周期同步
  2. 性能优化:

    1. 支持批量加载 (batchSize),平衡性能和流畅度
    2. 内置 IntersectionObserver 实现懒加载
    3. 完善的资源清理机制,避免内存泄漏
  3. 灵活配置:

    1. 可控制加载方向 (正向/反向)
    2. 可调整加载速度和延迟
    3. 可设置视口触发阈值
  4. TypeScript 支持:

    1. 完整的类型定义
    2. 配置选项类型检查
TypeScript
HTML
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>
打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

你好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 自定义指令
    1. 1.1. 自定义指令种类
    2. 1.2. 钩子函数
    3. 1.3. 批量挂载自定义指令
  2. 2. 案例
    1. 2.1. 点击外部指令 v-click-outside
    2. 2.2. 响应缩放指令 v-resize
    3. 2.3. 拖拽指令 (v-draggable)
    4. 2.4. 权限控制指令 v-permission
    5. 2.5. 复制文本指令 v-copy
    6. 2.6. 防抖指令 v-debounce
    7. 2.7. 聚焦指令 v-focus
    8. 2.8. 无限滚动指令 v-infinite-scroll
    9. 2.9. 图片懒加载指令 v-lazy
    10. 2.10. 全屏指令 v-screenfull
    11. 2.11. 文字超出省略指令 v-ellipsis
    12. 2.12. 回到顶部指令 v-backtop
    13. 2.13. 水印指令 vue-waterMarker
    14. 2.14. 快速引入外部页面指令 v-iframe
    15. 2.15. tooltip 指令 v-tooltip
    16. 2.16. 动画滚动指令 v-slide-in
    17. 2.17. 自定义滚动条指令 v-scrollbar
    18. 2.18. 滑动动画指令 v-slideIn
    19. 2.19. 多组件按帧加载指令 v-frame-load