Code前端首页关于Code前端联系我们

Vue3自定义指令用在哪?怎么比组件更轻量高效?新手也能快速上手吗?

terry 13小时前 阅读数 357 #Vue

先别急着学,得搞懂Vue3自定义指令的「正确打开场景」

很多人学Vue3,要么跳过directive直奔Composition API和组件库,要么学了点皮毛就到处乱用,反而把代码搞乱了,其实Vue官方文档里明确提过:自定义指令主要是用来封装纯DOM操作逻辑的,不能替代组件的职责——组件管「视图结构复用」「业务逻辑关联」,指令管「和DOM节点强绑定、通用的、小粒度的交互或效果」。

举几个你天天写代码都可能遇到但没意识到能靠指令解决的场景:

  1. 表单输入自动聚焦:虽然原生input有autofocus属性,但它只在页面首次加载时生效,像弹窗打开、条件渲染后的input,autofocus就没用了,这时候写一个v-focus指令,比每次写onMounted获取DOM聚焦要简洁10倍。
  2. 图片懒加载+占位图+错误降级:很多UI组件库有图片组件,但如果你项目里没用大库,或者只需要懒加载几个核心位置的图,单独写个组件太冗余,v-lazy就是完美选择。
  3. 文本溢出省略+鼠标悬停显示完整内容:同样,UI库有tooltip,但如果只是想给表格里的某几列文本加这个功能,不需要复杂的tooltip样式,v-ellipsis-tooltip就能搞定。
  4. 无限滚动:监听scroll事件是个力气活,每次都要写节流、判断距离底部高度、执行回调,封装成v-infinite-scroll,一个指令绑定容器就能跑。
  5. 权限控制DOM显示隐藏/禁用:比如管理员才能看到的「删除按钮」,普通用户只能看不能点的「提交按钮」,用v-permission比v-if判断一堆权限码要直观,而且权限逻辑统一管理,改起来方便。

要是反过来,把带复杂视图结构的功能(比如一个下拉菜单、一个弹窗)封装成指令,那就完全走偏了——指令没法方便地传递插槽、复杂props,也没法和Vue的响应式系统深度结合,最后只会写出一堆难维护的代码。


为什么说Vue3自定义指令比「原生JS操作」「轻量组件」更轻量高效?

(一)比原生JS操作:不用手动管理DOM的生命周期

原生JS操作DOM,最头疼的就是生命周期绑定:比如你写了一个无限滚动的监听函数,当组件销毁的时候,必须手动移除这个事件监听器,不然就会内存泄漏;条件渲染的input,每次渲染都要重新写focus的代码。

但Vue3的自定义指令自带钩子函数,完全贴合Vue组件的生命周期,能自动帮你处理这些麻烦事,举个简单的v-focus指令的钩子对比:

  • 原生JS实现条件渲染input自动聚焦:要写在父组件的onMounted和onUpdated里,还要用ref获取DOM,代码散、重复率高;
  • Vue3 v-focus指令实现:只需要在directive的mounted钩子(组件挂载后)和updated钩子(组件更新后,但注意只有绑定的值或者指令的参数变化才会触发,Vue3还有个更细的钩子叫componentUpdated,看后面的API介绍)里写一行el.focus()就行,Vue会自动在合适的时机调用,不用你管什么时候销毁、什么时候重新获取。

(二)比轻量组件:减少虚拟DOM的开销

不管是组件还是原生HTML标签,在Vue里都会先生成虚拟DOM,然后再比对、渲染到真实DOM上,轻量组件虽然代码不多,但也会多生成一层虚拟DOM节点——比如把文本溢出省略做成一个轻量组件,可能生成的真实DOM是<div class="ellipsis-wrapper"><span>文本</span></div>,但如果做成v-ellipsis指令,直接在原来的<span>标签上绑定就行,真实DOM还是一层,虚拟DOM也少了一层。

别小看这一层虚拟DOM的开销,如果你项目里有几百上千个表格单元格都用这个组件,那虚拟DOM的比对、渲染速度就会明显变慢;但用指令的话,几乎不会增加额外的开销。

(三)复用性和可维护性更高

