你每天写v-model@click,但有没有想过:Vue 是如何把事件、数据“塞”进 DOM 的?

其实,自定义指令正是理解这套机制的钥匙。

本文将带你从最熟悉的@click 出发,一步步揭开指令的神秘面纱,最终写出专业、可复用的指令。


一、先从你熟悉的@click 说起

1.1 事件背后的逻辑

当你在模板中写下:

<button @click="handleClick">点击</button>

Vue 会自动把原生事件对象(如 MouseEvent作为第一个参数传给handleClick 函数:

methods: {
  handleClick(event) {
    console.log(event.type); // "click"
  }
}

如果你还需要传递额外参数(比如当前项的 ID),就用$event 显式传入:

<button @click="handleClick($event, itemId)">点击</button>

1.2 修饰符是怎么工作的?

Vue 提供了一系列事件修饰符,让常见 DOM 操作更简洁:

  • .stop → 自动调用event.stopPropagation()
  • .prevent → 自动调用event.preventDefault()
  • .once → 事件只触发一次
  • .self → 仅当点击元素自身时触发

这些修饰符本质是 Vue 在调用你的函数之前,先帮你执行了对应的 DOM 方法。

💡 小结:事件处理 = 回调函数 + 修饰符(预处理)。这套“参数 + 修饰符”模型,正是自定义指令的雏形!

二、什么是自定义指令?为什么要用它?

2.1 指令是什么?

指令是以v- 开头的特殊 HTML 属性,用于增强 DOM 元素的行为
Vue 内置指令如v-ifv-forv-model,而自定义指令就是你自己写的v-my-dir

2.2 什么时候该用指令?(附决策表)

指令不是万能,最适合纯 DOM 操作、行为复用、不改变 UI 结构的场景。

三、动手写一个指令:从注册到使用

3.1 基本语法

自定义指令支持多种写法:

<!-- 无参 -->
<div v-my-dir></div>
<!-- 带值 -->
<div v-my-dir="message"></div>
<!-- 带参数 -->
<div v-my-dir:color="red"></div>
<!-- 带修饰符 -->
<div v-my-dir.once="doSomething"></div>
<!-- 组合使用 -->
<div v-my-dir:userId.hide="user.id"></div>

3.2 如何注册

全局注册(适用于整个应用):

// Vue 2
Vue.directive('myDir', { /* 钩子函数 */ });
// Vue 3
app.directive('myDir', { /* 钩子函数 */ });

局部注册(仅当前组件可用):

export default {
  directives: {
    myDir: { /* 钩子函数 */ }
  }
}

四、指令的核心:binding 对象详解

在指令的每个钩子函数中,第二个参数binding 提供了完整的上下文信息

💡 提示value 是最常用字段;modifiers 让指令行为更灵活(如.hide.disable)。

五、指令的生命周期:何时执行

指令有自己的生命周期,在不同阶段触发生命周期钩子

5.1 Vue 2 与 Vue 3 生命周期对照

5.2 关键钩子使用场景

  • mounted(Vue 3)/ inserted(Vue 2):元素已插入 DOM,可安全获取尺寸、位置。
  • updated:绑定值变化时调用,记得比对oldValue 避免无效更新。
  • beforeUnmount:务必在这里清理事件、定时器,防止内存泄漏!

六、实战:几个高频指令案例

学完理论,来看看真实项目中怎么用!

每个案例都体现了:

  • 何时触发(生命周期)
  • 如何读参binding.value,binding.arg
  • 如何操作 DOM

七、高级技巧:动态参数与修饰符组合

7.1 动态参数

你可以用响应式数据作为指令参数:

<div v-my-dir:[dynamicKey]="value"></div>

此时binding.arg 的值会随dynamicKey 变化,非常灵活!

7.2 自定义修饰符

通过binding.modifiers 读取修饰符,实现开关式行为:

// v-permission:edit.hide="['admin']"
const { value, arg, modifiers } = binding
if (!hasPermission) {
  if (modifiers.hide) el.style.display = 'none'
  if (modifiers.disable) el.disabled = true
}

八、最佳实践 & 注意事项

8.1 编写高质量指令的 5 条原则

  1. 专注 DOM 操作,不处理业务逻辑。
  2. 务必清理副作用(事件、定时器),在beforeUnmount 中执行。
  3. 避免频繁操作 DOM,在updated 中做value!==oldValue 判断。
  4. 命名清晰直观(如v-copy,v-focus),便于团队理解。
  5. 提供 TypeScript 类型(如DirectiveBinding),提升开发体验。

8.2 常见误区

  • 在指令中管理复杂状态 → 改用组件。
  • 忽略beforeUnmount 清理 → 内存泄漏。
  • 过度使用指令替代 CSS → 能用 CSS 解决的,别用 JS。

九、总结

现在,你不仅能看懂v-permission 的实现原理,还能自己写出:

  • 防抖按钮v-debounce
  • 自动聚焦v-focus
  • 懒加载图片v-lazy

记住:指令是 Vue 的“DOM  增强器”,用好它,代码更简洁、复用性更高!
 

十、附录:高频自定义指令完整实现代码

为了让你能快速上手,以下是前文提到的 7 个常用指令的完整、可运行的核心代码,均已适配 Vue 3(如需 Vue 2 版本,主要差异在生命周期钩子名称)。所有代码均可直接复制到项目中使用。


1. 权限控制指令v-permission

// directives/permission.js
export default {
  mounted(el, binding) {
    const { value, modifiers } = binding
    // 请替换为实际的权限获取逻辑(如从 Pinia/Vuex 或 API 获取)
    const userRoles = JSON.parse(localStorage.getItem('ROLES') || '[]')
    const hasPermission = Array.isArray(value)
      ? value.some((role) => userRoles.includes(role))
      : userRoles.includes(value)
    if (!hasPermission) {
      if (modifiers.hide) {
        el.style.display = 'none'
      } else if (modifiers.disable) {
        el.disabled = true
        el.style.opacity = '0.6'
        el.style.pointerEvents = 'none'
      } else {
        el.parentNode?.removeChild(el)
      }
    }
  },
}

2. 防抖点击指令v-debounce

// directives/debounce.js
export default {
  beforeMount(el, binding) {
    let timer = null;
    const delay = parseInt(binding.arg) || 300;

    el._debounceHandler = () => {
      if (timer) clearTimeout(timer);
      timer = setTimeout(() => {
        if (typeof binding.value === 'function') {
          binding.value();
        }
      }, delay);
    };

    el.addEventListener('click', el._debounceHandler);
  },
  beforeUnmount(el) {
    el.removeEventListener('click', el._debounceHandler);
    if (el._debounceHandler) {
      clearTimeout(el._debounceHandler);
    }
  }
};

3. 自动聚焦指令v-focus


// directives/focus.js
export default {
  mounted(el) {
    // 确保元素是可聚焦的输入控件
    if (el.focus && ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)) {
      el.focus();
    }
  }
};

