Skip to content

自定义指令

搜索匹配的文本高亮

低阶版

ts
// 注册全局
app.directive("highlightTxt", (el: any, binding: { value: { search: string, txt: string } }) => {
  // 搜索的文字
  const searchTerm = binding.value.search.trim();
  // 当前item项的文字
  const txt = binding.value.txt;
  // 清除高亮
  if (!searchTerm) return (el.innerHTML = txt);

  // 转义正则特殊字符
  const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  const regex = new RegExp(`(${escapedTerm})`, "gi");

  // 替换匹配文本
  el.innerHTML = txt.replace(regex, '<span class="highlight">$1</span>');
});


/** style.css 编写样式 */
/* 代码高亮 */
.highlight {
  background: linear-gradient(120deg, #fff9c4 0%, #ffecb3 100%);
  padding: 0 4px;
  border-radius: 4px;
  font-weight: 700;
  color: #d35400;
}

使用说明

vue
<p v-highlightTxt="{txt: '文本', search: '搜索的内容'}">文本</p>

进阶版

ts
/**
 * 自定义文字高亮指令(带节流+XSS防护)
 * 功能:根据搜索关键词实时高亮元素中的匹配文本,通过节流减少高频DOM操作
 * 适用场景:搜索输入实时高亮、列表筛选高亮等
 */
export default {
  /**
   * 指令绑定到元素时执行(初始化阶段)
   * @param el 绑定的DOM元素(需是HTMLElement类型,确保可操作innerHTML)
   * @param binding 指令绑定值,格式:{ search: 搜索关键词, txt: 待高亮的原始文本 }
   */
  mounted(el: HTMLElement, binding: { value: { search: string; txt: string } }) {
    /**
     * 节流后的高亮核心逻辑
     * @param searchTerm 搜索关键词(可能为空)
     * @param txt 待高亮的原始文本
     * 节流延迟:200ms(可根据需求调整,输入类场景建议100-200ms)
     */
    const highlightThrottle = throttle((searchTerm: string, txt: string) => {
      // 无搜索关键词时,直接渲染原始文本(清除高亮)
      if (!searchTerm.trim()) {
        el.innerHTML = escapeHtml(txt); // 始终转义,防止XSS
        return;
      }

      // 转义正则特殊字符(如 . * + ? 等),避免正则匹配异常
      const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      // 构建正则:g-全局匹配,i-忽略大小写
      const regex = new RegExp(`(${escapedTerm})`, 'gi');

      // 1. 先转义原始文本(防XSS)→ 2. 用正则匹配关键词 → 3. 包裹高亮样式
      el.innerHTML = escapeHtml(txt).replace(regex, '<span class="highlight">$1</span>');
    }, 200);

    // 将节流函数挂载到el上,供updated钩子复用(避免重复创建节流函数)
    (el as any)._highlightThrottle = highlightThrottle;

    // 初始化执行一次高亮(确保页面加载时就有正确的显示)
    const { search, txt } = binding.value;
    highlightThrottle(search, txt);
  },

  /**
   * 绑定值(search/txt)变化时执行(更新阶段)
   * @param el 绑定的DOM元素
   * @param binding 包含新值(value)和旧值(oldValue)的绑定对象
   */
  updated(
    el: HTMLElement,
    binding: {
      value: { search: string; txt: string };
      oldValue: { search: string; txt: string };
    }
  ) {
    const { search: newSearch, txt: newTxt } = binding.value;
    const { search: oldSearch, txt: oldTxt } = binding.oldValue;

    // 优化:只有搜索词或原始文本真的变化时,才触发高亮(避免无效执行)
    if (newSearch === oldSearch && newTxt === oldTxt) return;

    // 执行节流后的高亮逻辑(从el上获取之前挂载的节流函数)
    (el as any)._highlightThrottle(newSearch, newTxt);
  },

  /**
   * 指令解绑时执行(清理阶段)
   * 作用:避免内存泄漏(清除未执行的节流定时器、删除el上的挂载属性)
   * @param el 绑定的DOM元素
   */
  unmounted(el: HTMLElement) {
    // 获取el上挂载的节流函数
    const throttleFn = (el as any)._highlightThrottle;

    // 若节流函数存在取消方法,执行取消(清除未触发的定时器)
    if (throttleFn && typeof throttleFn.cancel === 'function') {
      throttleFn.cancel();
    }

    // 删除el上的自定义属性,避免内存泄漏
    delete (el as any)._highlightThrottle;
  },
};

/**
 * 节流函数实现(适配浏览器环境)
 * 功能:控制函数在指定时间内最多执行一次,避免高频调用
 * @param fn 待节流的目标函数
 * @param delay 节流延迟时间(毫秒)
 * @returns 节流后的函数(附带cancel方法,用于取消未执行的任务)
 */
function throttle<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): T & { cancel: () => void } {
  let timer: number | null = null; // 浏览器环境定时器类型为number(修复NodeJS.Timeout报错)
  let lastExecTime = 0; // 上次函数执行时间戳

  /**
   * 节流包装函数
   * @param this 显式声明为void,修复"this隐式具有类型any"报错(无需绑定this)
   * @param args 目标函数的参数列表
   */
  const throttled = function (this: void, ...args: Parameters<T>) {
    const now = Date.now(); // 当前时间戳
    const elapsed = now - lastExecTime; // 距离上次执行的时间差

    // 情况1:距离上次执行超过延迟时间 → 直接执行目标函数
    if (elapsed >= delay) {
      lastExecTime = now; // 更新上次执行时间
      fn(...args); // 直接调用目标函数(无需绑定this,因声明this为void)
    } else {
      // 情况2:未超过延迟时间 → 取消之前的定时器,重新设置
      if (timer) clearTimeout(timer); // 清除旧定时器,避免重复执行
      timer = setTimeout(() => {
        lastExecTime = Date.now(); // 更新上次执行时间
        fn(...args); // 延迟后执行目标函数
        timer = null; // 定时器执行后重置为null
      }, delay - elapsed); // 剩余延迟时间 = 总延迟 - 已过去时间
    }
  } as T & { cancel: () => void };

  /**
   * 取消节流任务的方法
   * 作用:在指令解绑时清除未执行的定时器,避免内存泄漏
   */
  throttled.cancel = () => {
    if (timer) clearTimeout(timer); // 清除定时器
    timer = null; // 重置定时器状态
    lastExecTime = 0; // 重置上次执行时间戳
  };

  return throttled;
}

