Skip to content

Vue 自定义指令完全指南

概述

Vue 指令是带有 v- 前缀的特殊属性。除了内置指令(v-if、v-for、v-model 等),Vue 还允许注册自定义指令,用于对 DOM 元素进行底层操作。

指令基础

注册方式

javascript
// 全局注册 - Vue 3
const app = createApp(App);
app.directive('focus', {
  mounted(el) {
    el.focus();
  }
});

// 局部注册 - Vue 3 选项式
export default {
  directives: {
    focus: {
      mounted(el) {
        el.focus();
      }
    }
  }
}

// 局部注册 - Vue 3 组合式 <script setup>
const vFocus = {
  mounted: (el) => el.focus()
};
// 使用时:<input v-focus />

函数简写

javascript
// 当只需要在 mounted 和 updated 触发相同行为时
app.directive('color', (el, binding) => {
  el.style.color = binding.value;
});

// 等价于
app.directive('color', {
  mounted(el, binding) {
    el.style.color = binding.value;
  },
  updated(el, binding) {
    el.style.color = binding.value;
  }
});

指令钩子函数

Vue 3 钩子

javascript
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) {}
};

Vue 2 vs Vue 3 钩子对比

Vue 2Vue 3说明
bindbeforeMount指令首次绑定到元素
insertedmounted元素插入父节点
updatebeforeUpdate + updated组件更新
componentUpdatedupdated组件及子组件更新完成
unbindunmounted指令与元素解绑
-created新增:属性绑定前
-beforeUnmount新增:卸载前

钩子参数

binding 对象

javascript
app.directive('example', {
  mounted(el, binding, vnode, prevVnode) {
    console.log(binding);
  }
});

// binding 对象包含:
{
  value: any,        // 指令绑定的值,如 v-my="1+1" 则为 2
  oldValue: any,     // 之前的值,仅在 beforeUpdate 和 updated 中可用
  arg: string,       // 传给指令的参数,如 v-my:foo 则为 "foo"
  modifiers: object, // 修饰符对象,如 v-my.a.b 则为 { a: true, b: true }
  instance: object,  // 使用该指令的组件实例
  dir: object        // 指令定义对象
}

使用示例

html
<div v-example:arg.modifier1.modifier2="value"></div>
javascript
app.directive('example', {
  mounted(el, binding) {
    // binding.arg = 'arg'
    // binding.modifiers = { modifier1: true, modifier2: true }
    // binding.value = value 的值
  }
});

实战指令示例

1. v-focus(自动聚焦)

javascript
// 自动聚焦指令
const vFocus = {
  mounted(el, binding) {
    // 支持条件聚焦
    if (binding.value !== false) {
      el.focus();
    }
  },
  updated(el, binding) {
    if (binding.value && !binding.oldValue) {
      el.focus();
    }
  }
};

// 使用
// <input v-focus />
// <input v-focus="shouldFocus" />

2. v-click-outside(点击外部)

javascript
const vClickOutside = {
  mounted(el, binding) {
    el._clickOutside = (event) => {
      // 判断点击是否在元素外部
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event);
      }
    };
    document.addEventListener('click', el._clickOutside);
  },
  unmounted(el) {
    document.removeEventListener('click', el._clickOutside);
    delete el._clickOutside;
  }
};

// 使用
// <div v-click-outside="handleClickOutside">...</div>

3. v-debounce(防抖)

javascript
const vDebounce = {
  mounted(el, binding) {
    const { value, arg = 'click' } = binding;
    const delay = binding.modifiers.delay || 300;

    let timer = null;

    el._debounceHandler = () => {
      clearTimeout(timer);
      timer = setTimeout(() => {
        value();
      }, delay);
    };

    el.addEventListener(arg, el._debounceHandler);
  },
  unmounted(el, binding) {
    const { arg = 'click' } = binding;
    el.removeEventListener(arg, el._debounceHandler);
    delete el._debounceHandler;
  }
};

