Vue3 封装自定义指令实践

前言

Vue 自定义指令允许直接操作 DOM 元素,能够在模板中轻松应用特定的交互和效果,强大且灵活,使得我们能够更加高效地封装和应用指令。

接下来探索Vue3自定义指令的用法和技巧,如何创建、封装自定义指令,使用 Hooks 封装常用的自定义指令,以及最佳实践。

自定义指令的基本用法

创建自定义指令

  1. 全局自定义指令

main.js 入口调用应用 API app.directive 注册

JS 复制代码
// main.js
const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive('focus', {
  /* ... */
})

在项目中,如果一个一个注册全局指令比较麻烦,好的做法是统一将全局指令放在 directives 目录下进行批量注册,将全局指令注册作为插件安装

批量注册指令,新建 directives/index.js 文件

js 复制代码
import permission from './permission'
import clickOutside from './click-outside'
// 自定义指令
const directives = {
  permission,
  clickOutside,
  ...
}
 
export default {
  install(app) {
    Object.keys(directives).forEach((key) => {
      app.directive(key, directives[key])
    })
  },
}

main.js 引入并调用

js 复制代码
import directives from './directives/index.js'
const app = createApp({})

app.use(directives)
  1. 局部注册:在 directives 选项注册
JS 复制代码
export default {
  setup() {
    /*...*/
  },
  directives: {
    // 在模板中启用 v-focus
    focus: {
      /* ... */
    }
  }
}
  1. 局部注册:在 script setup 注册

<script setup> 定义组件内的指令,以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令,然后在模板中使用,如鼠标聚焦指令

html 复制代码
<script setup>
// 在模板中启用 v-focus
const vFocus = {
  mounted: (el) => el.focus()
}
</script>

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

指令钩子函数和参数

指令钩子

自定义指令的生命周期钩子函数提供了丰富的功能和灵活性,使得我们可以在不同的时机对 DOM 元素进行操作和交互

js 复制代码
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) {}
}
  1. created:指令进行初始化逻辑,还不能操作 DOM

  2. beforeMount:绑定的元素挂载到 DOM 之前调用,此时元素尚未插入到页面中。比如获取元素的初始位置、样式等

  3. mounted:绑定的元素挂载到 DOM 之后调用,此时元素已经插入到页面中。比如初始化第三方插件、注册事件监听器等。

  4. beforeUpdate:组件的 VNode 更新之前调用,但是子组件的 VNode 尚未更新。比如获取更新前的 DOM 状态,用于后续比较和优化。

  5. updated:VNode 更新之后调用,子组件的 VNode 已经更新。比如更新 DOM 状态、重新计算样式等。

  6. beforeUnmount:组件的 VNode 销毁之前调用。比如取消事件监听器、释放资源等。

  7. unmounted:VNode 销毁后调用。执行一些收尾操作,比如清除定时器、释放内存等。

钩子的参数

自定义指令的钩子函数在调用时会传入不同的参数,这些参数提供了与指令相关的信息,帮助我们更好地理解和操作指令

JS 复制代码
mounted(el, binding, vnode, preVnode) {
  el.focus()
}

钩子函数接收 4 个参数:

  • el:指令绑定到的 DOM 元素,可以用于直接操作当前元素,默认传入钩子的就是 el 参数,例如我们开始实现的 focus 指令,就是直接操作的元素 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:使用该指令的组件实例,注意不是 DOM
    • dir:指令的定义对象
  • vnode:代表绑定元素的底层 VNode

  • preVnode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用

例如

JS 复制代码
<script setup>
import { ref } from 'vue'

const vFocus = {
  mounted (el, bind, vnode, preVode) {
    console.log(el, bind, vnode, preVode)
  }
}

const msg = ref('Hello World!')
</script>

<template>
  <input v-model="msg" v-focus:foo.bar="msg" >
</template>

binding 参数对象

vnode 参数对象

封装常用自定义指令

权限指令

封装一个权限指令,在模板中根据用户权限来控制元素的显示或隐藏

js 复制代码
// permission.js
import { ref, watchEffect } from 'vue';

const hasPermission = (permission) => {
  // 在实际项目中,根据后端返回的用户权限进行判断
  const userPermissions = ['view', 'edit'];
  return userPermissions.includes(permission);
};

export default {
  beforeMount(el, binding) {
      const { value } = binding;
      const visible = ref(false);

      // 监听权限变化,当权限发生改变时重新判断是否显示元素
      watchEffect(() => {
        visible.value = hasPermission(value);
      });

      // 根据 visible 的值来显示或隐藏元素
      el.style.display = visible.value ? 'block' : 'none';
    }
}