原生JS的逻辑散在各个组件里,改一个效果要找遍所有用的地方;轻量组件虽然复用性好,但如果逻辑太细,组件库就会变得很臃肿,找起来也麻烦。

自定义指令可以全局注册,也可以局部注册,逻辑统一放在一个文件里,改起来只要动一处,而且指令的参数、修饰符、绑定值都可以灵活配置,比如v-permission可以传['admin', 'editor']数组,也可以传单个'admin'字符串,还可以加.disable修饰符表示禁用而不是隐藏,比组件的props配置更轻量、更直观。


新手别怕!Vue3自定义指令的核心API超简单,5分钟就能写一个能用的

Vue3的自定义指令API和Vue2有一些区别,但比Vue2更简洁、更贴合Composition API的风格,核心就是5个钩子函数和4个钩子参数,我们一个一个说,最后再写3个实用的指令示例,保证你看完就能动手。

(一)核心钩子函数:别死记硬背,理解生命周期就行

Vue3的自定义指令钩子函数和组件的生命周期钩子是一一对应的,甚至名字都差不多,记起来很容易:

  1. created:在绑定元素的属性或事件监听器被应用之前调用,这个钩子用得不多,一般用来做一些不需要DOM的初始化工作。
  2. beforeMount:在绑定元素被挂载到DOM之前调用,这个钩子也用得少,因为还没法操作真实DOM。
  3. mounted:在绑定元素被挂载到DOM之后调用,这个钩子是最常用的,比如v-focus的聚焦、v-lazy的初始化占位图、v-infinite-scroll的绑定scroll事件,都可以写在这里。
  4. beforeUpdate:在包含绑定元素的组件更新之前调用,但注意不会在绑定元素自身的属性更新时调用(除非绑定的值或参数变了),这个钩子用得也不多。
  5. updated:在包含绑定元素的组件更新之后调用,同样要配合绑定值或参数变化,Vue3还新增了一个componentUpdated钩子,区别不大?其实componentUpdated是在包含绑定元素的VNode及其子VNode更新之后调用,updated是在包含绑定元素的VNode更新之后、子VNode更新之前调用——不过一般新手用componentUpdated就行,不容易出错。
  6. beforeUnmount:在绑定元素被卸载之前调用,这个钩子也很常用,比如v-infinite-scroll的移除scroll事件监听器、v-lazy的取消图片预加载,都可以写在这里,防止内存泄漏。
  7. unmounted:在绑定元素被卸载之后调用,这个钩子用得很少,因为beforeUnmount已经能处理大部分清理工作了。

(二)核心钩子参数:看懂这4个,指令就能灵活配置了

每个钩子函数都接收4个参数,按顺序是:

  1. el:绑定指令的真实DOM元素,直接用原生JS操作它就行,比如el.style.color = 'red'。
  2. binding:一个对象,包含指令的所有信息,这是最核心的参数:
    • name:指令的名字,不带v-前缀,比如v-focus的name就是'focus'。
    • value:指令的绑定值,比如v-permission="['admin']"的value就是['admin']数组,v-ellipsis:2="true"的value就是true布尔值。
    • oldValue:指令的上一个绑定值,只有beforeUpdate和updated/componentUpdated钩子才有,用来判断绑定值有没有变化。
    • arg:指令的参数,比如v-ellipsis:2的arg就是'2'(字符串哦,记得转成数字),v-on:click的arg就是'click'。
    • modifiers:一个对象,包含指令的所有修饰符,比如v-permission.disable的modifiers就是{ disable: true },v-on.stop.prevent的modifiers就是{ stop: true, prevent: true }。
    • instance:使用该指令的组件实例,Vue3里这个是Proxy对象,新手尽量少直接操作它,除非你真的知道自己在做什么。
    • dir:指令定义对象本身,新手也用得少。
  3. vnode:绑定元素对应的虚拟DOM节点,新手一般不用。
  4. prevVnode:上一个虚拟DOM节点,只有beforeUpdate和updated/componentUpdated钩子才有,新手也不用。

(三)新手必练的3个实用自定义指令示例

v-focus:条件渲染/弹窗后的自动聚焦

这个是最简单但最常用的指令,我们先写全局注册的版本,再写局部注册的版本。 全局注册的话,在main.js/main.ts里写:

// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
// 全局注册v-focus指令
app.directive('focus', {
  // 挂载后聚焦
  mounted(el) {
    el.focus()
  },
  // 组件更新后(比如条件渲染显示了)重新聚焦
  componentUpdated(el, binding) {
    // 只有绑定值为true的时候才聚焦,不然每次更新都聚焦会很烦
    if (binding.value) {
      el.focus()
    }
  }
})
app.mount('#app')

然后在组件里用:

<template>
  <div>
    <button @click="showInput = !showInput">切换输入框</button>
    <!-- 只有showInput为true的时候才显示并聚焦 -->
    <input v-if="showInput" v-focus="showInput" type="text" placeholder="自动聚焦哦">
  </div>
</template>
<script setup>
import { ref } from 'vue'
const showInput = ref(false)
</script>

局部注册的话,直接在组件的script里定义directives对象就行:

<script setup>
import { ref, directive } from 'vue' // Vue3.2+可以直接用directive()函数定义局部指令
const showInput = ref(false)
// 局部注册v-focus指令,用directive()函数或者directives对象都可以
const vFocus = {
  mounted(el) {
    el.focus()
  },
  componentUpdated(el, binding) {
    if (binding.value) el.focus()
  }
}
</script>

v-lazy:图片懒加载+占位图+错误降级

这个稍微复杂一点,但也很实用,我们需要用到Intersection Observer API(比监听scroll事件性能好太多,不会卡顿)。

// 先在src/directives/lazy.js里定义全局指令
export default {
  mounted(el, binding) {
    // 初始化:用占位图替换真实src,把真实src存到data-src里
    const placeholder = binding.arg || 'https://via.placeholder.com/300x200?text=加载中...'
    const errorImg = binding.modifiers.error ? 'https://via.placeholder.com/300x200?text=加载失败' : placeholder
    el.src = placeholder
    el.dataset.src = binding.value || ''
    el.dataset.error = errorImg
    // 创建Intersection Observer实例
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        // 如果元素进入视口
        if (entry.isIntersecting) {
          const img = entry.target
          // 加载真实图片
          const realSrc = img.dataset.src
          if (realSrc) {
            img.src = realSrc
            // 图片加载成功后取消观察
            img.onload = () => {
              observer.unobserve(img)
            }
            // 图片加载失败后用错误图,然后取消观察
            img.onerror = () => {
              img.src = img.dataset.error
              observer.unobserve(img)
            }
          }
        }
      })
    }, {
      // 可以配置rootMargin,提前一点加载,比如100px
      rootMargin: '100px 0px',
      threshold: 0.1 // 元素进入视口10%就触发
    })
    // 观察当前元素
    observer.observe(el)
    // 把observer实例存到el._lazyObserver里,方便卸载的时候取消观察
    el._lazyObserver = observer
  },
  beforeUnmount(el) {
    // 卸载前取消观察,防止内存泄漏
    if (el._lazyObserver) {
      el._lazyObserver.unobserve(el)
      el._lazyObserver = null
    }
  }
}

然后在main.js里全局注册:

import vLazy from './directives/lazy.js'
app.directive('lazy', vLazy)

在组件里用:

<template>
  <div class="img-list">
    <!-- 循环渲染100张图片,真实src用picsum的随机图 -->
    <img 
      v-for="i in 100" 
      :key="i" 
      v-lazy.error="`https://picsum.photos/300/200?random=${i}`" 
      :arg="'https://via.placeholder.com/300x200?text=我的占位图'"
      alt="懒加载图片"
    >
  </div>
</template>
<style scoped>
.img-list {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  padding: 20px;
}
.img-list img {
  width: 300px;
  height: 200px;
  object-fit: cover;
}
</style>

注意哦,这里的arg我用了arg的方式传,因为默认arg是字符串,没法传变量,这种写法是Vue3支持的动态指令参数,非常实用。

v-permission:权限控制DOM显示隐藏/禁用

这个是企业级项目里常用的,我们需要先有一个全局的权限管理机制,比如从localStorage或者Vuex/Pinia里获取当前用户的权限码。 先假设我们有一个Pinia store叫usePermissionStore,里面有一个currentPermissions数组,存的是当前用户的权限码:

// src/stores/permission.js
import { defineStore } from 'pinia'
export const usePermissionStore = defineStore('permission', {
  state: () => ({
    currentPermissions: ['admin', 'editor'] // 模拟当前用户是管理员和编辑
  }),
  getters: {
    hasPermission: (state) => (permission) => {
      if (!permission) return true
      if (Array.isArray(permission)) {
        return permission.some(p => state.currentPermissions.includes(p))
      }
      return state.currentPermissions.includes(permission)
    }
  }
})

然后定义v-permission指令:

// src/directives/permission.js
import { usePermissionStore } from '../stores/permission.js'
export default {
  mounted(el, binding) {
    const permissionStore = usePermissionStore()
    const hasPermission = permissionStore.hasPermission(binding.value)
    const modifiers = binding.modifiers
    if (!hasPermission) {
      // 如果有disable修饰符,就禁用元素
      if (modifiers.disable) {
        el.disabled = true
        el.style.opacity = 0.5
        el.style.cursor = 'not-allowed'
        // 还要阻止点击事件哦,不然光禁用样式没用
        el.addEventListener('click', (e) => e.stopPropagation(), true)
        el._preventClick = true
      } else {
        // 否则就隐藏元素,用display: none比v-if轻量,而且不会影响虚拟DOM的位置
        el.style.display = 'none'
      }
    }
  },
  beforeUnmount(el) {
    // 卸载前移除阻止点击事件的监听器
    if (el._preventClick) {
      el.removeEventListener('click', (e) => e.stopPropagation(), true)
      el._preventClick = false
    }
  }
}

全局注册后在组件里用:

<template>
  <div class="permission-demo">
    <button v-permission="['admin']">只有管理员能看到的删除按钮</button>
    <button v-permission="['admin', 'editor']">管理员和编辑能看到的编辑按钮</button>
    <button v-permission.disable="['superadmin']">只有超级管理员能点的提交按钮</button>
  </div>
</template>

写Vue3自定义指令的几个避坑指南

虽然Vue3自定义指令简单,但新手还是容易踩几个坑,这里总结一下:

  1. 不要滥用指令:开头说了,指令只能封装纯DOM操作逻辑,不能替代组件。
  2. 注意钩子函数的触发时机:比如created钩子还没法操作真实DOM,mounted钩子是最合适的;beforeUnmount钩子一定要做清理工作,比如移除事件监听器、取消Intersection Observer观察,防止内存泄漏。
  3. 动态指令参数要用arg的方式传:默认的arg是静态字符串,比如v-ellipsis:2的arg是'2',如果要传变量,比如v-ellipsis:[lineCount],lineCount是响应式的,那lineCount变化的时候,钩子函数的arg也会变,这时候就可以在updated/componentUpdated钩子函数里重新计算文本溢出的行数。
  4. binding.value是响应式的,但指令本身不会自动监听响应式数据的变化:除非响应式数据变化导致包含绑定元素的VNode更新,这时候beforeUpdate和updated/componentUpdated钩子才会触发,如果要让指令主动监听响应式数据的变化,可以在mounted钩子函数里用watch/watchEffect,不过要记得在beforeUnmount钩子函数里停止监听。
  5. 全局注册的指令名字不要和原生HTML属性或事件冲突:比如不要叫v-class、v-style、v-on,不然会覆盖Vue内置的指令。
  6. 企业级项目里最好把所有自定义指令放在单独的目录里统一管理:比如src/directives,然后在main.js里批量全局注册,方便维护和复用。

Vue3自定义指令虽然不是必须学的,但学会了之后能帮你简化代码、提升性能、提高复用性和可维护性,核心就是记住它的「正确打开场景」——纯DOM操作逻辑,然后掌握5个核心钩子函数和4个核心钩子参数,再练几个实用的指令示例,新手也能快速上手。

最后再提一句,如果你项目里用了Element Plus、Ant Design Vue这样的UI组件库,其实它们已经内置了很多实用的自定义指令,比如Element Plus的v-loading、v-infinite-scroll、v-permission(Pro版本才有),可以直接用,不用自己重复造轮子,但如果内置的指令满足不了你的需求,或者你项目里没用大库,那自己写自定义指令就是最好的选择。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门