// 使用
// <button v-debounce="handleClick">点击</button>
// <input v-debounce:input="handleInput" />

4. v-throttle(节流)

javascript
const vThrottle = {
  mounted(el, binding) {
    const { value, arg = 'click' } = binding;
    const delay = binding.modifiers.delay || 300;

    let lastTime = 0;

    el._throttleHandler = () => {
      const now = Date.now();
      if (now - lastTime >= delay) {
        lastTime = now;
        value();
      }
    };

    el.addEventListener(arg, el._throttleHandler);
  },
  unmounted(el, binding) {
    const { arg = 'click' } = binding;
    el.removeEventListener(arg, el._throttleHandler);
    delete el._throttleHandler;
  }
};

// 使用
// <button v-throttle="handleClick">点击</button>
// <div v-throttle:scroll="handleScroll">...</div>

5. v-lazy(图片懒加载)

javascript
const vLazy = {
  mounted(el, binding) {
    const defaultImg = binding.arg || '/placeholder.png';

    // 设置默认图片
    el.src = defaultImg;

    // 使用 IntersectionObserver
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        el.src = binding.value;
        el.onerror = () => {
          el.src = defaultImg;
        };
        observer.unobserve(el);
      }
    }, {
      rootMargin: '100px'
    });

    el._lazyObserver = observer;
    observer.observe(el);
  },
  unmounted(el) {
    el._lazyObserver?.disconnect();
    delete el._lazyObserver;
  }
};

// 使用
// <img v-lazy="imageUrl" />
// <img v-lazy:"/error.png"="imageUrl" />

6. v-loading(加载状态)

javascript
const vLoading = {
  mounted(el, binding) {
    // 创建加载遮罩
    const mask = document.createElement('div');
    mask.className = 'loading-mask';
    mask.innerHTML = `
      <div class="loading-spinner">
        <div class="spinner"></div>
        <span>加载中...</span>
      </div>
    `;

    el._loadingMask = mask;
    el.style.position = 'relative';

    if (binding.value) {
      el.appendChild(mask);
    }
  },
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      if (binding.value) {
        el.appendChild(el._loadingMask);
      } else {
        el._loadingMask.remove();
      }
    }
  },
  unmounted(el) {
    el._loadingMask?.remove();
    delete el._loadingMask;
  }
};

// 使用
// <div v-loading="isLoading">...</div>

7. v-permission(权限控制)

javascript
const vPermission = {
  mounted(el, binding) {
    const { value } = binding;
    // 从 store 或其他地方获取用户权限
    const userPermissions = store.getters.permissions;

    // value 可以是字符串或数组
    const requiredPermissions = Array.isArray(value) ? value : [value];

    // 检查权限
    const hasPermission = requiredPermissions.some(
      permission => userPermissions.includes(permission)
    );

    if (!hasPermission) {
      // 无权限时移除元素
      el.parentNode?.removeChild(el);
    }
  }
};

// 使用
// <button v-permission="'admin'">管理员按钮</button>
// <button v-permission="['admin', 'editor']">编辑按钮</button>

8. v-copy(复制文本)

javascript
const vCopy = {
  mounted(el, binding) {
    el._copyHandler = async () => {
      const text = binding.value || el.innerText;

      try {
        await navigator.clipboard.writeText(text);
        // 成功回调
        binding.arg?.success?.();
      } catch (err) {
        // 失败回调
        binding.arg?.error?.(err);
      }
    };

    el.addEventListener('click', el._copyHandler);
  },
  unmounted(el) {
    el.removeEventListener('click', el._copyHandler);
    delete el._copyHandler;
  }
};

// 使用
// <button v-copy="'复制的文本'">复制</button>
// <button v-copy="textToCopy">复制</button>

9. v-tooltip(提示框)

