vue自定义指令-输入框校验不允许用户输入表情符号(兼容复制粘贴,输入场景)

26 阅读5分钟
import Vue from "vue";

/**
 * 禁止输入表情符号指令(性能优化版)
 * 使用方法: v-no-emoji
 * 支持 input、textarea 等输入框组件
 *
 * 性能说明:const emojiRegex =
    /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu;
 * - 正则表达式在模块级别预编译,所有实例共享
 * - 使用快速检测机制,无表情符号时几乎零开销
 * - 每个输入框独立处理,互不影响
 * - 支持大量输入框同时使用(100+ 无压力)
 */

// 预编译正则表达式,避免每次创建(所有实例共享,性能最优)
// 参考 utils/index.js 的格式,使用字符类统一匹配
// 包含所有常见的表情符号范围,包括 ⌚️ (U+231A) 等符号
const EMOJI_REGEX =
  /[\u{231A}-\u{231B}\u{23E9}-\u{23FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2934}-\u{2935}\u{2B05}-\u{2B07}\u{2B1B}-\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}-\u{3299}\u{1F004}\u{1F0CF}\u{1F170}-\u{1F251}\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{FE00}-\u{FE0F}]/gu;

// 快速检测是否包含表情符号(只检测,不替换)
const HAS_EMOJI_REGEX =
  /[\u{231A}-\u{231B}\u{23E9}-\u{23FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2934}-\u{2935}\u{2B05}-\u{2B07}\u{2B1B}-\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}-\u{3299}\u{1F004}\u{1F0CF}\u{1F170}-\u{1F251}\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{FE00}-\u{FE0F}]/u;

// 使用 WeakMap 存储每个元素的状态,避免内存泄漏(可选优化)
// 当前使用 el._xxx 方式也可以,但 WeakMap 更优雅
const elementCache = new WeakMap();