/**
 * HTML转义函数(防XSS攻击核心)
 * 作用:将HTML特殊字符转义为实体,避免恶意脚本注入
 * 需转义的特殊字符:& < > " '
 */
function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;') // & → &amp;
    .replace(/</g, '&lt;') // < → &lt;
    .replace(/>/g, '&gt;') // > → &gt;
    .replace(/"/g, '&quot;') // " → &quot;
    .replace(/'/g, '&#39;'); // ' → &#39;
}

使用说明

  1. 在Vue中注册指令:
ts
Vue.directive('highlight', highlightDirective)
  1. 模板中使用:
vue
<div v-highlight="{ search: 搜索关键词, txt: 原始文本 }"></div>
  1. 需添加高亮样式(示例):
css
.highlight {
  background-color: #fff2cc; /* 浅黄色背景 */
  color: #d97706; /* 橙色文字 */
  padding: 0 2px;
  border-radius: 2px;
}
  1. 调整建议:
    • 节流延迟:输入类场景用100-200ms,筛选类用300ms
    • 若需"输入停止后立即高亮",可将throttle改为debounce(防抖)
    • 若txt是可信HTML片段(不建议),可移除escapeHtml调用

元素过度动画

元素平滑上升

ts
// y轴偏移的默认值
const DISTANCE = 50;

// 弱映射:存储元素与动画实例的关联(避免内存泄漏,元素销毁时自动释放)
const map = new WeakMap<HTMLElement, Animation>();

// ========================= IntersectionObserver配置 =========================
/**
 * 观察器配置项 - 优化元素交叉判定精度
 * root: 默认null(视口作为根元素)
 * rootMargin: 根元素边界扩展/收缩(下边界扩展DISTANCE,让元素快进入视口时提前触发动画)
 * threshold: 交叉阈值(元素可见比例≥30%时才判定为"交叉",避免1px误触发)
 */
const observerOptions: IntersectionObserverInit = {
  rootMargin: `0px 0px ${DISTANCE}px 0px`,
  threshold: 0.3,
};

// ========================= 创建IntersectionObserver实例 =========================
/**
 * 元素可见性监听器 - 监听元素是否进入视口
 * 核心逻辑:元素交叉(可见)时,播放预存的动画并清理资源
 */
const ob = new IntersectionObserver((entries) =>{
    for (const entry of entries) {
        // 仅当元素处于交叉状态(可见)时执行
        if(entry.isIntersecting){
            // 从映射中获取当前元素的动画实例
            const animation = map.get(entry.target as HTMLElement);
            // 播放动画(存在动画实例时才执行)
            animation?.play();
            // 动画播放后,从映射中删除(确保只播放一次)
            map.delete(entry.target as HTMLElement);
            // 取消对当前元素的监听(减少性能消耗)
            ob.unobserve(entry.target);
        }
    }
}, observerOptions);


export default{
    mounted(el: HTMLElement, binding: { value: { firstScreen: Boolean } }){  // firstScreen为true时表示第一屏也有动画,默认为false
        // 条件判断:非首屏动画 + 元素在视口外 → 跳过动画(直接显示元素)
        // 反之(首屏动画 或 元素在视口内)→ 初始化动画
        if(!(el.getBoundingClientRect().top > window.innerHeight) && !binding.value?.firstScreen) return;

        // 创建动画
        const animation = el.animate([
            // 动画起始状态:向下偏移DISTANCE像素 + 完全透明
            // { transform: `translateY(${DISTANCE}px)`, opacity: 1 },
            { transform: `translate3d(0, ${DISTANCE}px, 0)`, opacity: 0 },
            // 动画结束状态:偏移量为0 + 完全不透明
            // { transform: `translateY(0)`, opacity: 1 }
            { transform: `translate3d(0, 0, 0)`, opacity: 1 }
        ],{
            // 动画时长
            duration: 300,
            // 时间函数
            // easing: 'cubic-bezier(1, 0.6, 0.6, 1)',
            easing: 'ease-out',
            // 填充模式(动画前后元素状态)
            // fill: 'forwards',
        })
        
        // 初始暂停动画(等待元素进入视口后再播放)
        animation.pause();
        // 将动画实例存入映射(与元素关联)
        map.set(el, animation);
        // 开始监听当前元素的可见性
        ob.observe(el);
    },
    // 卸载时移除监听
    unmounted(el: HTMLElement){
        // 从映射中获取动画实例并取消(避免动画残留)
        const animation = map.get(el);
        // 全部清除
        animation?.cancel();
        // 从映射中删除元素关联
        map.delete(el);
        // 取消对元素的可见性监听
        ob.unobserve(el);
    }
}

使用说明

直接注册使用即可

ts
app.directive('slideIn', slideIn);

指令使用 firstScreen表示第一屏是否就开启动画,默认不开启

ts
v-slide-in="{ firstScreen: true }"

缩放动画

ts
/********************* 缩放动画 *********************/

// 弱映射:存储元素与动画实例的关联(避免内存泄漏,元素销毁时自动释放)
const map = new WeakMap<HTMLElement, Animation>();

// ========================= IntersectionObserver配置 =========================
/**
 * 观察器配置项 - 优化元素交叉判定精度
 * root: 默认null(视口作为根元素)
 * rootMargin: 根元素边界扩展/收缩(下边界扩展DISTANCE,让元素快进入视口时提前触发动画)
 * threshold: 交叉阈值(元素可见比例≥30%时才判定为"交叉",避免1px误触发)
 */
const observerOptions: IntersectionObserverInit = {
  threshold: 0.3,
};

// ========================= 创建IntersectionObserver实例 =========================
/**
 * 元素可见性监听器 - 监听元素是否进入视口
 * 核心逻辑:元素交叉(可见)时,播放预存的动画并清理资源
 */
const ob = new IntersectionObserver((entries) =>{
    for (const entry of entries) {
        // 仅当元素处于交叉状态(可见)时执行
        if(entry.isIntersecting){
            // 从映射中获取当前元素的动画实例
            const animation = map.get(entry.target as HTMLElement);
            // 播放动画(存在动画实例时才执行)
            animation?.play();
            // 动画播放后,从映射中删除(确保只播放一次)
            map.delete(entry.target as HTMLElement);
            // 取消对当前元素的监听(减少性能消耗)
            ob.unobserve(entry.target);
        }
    }
}, observerOptions);


export default{  //  firstScreen: 第一屏是否立即执行动画.
    mounted(el: HTMLElement, binding: { value: { firstScreen: boolean } }){  // firstScreen为true时表示第一屏也有动画,默认为false
        // 条件判断:非首屏动画 + 元素在视口外 → 跳过动画(直接显示元素)
        // 反之(首屏动画 或 元素在视口内)→ 初始化动画
        if(!(el.getBoundingClientRect().top > window.innerHeight) && !binding.value?.firstScreen) return;
        
        // 创建动画
        const animation = el.animate([
            // 动画起始状态:向下偏移DISTANCE像素 + 完全透明
            { scale: 0.9, opacity: 0 },
            // 动画结束状态:偏移量为0 + 完全不透明
            { scale: 1, opacity: 1 }
        ],{
            // 动画时长
            duration: 300,
            easing: 'ease-out',
        })
        
        // 初始暂停动画(等待元素进入视口后再播放)
        animation.pause();
        // 将动画实例存入映射(与元素关联)
        map.set(el, animation);
        // 开始监听当前元素的可见性
        ob.observe(el);
    },
    // 卸载时移除监听
    unmounted(el: HTMLElement){
        // 从映射中获取动画实例并取消(避免动画残留)
        const animation = map.get(el);
        // 全部清除
        animation?.cancel();
        // 从映射中删除元素关联
        map.delete(el);
        // 取消对元素的可见性监听
        ob.unobserve(el);
    }
}

使用说明

直接注册使用即可

ts
app.directive('scalingIn', scalingIn);

指令使用 firstScreen表示第一屏是否就开启动画,默认不开启

ts
v-scaling-in="{ firstScreen: true }"

Copyright © 2026 Luke