javascript
const vTooltip = {
  mounted(el, binding) {
    const tooltip = document.createElement('div');
    tooltip.className = 'tooltip';
    tooltip.textContent = binding.value;

    // 定位
    const position = binding.arg || 'top';
    tooltip.classList.add(`tooltip-${position}`);

    el._tooltip = tooltip;

    el.addEventListener('mouseenter', () => {
      document.body.appendChild(tooltip);

      // 计算位置
      const rect = el.getBoundingClientRect();
      const tooltipRect = tooltip.getBoundingClientRect();

      let top, left;
      switch (position) {
        case 'top':
          top = rect.top - tooltipRect.height - 8;
          left = rect.left + (rect.width - tooltipRect.width) / 2;
          break;
        case 'bottom':
          top = rect.bottom + 8;
          left = rect.left + (rect.width - tooltipRect.width) / 2;
          break;
        // ... 其他位置
      }

      tooltip.style.top = `${top}px`;
      tooltip.style.left = `${left}px`;
    });

    el.addEventListener('mouseleave', () => {
      tooltip.remove();
    });
  },
  updated(el, binding) {
    el._tooltip.textContent = binding.value;
  },
  unmounted(el) {
    el._tooltip?.remove();
    delete el._tooltip;
  }
};

// 使用
// <button v-tooltip="'提示文本'">悬停</button>
// <button v-tooltip:bottom="'底部提示'">悬停</button>

10. v-longpress(长按)

javascript
const vLongpress = {
  mounted(el, binding) {
    const duration = binding.arg || 1000;
    let timer = null;

    const start = (e) => {
      if (e.type === 'click' && e.button !== 0) return;

      timer = setTimeout(() => {
        binding.value(e);
      }, duration);
    };

    const cancel = () => {
      clearTimeout(timer);
      timer = null;
    };

    // 支持触屏和鼠标
    el.addEventListener('mousedown', start);
    el.addEventListener('touchstart', start);
    el.addEventListener('mouseup', cancel);
    el.addEventListener('touchend', cancel);
    el.addEventListener('touchcancel', cancel);
    el.addEventListener('mouseleave', cancel);

    el._longpressHandlers = { start, cancel };
  },
  unmounted(el) {
    const { start, cancel } = el._longpressHandlers;
    el.removeEventListener('mousedown', start);
    el.removeEventListener('touchstart', start);
    el.removeEventListener('mouseup', cancel);
    el.removeEventListener('touchend', cancel);
    el.removeEventListener('touchcancel', cancel);
    el.removeEventListener('mouseleave', cancel);
    delete el._longpressHandlers;
  }
};

// 使用
// <button v-longpress="handleLongPress">长按</button>
// <button v-longpress:2000="handleLongPress">长按2秒</button>

指令工厂模式

创建可配置指令

javascript
// 指令工厂函数
function createPermissionDirective(options = {}) {
  const { getPermissions, action = 'remove' } = options;

  return {
    mounted(el, binding) {
      const permissions = getPermissions();
      const required = Array.isArray(binding.value) ? binding.value : [binding.value];

      const hasPermission = required.some(p => permissions.includes(p));

      if (!hasPermission) {
        switch (action) {
          case 'remove':
            el.parentNode?.removeChild(el);
            break;
          case 'disable':
            el.disabled = true;
            el.style.opacity = '0.5';
            break;
          case 'hide':
            el.style.display = 'none';
            break;
        }
      }
    }
  };
}

// 使用
app.directive('permission', createPermissionDirective({
  getPermissions: () => store.state.user.permissions,
  action: 'disable'
}));

指令与组合式 API

在 setup 中使用指令

vue
<script setup>
import { ref } from 'vue';

// 以 v 开头的变量会自动注册为指令
const vHighlight = {
  mounted(el, binding) {
    el.style.backgroundColor = binding.value || 'yellow';
  },
  updated(el, binding) {
    el.style.backgroundColor = binding.value || 'yellow';
  }
};

const color = ref('lightblue');
</script>

<template>
  <p v-highlight="color">高亮文本</p>
  <button @click="color = 'pink'">改变颜色</button>
