Skip to content

Vue 自定义指令详解

在 Vue 开发中,我们经常会用到指令(Directive)来操作 DOM,例如 v-if、v-for、v-model、v-show 等。但除了这些内置指令,Vue 还允许我们 自定义指令(Custom Directives) 来扩展功能。

一、什么是自定义指令?

  • 自定义指令 本质上是对 DOM 的底层封装,可以在 元素绑定、更新、卸载 的不同生命周期中进行操作。

  • 如果一个功能更偏向于 逻辑和 DOM 交互(例如自动聚焦、拖拽、权限控制),就非常适合用指令。

二、指令的基本用法

在 Vue3 中,注册自定义指令有两种方式:

1. 局部注册

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

const vFocus = {
  mounted(el) {
    el.focus()
  }
}
</script>

<template>
  <input v-focus />
</template>

这里我们定义了一个 v-focus 指令,作用是让输入框自动聚焦。

2. 全局注册

ts
import { createApp } from 'vue';
import App from './App.vue';

const app = createApp(App);

// 注册全局指令
app.directive('focus', {
  mounted(el) {
    el.focus();
  },
});

app.mount('#app');

之后,任何组件中都可以直接使用 v-focus。

三、指令的钩子函数

一个完整的指令对象可以包含以下钩子函数:

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

主要参数说明:

  • el:指令绑定到的元素。这可以用于直接操作 DOM。
  • binding:一个对象,包含以下属性。
    • value:传递给指令的值。例如在 v-my-directive="1 + 1" 中,值是 2
    • oldValue:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否更改,它都可用。
    • arg:传递给指令的参数 (如果有的话)。例如在 v-my-directive:foo 中,参数是 "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。
  • prevVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

四、使用参数和修饰符

1. 参数

arg

js
<div v-color:background="'red'">背景红色</div>
<div v-color:color="'blue'">文字蓝色</div>
app.directive('color', {
  mounted(el, binding) {
    el.style[binding.arg] = binding.value
  }
})

2. 修饰符

modifiers

js
<input v-focus.once />
app.directive('focus', {
  mounted(el, binding) {
    if (binding.modifiers.once) {
      el.focus()
    }
  }
})

五、实用案例

1. v-copy(复制粘贴)

  • 实现一键复制文本内容,用于鼠标右键粘贴。

  • 动态创建 textarea 标签,并设置 readOnly 属性及移出可视区域

  • 将要复制的值赋给 textarea 标签的 value 属性,并插入到 body

  • body 中插入的 textarea 移除

  • 在第一次调用时绑定事件,在解绑时移除事件

js
const copy = {
  bind(el, { value }) {
    el.$value = value;
    el.handler = () => {
      if (!el.$value) {
        // 值为空的时候,给出提示。可根据项目UI仔细设计
        console.log('无复制内容');
        return;
      }
      // 动态创建 textarea 标签
      const textarea = document.createElement('textarea');
      // 将该 textarea 设为 readonly 防止 iOS 下自动唤起键盘,同时将 textarea 移出可视区域
      textarea.readOnly = 'readonly';
      textarea.style.position = 'absolute';
      textarea.style.left = '-9999px';
      // 将要 copy 的值赋给 textarea 标签的 value 属性
      textarea.value = el.$value;
      // 将 textarea 插入到 body 中
      document.body.appendChild(textarea);
      // 选中值并复制
      textarea.select();
      const result = document.execCommand('Copy');
      if (result) {
        console.log('复制成功'); // 可根据项目UI仔细设计
      }
      document.body.removeChild(textarea);
    };
    // 绑定点击事件,就是所谓的一键 copy 啦
    el.addEventListener('click', el.handler);
  },
  // 当传进来的值更新的时候触发
  componentUpdated(el, { value }) {
    el.$value = value;
  },
  // 指令与元素解绑的时候,移除事件绑定
  unbind(el) {
    el.removeEventListener('click', el.handler);
  },
};

export default copy;
  • 给 DOM 加上 v-copy 及复制的文本即可
js
<template>
  <button v-copy="copyText">复制</button>
</template>

<script> export default {
    data() {
      return {
        copyText: 'a copy directives',
      }
    },
  } </script>

2.v-longpress(长按事件)

  • 实现长按,用户需要按下并按住按钮几秒钟,触发相应的事件
  • 创建一个计时器, 2 秒后执行函数
  • 当用户按下按钮时触发 mousedown 事件,启动计时器;用户松开按钮时调用 mouseout 事件。
  • 如果 mouseup 事件 2 秒内被触发,就清除计时器,当作一个普通的点击事件
  • 如果计时器没有在 2 秒内清除,则判定为一次长按,可以执行关联的函数。
  • 在移动端要考虑 touchstarttouchend 事件