定义了一个 hasPermission 函数来模拟权限判断逻辑,根据用户权限来返回 truefalse

在模板中使用 v-permission 指令来根据用户权限控制元素的显示或隐藏

html 复制代码
<template>
  <div>
    <button v-permission="'view'">View Button</button>
    <button v-permission="'edit'">Edit Button</button>
    <button v-permission="'delete'">Delete Button</button>
  </div>
</template>

v-click-outside指令

常见下拉框组件,点击下拉框外部其他地方,可以收起下拉框。实现这种效果可以在目标元素上使用 v-click-outside 指令,判断如果点击的元素和指令绑定在同一个元素,则不执行操作,否则执行回调函数隐藏下拉框

JS 复制代码
// v-click-outside
export default {
  mounted(el, binding) {
    el.handler = function(e) {
      // 点击范围在绑定的元素范围内,不执行指令操作
      if (el.contains(e.target)) return;
      if (typeof binding.value === 'function') {
        // 绑定给指令的如果是一个函数,那么将回调并指定this
        binding.value.apply(this, arguments)
      }
    }
    // 监听全局的点击事件
    document.addEventListener('click', el.handler)
  },
  // 解除事件绑定
  beforeUnmount(el) {
    document.removeEventListener('click', el.handler)
  }
}

关键逻辑判断在 el.contains(e.target)

防抖函数指令

在模板中使用防抖功能,可以用于减少频繁触发的事件的执行次数,比如在输入框中的实时搜索场景

  1. 封装防抖指令文件
js 复制代码
// debounce.js
import { ref, watchEffect } from 'vue';

export default {
  beforeMount(el, binding) {
      const { value } = binding;
      
      // 需要回调函数以及监听事件类型
      if (typeof value.fn !== 'function' || !value.event) return;
      
      el.timer = null
      el.handler = function() {
        if (el.timer) {
          clearTimeout(el.timer);
          el.timer = null;
        };
        el.timer = setTimeout(() => {
          binding.value.fn.apply(this, arguments)
          el.timer = null;
        }, value.delay || 300);
      }
      
      el.addEventListener(value.event, el.handler)
    },
    beforeUnmount(el, binding) {
      // 在组件卸载前清除定时器,防止内存泄漏
      if (el.timer) {
        clearTimeout(el.timer);
        el.timer = null;
      }
      el.removeEventListener(binding.value.event, el.handler)
    }
}
  1. 模版上使用指令
html 复制代码
<template>
  <div>
    <input v-model="inputValue" v-debounce="handleInput" />
  </div>
</template>

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

const inputValue = ref('');
  
  
const handleInput = {
  event: 'input',
  fn (event) {
    console.log('Debounced Input:', event.target.value);
  },
  delay: 500
}
</script>

节流指令

防抖是限制执行次数,多次密集的触发只会执行最后一次,无规律,更关注结果;一般用于服务端校验,如防止多次点击、搜索输入、表单校验

节流是限制执行频率,有节奏的执行,有规律, 更关注过程。一般用于 DOM 操作频次限制,优化性能,如拖拽、滚动、resize 等操作

JS 复制代码
// throtte.js
export default {
  mounted(el, binding) {
    // 至少需要回调函数以及监听事件类型
    if (typeof binding.value.fn !== 'function' || !binding.value.event) return;
    let delay = 200;
    el.timer = null;
    el.handler = function() {
      if (el.timer) return;
      el.timer = setTimeout(() => {
        binding.value.fn.apply(this, arguments)
        el.timer = null;
      }, binding.value.delay || delay);
    }
    el.addEventListener(binding.value.event, el.handler)
  },
  // 元素卸载前也记得清理定时器并且移除监听事件
  beforeUnmount(el, binding) {
    if (el.timer) {
      clearTimeout(el.timer);
      el.timer = null;
    }
    el.removeEventListener(binding.value.event, el.handler)
  }
}

resize 指令

resize 在模板中使用该指令来监听元素大小的变化,执行相应的业务逻辑代码

js 复制代码
// resize.js
import { ref, onMounted, onUnmounted } from 'vue';

export default {
  mounted(el, binding) {
      const { value: callback } = binding;
      const width = ref(0);
      const height = ref(0);

      function handleResize() {
        width.value = el.clientWidth;
        height.value = el.clientHeight;
        callback({ width: width.value, height: height.value });
      }

      // 监听窗口大小变化,调用 handleResize
      window.addEventListener('resize', handleResize);

      // 初始时调用一次 handleResize
      handleResize();

      // 在组件卸载前移除事件监听
      onUnmounted(() => {
        window.removeEventListener('resize', handleResize);
      });
    }
}