</template>

封装为组合式函数

javascript
// useDirective.js
import { onMounted, onUnmounted, ref } from 'vue';

export function useClickOutside(elementRef, callback) {
  const handler = (event) => {
    if (elementRef.value && !elementRef.value.contains(event.target)) {
      callback(event);
    }
  };

  onMounted(() => {
    document.addEventListener('click', handler);
  });

  onUnmounted(() => {
    document.removeEventListener('click', handler);
  });
}

// 使用
const menuRef = ref(null);
useClickOutside(menuRef, () => {
  closeMenu();
});

面试常见问题

1. 自定义指令的使用场景?

  • DOM 操作:聚焦、滚动、动画
  • 事件处理:点击外部、长按、防抖节流
  • 权限控制:按钮权限、页面权限
  • 图片懒加载
  • 表单验证
  • 复制文本

2. Vue 2 和 Vue 3 指令钩子的区别?

Vue 3 重命名了钩子函数,使其与组件生命周期更一致:

  • bind → beforeMount
  • inserted → mounted
  • unbind → unmounted
  • 新增 created 和 beforeUnmount

3. 指令和组件的选择?

javascript
// 使用指令的情况
// - 需要直接操作 DOM
// - 简单的 DOM 增强
// - 不需要模板或状态管理

// 使用组件的情况
// - 需要模板
// - 需要响应式状态
// - 需要生命周期管理
// - 功能复杂

4. 如何在指令中访问组件实例?

javascript
app.directive('example', {
  mounted(el, binding) {
    // 通过 binding.instance 访问组件实例
    const instance = binding.instance;
    console.log(instance.$props);
    console.log(instance.$data);
  }
});

5. 指令钩子中如何清理副作用?

javascript
app.directive('example', {
  mounted(el, binding) {
    // 将需要清理的引用存储在元素上
    el._handler = () => { /* ... */ };
    el._observer = new IntersectionObserver(/* ... */);

    document.addEventListener('click', el._handler);
  },
  unmounted(el) {
    // 在 unmounted 中清理
    document.removeEventListener('click', el._handler);
    el._observer?.disconnect();

    delete el._handler;
    delete el._observer;
  }
});

最佳实践

1. 命名规范

javascript
// 全局指令使用 kebab-case
app.directive('click-outside', { /* ... */ });

// 局部指令使用 camelCase
const vClickOutside = { /* ... */ };

2. 清理副作用

javascript
// 始终在 unmounted 中清理事件监听器、定时器、观察者等
{
  mounted(el) {
    el._cleanup = [];
    const handler = () => {};
    document.addEventListener('click', handler);
    el._cleanup.push(() => document.removeEventListener('click', handler));
  },
  unmounted(el) {
    el._cleanup.forEach(fn => fn());
  }
}

3. 处理动态值

javascript
// 使用 updated 钩子处理值变化
{
  mounted(el, binding) {
    applyEffect(el, binding.value);
  },
  updated(el, binding) {
    if (binding.value !== binding.oldValue) {
      applyEffect(el, binding.value);
    }
  }
}

4. 类型支持

typescript
// TypeScript 类型定义
import type { Directive, DirectiveBinding } from 'vue';

interface PermissionBinding extends DirectiveBinding {
  value: string | string[];
}

const vPermission: Directive<HTMLElement, string | string[]> = {
  mounted(el, binding: PermissionBinding) {
    // ...
  }
};

总结

Vue 自定义指令是处理 DOM 操作的强大工具:

  1. 使用场景:直接 DOM 操作、事件处理、权限控制等
  2. 钩子函数:遵循组件生命周期,Vue 3 重命名更直观
  3. 参数绑定:支持参数、修饰符、动态值
  4. 清理机制:在 unmounted 中清理副作用
  5. 与组件对比:简单 DOM 操作用指令,复杂功能用组件

掌握自定义指令能够优雅地解决很多 DOM 操作需求,是 Vue 开发的重要技能。