js
const longpress = {
  bind: function (el, binding, vNode) {
    if (typeof binding.value !== 'function') {
      throw 'callback must be a function';
    }
    // 定义变量
    let pressTimer = null;
    // 创建计时器( 2秒后执行函数 )
    let start = (e) => {
      if (e.type === 'click' && e.button !== 0) {
        return;
      }
      if (pressTimer === null) {
        pressTimer = setTimeout(() => {
          handler();
        }, 2000);
      }
    };
    // 取消计时器
    let cancel = (e) => {
      if (pressTimer !== null) {
        clearTimeout(pressTimer);
        pressTimer = null;
      }
    };
    // 运行函数
    const handler = (e) => {
      binding.value(e);
    };
    // 添加事件监听器
    el.addEventListener('mousedown', start);
    el.addEventListener('touchstart', start);
    // 取消计时器
    el.addEventListener('click', cancel);
    el.addEventListener('mouseout', cancel);
    el.addEventListener('touchend', cancel);
    el.addEventListener('touchcancel', cancel);
  },
  // 当传进来的值更新的时候触发
  componentUpdated(el, { value }) {
    el.$value = value;
  },
  // 指令与元素解绑的时候,移除事件绑定
  unbind(el) {
    el.removeEventListener('click', el.handler);
  },
};

export default longpress;
js
<template>
  <button v-longpress="longpress">长按</button>
</template>

<script> export default {
  methods: {
    longpress () {
      alert('长按指令生效')
    }
  }
} </script>

3.v-debounce(防抖)

  • 防止按钮在短时间内被多次点击,使用防抖函数限制规定时间内只能点击一次。
  • 定义一个延迟执行的方法,如果在延迟时间内再调用该方法,则重新计算执行时间。
  • 将时间绑定在 click 方法上。
js
const debounce = {
  inserted: function (el, binding) {
    let timer;
    el.addEventListener('keyup', () => {
      if (timer) {
        clearTimeout(timer);
      }
      timer = setTimeout(() => {
        binding.value();
      }, 1000);
    });
  },
};

export default debounce;
js
<template>
  <button v-debounce="debounceClick">防抖</button>
</template>

<script> export default {
  methods: {
    debounceClick () {
      console.log('只触发一次')
    }
  }
} </script>

4.v-LazyLoad(懒加载)

  • 实现一个图片懒加载指令,只加载浏览器可见区域的图片。
  • 图片懒加载的原理主要是判断当前图片是否到了可视区域这一核心逻辑实现的
  • 拿到所有的图片 Dom ,遍历每个图片判断当前图片是否到了可视区范围内
  • 如果到了就设置图片的 src 属性,否则显示默认图片

图片懒加载有两种方式可以实现,一是绑定 srcoll 事件进行监听,二是使用 IntersectionObserver 判断图片是否到了可视区域,但是有浏览器兼容性问题。

下面封装一个懒加载指令兼容两种方法,判断浏览器是否支持 IntersectionObserver API,如果支持就使用 IntersectionObserver 实现懒加载,否则则使用 srcoll 事件监听 + 节流的方法实现。

js
const LazyLoad = {
  // install方法
  install(Vue, options) {
    const defaultSrc = options.default;
    Vue.directive('lazy', {
      bind(el, binding) {
        LazyLoad.init(el, binding.value, defaultSrc);
      },
      inserted(el) {
        if (IntersectionObserver) {
          LazyLoad.observe(el);
        } else {
          LazyLoad.listenerScroll(el);
        }
      },
    });
  },
  // 初始化
  init(el, val, def) {
    el.setAttribute('data-src', val);
    el.setAttribute('src', def);
  },
  // 利用IntersectionObserver监听el
  observe(el) {
    var io = new IntersectionObserver((entries) => {
      const realSrc = el.dataset.src;
      if (entries[0].isIntersecting) {
        if (realSrc) {
          el.src = realSrc;
          el.removeAttribute('data-src');
        }
      }
    });
    io.observe(el);
  },
  // 监听scroll事件
  listenerScroll(el) {
    const handler = LazyLoad.throttle(LazyLoad.load, 300);
    LazyLoad.load(el);
    window.addEventListener('scroll', () => {
      handler(el);
    });
  },
  // 加载真实图片
  load(el) {
    const windowHeight = document.documentElement.clientHeight;
    const elTop = el.getBoundingClientRect().top;
    const elBtm = el.getBoundingClientRect().bottom;
    const realSrc = el.dataset.src;
    if (elTop - windowHeight < 0 && elBtm > 0) {
      if (realSrc) {
        el.src = realSrc;
        el.removeAttribute('data-src');
      }
    }
  },
  // 节流
  throttle(fn, delay) {
    let timer;
    let prevTime;
    return function (...args) {
      const currTime = Date.now();
      const context = this;
      if (!prevTime) prevTime = currTime;
      clearTimeout(timer);

      if (currTime - prevTime > delay) {
        prevTime = currTime;
        fn.apply(context, args);
        clearTimeout(timer);
        return;
      }

      timer = setTimeout(function () {
        prevTime = Date.now();
        timer = null;
        fn.apply(context, args);
      }, delay);
    };
  },
};

export default LazyLoad;
js
<img v-LazyLoad="xxx.jpg" />

Released under the MIT License.