滚动加载指令

封装一个滚动加载监听指令,在模板中使用该指令来实现滚动加载的功能

js 复制代码
// scrollLoad.js
import { onMounted, onUnmounted } from 'vue';

export default {
  mounted(el, binding) {
      const { fn, distance = 100 } = binding.value;

      function handleScroll() {
        const scrollHeight = el.scrollHeight;
        const offsetHeight = el.offsetHeight;
        const scrollTop = el.scrollTop;

        if (scrollHeight - offsetHeight - scrollTop <= distance) {
          fn();
        }
      }

      // 监听滚动事件,调用 handleScroll
      el.addEventListener('scroll', handleScroll);

      // 在组件卸载前移除事件监听
      onUnmounted(() => {
        el.removeEventListener('scroll', handleScroll);
      });
    }
}

在模板中使用 v-scroll-load 指令来监听滚动加载事件,并执行相应的回调函数:

js 复制代码
<template>
  <div v-scroll-load="loadMore" style="overflow: auto; height: 200px; border: 1px solid #ccc;">
    <ul>
      <li v-for="item in items" :key="item">{{ item }}</li>
    </ul>
  </div>
</template>

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

const items = ref([]);

function loadMore() {
  // 模拟加载更多数据
  for (let i = 0; i < 10; i++) {
    items.value.push('Item ' + (items.value.length + 1));
  }
}
</script>

图片懒加载指令

在图片元素上使用指令,实现图片的懒加载

js 复制代码
// LoadIMage
import { onMounted, onUnmounted } from 'vue';

export default {
  mounted(el, binding) {
    // 使用 Intersection Observer 实现图片懒加载
    const io = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          el.src = binding.value;
          observer.unobserve(el);
        }
      });
    });
    io.observe(el);
  }
}

自定义图像占位指令

在图像加载时使用指令,显示占位图像,直到真实图像加载完成。

html 复制代码
<template>
  <img v-placeholder-src="imageUrl" placeholder="/placeholder.png" />
</template>

<script>
  
import { defineComponent } from 'vue'
export default defineComponent({
  directives: {
    'placeholder-src': {
      mounted(el, binding) {
        const placeholder = binding.arg;
        el.setAttribute('src', placeholder);
        const img = new Image();
        img.src = binding.value;
        img.onload = () => {
          el.setAttribute('src', binding.value);
        };
      }
    }
  }
})
</script>

自定义提示框指令

在元素上使用指令,显示自定义的提示框

html 复制代码
<template>
  <button v-tooltip="tooltipText">Hover Me</button>
</template>

<script>
import { defineComponent } from 'vue'
  
export default defineComponent({
  directives: {
    tooltip: {
      mounted(el, binding) {
        const tooltipText = binding.value;
        el.addEventListener('mouseenter', () => {
          showTooltip(el, tooltipText);
        });
        el.addEventListener('mouseleave', hideTooltip);
      },
      beforeUnmount(el) {
        el.removeEventListener('mouseenter', showTooltip);
        el.removeEventListener('mouseleave', hideTooltip);
      }
    }
  }
})
  
function showTooltip(el, text) {
  // 显示自定义提示框逻辑
}

function hideTooltip() {
  // 隐藏自定义提示框逻辑
}
</script>

复制指令

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

js 复制代码
// copy.js

export default {
  mounted (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)
  },
  // 当传进来的值更新的时候触发
  updated(el, { value }) {
    el.$value = value
  },
  // 指令与元素解绑的时候,移除事件绑定
  beforeUnmounted(el) {
    el.removeEventListener('click', el.handler)
  },
}
  1. 动态创建 textarea 标签,并设置 readOnly 属性及移出可视区域

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

  3. 选中值 textarea 并复制,将 body 中插入的 textarea 移除

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

表情指令

开发中遇到的表单输入,往往会有对输入内容的限制,比如不能输入表情和特殊字符,只能输入数字或字母等。

根据正则表达式,设计自定义处理表单输入规则的指令,下面以禁止输入表情和特殊字符为例

js 复制代码
let findEle = (parent, type) => {
  return parent.tagName.toLowerCase() === type ? parent : parent.querySelector(type)
}
 
const trigger = (el, type) => {
  const e = document.createEvent('HTMLEvents')
  e.initEvent(type, true, true)
  el.dispatchEvent(e)
}
 