const noEmoji = {
  // 获取输入框元素(处理 Element UI 的 el-input 组件)
  getInputElement(el) {
    // 从缓存获取
    const cache = elementCache.get(el);
    if (cache && cache.input) return cache.input;

    // 查询输入框元素(按优先级顺序)
    const input =
      el.querySelector(".el-input__inner") ||
      el.querySelector(".el-textarea__inner") ||
      el.querySelector("input") ||
      el.querySelector("textarea") ||
      el;

    // 缓存结果
    if (!cache) {
      elementCache.set(el, { input });
    } else {
      cache.input = input;
    }

    return input;
  },

  // 移除表情符号(优化版:先检测再替换)
  removeEmoji(value) {
    if (!value || typeof value !== "string") return value;
    // 快速检测,如果没有表情符号直接返回
    if (!HAS_EMOJI_REGEX.test(value)) return value;
    // 有表情符号才进行替换
    return value.replace(EMOJI_REGEX, "");
  },

  // 轻量级事件触发(优化版)
  triggerInput(input, value) {
    const oldValue = input.value;
    if (oldValue === value) return; // 值没变化,不触发

    input.value = value;
    // 使用原生事件,性能更好
    const event = new Event("input", { bubbles: true, cancelable: true });
    input.dispatchEvent(event);
  },

  // 处理输入事件(优化版:减少不必要的操作)
  handleInput(e, directive) {
    const input = e.target;
    const value = input.value;

    // 快速检测,没有表情符号直接返回
    if (!value || !HAS_EMOJI_REGEX.test(value)) return;

    // 有表情符号才处理
    const cleanedValue = value.replace(EMOJI_REGEX, "");
    if (value !== cleanedValue) {
      // 保存光标位置
      const cursorPos = input.selectionStart;
      directive.triggerInput(input, cleanedValue);

      // 恢复光标位置(如果值变短了,光标位置需要调整)
      Vue.nextTick(() => {
        const newPos = Math.min(cursorPos, cleanedValue.length);
        if (input.setSelectionRange) {
          input.setSelectionRange(newPos, newPos);
        }
      });
    }
  },

  // 处理粘贴事件(优化版)
  handlePaste(e, directive) {
    const clipboardData = e.clipboardData || window.clipboardData;
    if (!clipboardData) return;

    const pastedText = clipboardData.getData("text");
    if (!pastedText) return;

    // 快速检测,没有表情符号直接返回,不阻止默认行为
    if (!HAS_EMOJI_REGEX.test(pastedText)) return;

    // 有表情符号才阻止默认行为并处理
    e.preventDefault();

    const cleanedText = pastedText.replace(EMOJI_REGEX, "");
    if (cleanedText === pastedText) return; // 清理后没变化,不应该发生

    const input = e.target;
    const start = input.selectionStart || 0;
    const end = input.selectionEnd || 0;
    const currentValue = input.value || "";

    // 插入处理后的文本
    const newValue =
      currentValue.substring(0, start) +
      cleanedText +
      currentValue.substring(end);
    directive.triggerInput(input, newValue);

    // 设置光标位置
    Vue.nextTick(() => {
      const newPos = start + cleanedText.length;
      if (input.setSelectionRange) {
        input.setSelectionRange(newPos, newPos);
      }
    });
  },

  // 处理组合输入(中文输入法等)
  handleComposition(e, directive) {
    if (e.type === "compositionstart") {
      e.target._isComposing = true;
    } else if (e.type === "compositionend") {
      e.target._isComposing = false;
      // 延迟处理,确保输入已完成
      Vue.nextTick(() => {
        directive.handleInput(e, directive);
      });
    }
  },

  inserted(el, binding) {
    // 直接使用 noEmoji 对象,避免 this 上下文丢失问题

    // 使用 nextTick 确保 Element UI 组件已完全渲染
    Vue.nextTick(() => {
      const input = noEmoji.getInputElement(el);
      if (!input) return;

      // 创建事件处理器(共享方法引用,减少内存占用)
      // 注意:paste 事件不能使用 passive,因为需要 preventDefault
      const handlers = {
        input: function (e) {
          if (e.target._isComposing) return;
          noEmoji.handleInput(e, noEmoji);
        },
        paste: function (e) {
          noEmoji.handlePaste(e, noEmoji);
        },
        compositionstart: function (e) {
          noEmoji.handleComposition(e, noEmoji);
        },
        compositionend: function (e) {
          noEmoji.handleComposition(e, noEmoji);
        },
      };

      // 绑定事件监听器
      // input 事件可以使用 passive,但这里需要修改值,所以不使用
      input.addEventListener("input", handlers.input, false);
      input.addEventListener("paste", handlers.paste, false);
      input.addEventListener(
        "compositionstart",
        handlers.compositionstart,
        false
      );
      input.addEventListener("compositionend", handlers.compositionend, false);

      // 保存事件处理器引用到缓存
      const cache = elementCache.get(el) || {};
      cache.handlers = handlers;
      elementCache.set(el, cache);

      // 初始值检查(只在有值且可能包含表情符号时检查)
      if (input.value && HAS_EMOJI_REGEX.test(input.value)) {
        const cleanedValue = noEmoji.removeEmoji(input.value);
        if (input.value !== cleanedValue) {
          noEmoji.triggerInput(input, cleanedValue);
        }
      }
    });
  },

  update(el, binding) {
    // 优化:只在值可能变化时才检查
    // 使用 noEmoji 对象而不是 this,确保上下文正确
    const input = noEmoji.getInputElement(el);
    if (!input || !input.value) return;

    // 快速检测,没有表情符号直接返回
    if (!HAS_EMOJI_REGEX.test(input.value)) return;

    const cleanedValue = noEmoji.removeEmoji(input.value);
    if (input.value !== cleanedValue) {
      noEmoji.triggerInput(input, cleanedValue);
    }
  },

  unbind(el) {
    // 清理事件监听器和缓存
    const cache = elementCache.get(el);
    if (!cache) return;

    const { input, handlers } = cache;

    if (input && handlers) {
      // 移除所有事件监听器
      input.removeEventListener("input", handlers.input);
      input.removeEventListener("paste", handlers.paste);
      input.removeEventListener("compositionstart", handlers.compositionstart);
      input.removeEventListener("compositionend", handlers.compositionend);
    }

    // 清理 WeakMap 缓存(WeakMap 会自动处理,但显式删除更清晰)
    elementCache.delete(el);
  },
};

export default noEmoji;