4. 图片懒加载指令v-lazy


// directives/lazy.js
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.remove('lazy-loading');
      observer.unobserve(img);
    }
  });
}, { threshold: 0.01 });

export default {
  mounted(el, binding) {
    el.dataset.src = binding.value;
    el.classList.add('lazy-loading');
    observer.observe(el);
  },
  beforeUnmount(el) {
    observer.unobserve(el);
  }
};

5. 复制文本指令v-copy

// directives/copy.js
export default {
  mounted(el, binding) {
    el._copyValue = binding.value;
    el.style.cursor = 'pointer';

    el._copyHandler = async () => {
      try {
        await navigator.clipboard.writeText(String(el._copyValue));
        // 可选:调用全局提示
        // ElMessage.success('已复制到剪贴板');
      } catch (err) {
        console.warn('复制失败,请手动复制', err);
      }
    };

    el.addEventListener('click', el._copyHandler);
  },
  updated(el, binding) {
    el._copyValue = binding.value;
  },
  beforeUnmount(el) {
    el.removeEventListener('click', el._copyHandler);
  }
};

6. 水印指令v-watermark

// directives/watermark.js
export default {
  mounted(el, binding) {
    const text = binding.value || '内部资料';
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');

    // 设置水印密度
    canvas.width = 240;
    canvas.height = 160;

    ctx.font = '18px sans-serif';
    ctx.fillStyle = 'rgba(0, 0, 0, 0.12)';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.rotate((-20 * Math.PI) / 180); // 旋转 -20 度
    ctx.fillText(text, canvas.width / 2, canvas.height / 2);

    el.style.backgroundImage = `url(${canvas.toDataURL('image/png')})`;
    el.style.backgroundRepeat = 'repeat';
  }
};

7. 拖拽指令v-draggable

// directives/draggable.js
export default {
  mounted(el) {
    el.style.cursor = 'move';
    el.style.position = 'absolute';
    el.style.userSelect = 'none'; // 禁止选中文字

    const handleDrag = (e) => {
      const disX = e.clientX - el.offsetLeft;
      const disY = e.clientY - el.offsetTop;

      const move = (e) => {
        el.style.left = e.clientX - disX + 'px';
        el.style.top = e.clientY - disY + 'px';
      };

      const up = () => {
        document.removeEventListener('mousemove', move);
        document.removeEventListener('mouseup', up);
      };

      document.addEventListener('mousemove', move);
      document.addEventListener('mouseup', up);
    };

    el.addEventListener('mousedown', handleDrag);
  }
};

💡 使用提示

  • 将上述文件放入src/directives/ 目录;
  • main.js 中全局注册(见前文);
  • 所有指令均支持 TypeScript,可为binding 添加类型DirectiveBinding 提升开发体验。

现在,你已拥有一个可直接投入生产的指令工具箱!快去优化你的项目吧!

 

原文链接:https://mp.weixin.qq.com/s/J8vPuPXgINwQ2b4sz9XBmw