export default {
  mounted: function (el, binding, vnode) {
    // 正则规则可根据需求自定义
    const regRule = /[^u4E00-u9FA5|d|a-zA-Z|rns,.?!,。?!...---&$=()-+/*{}[]]|s/g

    el.handle = function () {
      const val = binding.value
      el.target.value = val.replace(regRule, '')
    }
    el.addEventListener('input', el.handle)
  },
  unmounted: function (el) {
    el.removeEventListener('input', el.handle)
  },
}

拖拽指令

在元素上使用指令,实现拖拽功能。

  1. 鼠标按下(onmousedown)时记录目标元素当前的 lefttop 值。

  2. 鼠标移动(onmousemove)时计算每次移动的横向距离和纵向距离的变化值,并改变元素的 lefttop

  3. 鼠标松开(onmouseup)时完成一次拖拽

HTLM 复制代码
<template>
  <div v-draggable>
    Drag Me
  </div>
</template>

<script>
export default {
  directives: {
    draggable: {
      mounted(el) {
        let offsetX = 0;
        let offsetY = 0;

        el.addEventListener('mousedown', (e) => {
          offsetX = e.clientX - el.getBoundingClientRect().left;
          offsetY = e.clientY - el.getBoundingClientRect().top;
          document.addEventListener('mousemove', move);
          document.addEventListener('mouseup', stop);
        });

        function move(e) {
          el.style.left = `${e.clientX - offsetX}px`;
          el.style.top = `${e.clientY - offsetY}px`;
        }

        function stop() {
          document.removeEventListener('mousemove', move);
          document.removeEventListener('mouseup', stop);
        }
      }
    }
  }
}
</script>

水印指令

给页面添加背景水印。

  1. 使用 canvas 特性生成 base64 格式的图片文件,设置其字体大小,颜色等。
  2. 将其设置为背景图片,从而实现页面或组件水印效果
JS 复制代码
const addWaterMarker = (str, parentNode, font, textColor) => {
    // 水印文字,父元素,字体,文字颜色
    let can: HTMLCanvasElement = document.createElement("canvas");
    parentNode.appendChild(can);
    can.width = 200;
    can.height = 150;
    can.style.display = "none";
    let cans = can.getContext("2d");
    cans.rotate((-20 * Math.PI) / 180);
    cans.font = font || "16px Microsoft JhengHei";
    cans.fillStyle = textColor || "rgba(180, 180, 180, 0.3)";
    cans.textAlign = "left";
    cans.textBaseline = "Middle";
    cans.fillText(str, can.width / 10, can.height / 2);
    parentNode.style.backgroundImage = "url(" + can.toDataURL("image/png") + ")";
};
 
const waterMarker = {
    mounted(el, binding) {
        addWaterMarker(binding.value.text, el, binding.value.font, binding.value.textColor);
    }
};
export default waterMarker;

使用:设置水印文案,颜色,字体大小

HTML 复制代码
<template>
  <div
    class="content-box"
    v-waterMarker="{
      text: 'Watermark Direct',
      textColor: 'rgba(180, 180, 180, 0.6)'
    }"
  >
    <span class="text">水印指令</span>
  </div>
</template>
 
<style scoped lang="scss">
.content-box {
  width: 100%;
  height: 600px;
}
</style>

自定义指令的性能优化

避免在指令中执行昂贵的操作

  1. 避免频繁的 DOM 操作,可以考虑使用防抖或节流等技术来限制操作频率,以减轻性能负担

  2. 避免计算密集型的操作,比如大量的数据处理或复杂的计算,可以使用异步操作或缓存计算结果

  3. 避免在指令中进行网络请求或其他异步操作,尽量将异步操作移到组件或异步组件中处理

使用合适的钩子函数进行性能优化

Vue3提供了多个钩子函数来处理指令的生命周期,合理使用这些钩子函数可以优化指令的性能。

  • 如果指令只需要在元素挂载和卸载时执行一次操作,可以将逻辑放在 beforeMountunmounted 钩子函数中,避免重复执行。

  • 如果指令的逻辑依赖于响应式数据的变化,可以使用 beforeUpdateupdated 钩子函数来控制逻辑的更新时机,避免不必要的计算。

异步更新和懒加载指令的策略

异步更新:如果指令的逻辑涉及到异步操作,可以使用 nextTick 函数或 Promise 来延迟执行,以避免阻塞主线程。

懒加载:对于不是在页面初始加载时必须使用的指令,可以考虑使用懒加载的策略。即在需要使用指令的组件中动态导入指令,而不是在全局注册指令。这样可以减少页面初始加载时的代码量,提升页面加载性能。

相关推荐
醉城夜风~32 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子4 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅4 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师6 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc886 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn6 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端