likes
comments
collection
share

Vue3 封装自定义指令实践

作者站长头像
站长
· 阅读数 13

前言

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

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

自定义指令的基本用法

创建自定义指令

  1. 全局自定义指令

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

// main.js
const app = createApp({})

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

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

批量注册指令,新建 directives/index.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 引入并调用

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

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

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

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

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

指令钩子函数和参数

指令钩子

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

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 销毁后调用。执行一些收尾操作,比如清除定时器、释放内存等。

钩子的参数

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

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 钩子中可用

例如

<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 参数对象

Vue3 封装自定义指令实践

vnode 参数对象 Vue3 封装自定义指令实践

封装常用自定义指令

权限指令

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

// 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 指令来根据用户权限控制元素的显示或隐藏

<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 指令,判断如果点击的元素和指令绑定在同一个元素,则不执行操作,否则执行回调函数隐藏下拉框

// 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. 封装防抖指令文件
// 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. 模版上使用指令
<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 等操作

// 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 在模板中使用该指令来监听元素大小的变化,执行相应的业务逻辑代码

// 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);
      });
    }
}

滚动加载指令

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

// 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 指令来监听滚动加载事件,并执行相应的回调函数:

<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>

图片懒加载指令

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

// 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);
  }
}

自定义图像占位指令

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

<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>

自定义提示框指令

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

<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>

复制指令

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

// 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. 在第一次调用时绑定事件,在解绑时移除事件

表情指令

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

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

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)时完成一次拖拽

<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. 将其设置为背景图片,从而实现页面或组件水印效果
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;

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

<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 来延迟执行,以避免阻塞主线程。

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

转载自:https://juejin.cn/post/7262349502920343609
评论
请登录