Vue中自定义指令的使用

47 阅读10分钟

一、什么是自定义指令?

Vue自定义指令是一种扩展Vue功能的方式,它允许我们在DOM元素上添加额外的行为或操作。

二、自定义指令相关参数

钩子函数

Vue 2.X 与 Vue 3.X 相比,钩子函数是存在变化的

Vue 2.X钩子函数:

  • bind:自定义指令绑定到DOM后调用。只调用一次,指令第一次绑定到元素的调用。在这里可以进行一次性的初始化设置。注意:只是加入进了DOM,但是渲染没有完成。
  • inserted:自定义指令所在DOM,插入到父DOM后调用,渲染已经完成(父节点存在即可调用,不必存在于document中)。
  • update:元素更新,但子元素尚未更新,将调用此钩子(自定义指令所在组件更新时执行,但是不保证更新完成),和自定义指令所在组件有关。
  • componentUpdated:组件和子级更新后执行(自定义指令所在组件更新完成,且子组件也完成更新
  • unbind:解绑(销毁)(自定义指令所在DOM销毁时执行),只调用一次。

Vue 3.X钩子函数:

  • created:自定义指令所在组件,创建后调用
  • beforeMount:相当于Vue 2.X中的bind,当元素被插入到DOM前调用
  • mounted:相当于Vue 2.X中的inserted,当绑定元素的父组件被挂载后调用
  • beforeUpdate:绑定元素的父组件更新前调用
  • updated:相当于Vue 2.X中的componentUpdated,在绑定元素的父组件及他自己的所有子节点都更新后调用
  • beforeUnmount:绑定元素的父组件卸载前调用
  • unmounted:绑定元素的父组件卸载后调用

钩子函数参数

指令的钩子会传递以下几种参数:

  • 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。虚拟DOM节点,一个真实DOM元素的蓝图,对应el.

  • prevNode:上一个虚拟节点。之前的渲染中代表指令所绑定元素的VNode。仅在beforUpdateupdated钩子中使用.

三、注册自定义指令

自定义指令要分全局自定义指令局部指令

全局指令:通过应用实例身上的directive()注册一个全局自定义指令

Vue.directive(指令名, { 自定义指令生命周期 })
// Vue 2.X
import Vue from 'vue'

Vue.directive('focus', {
    inserted: function(el) {
        el.focus()
    }
})
// Vue 3.X
const app = createApp({})

app.directive('focus', {
    mounted(el) {
        el.focus()
    }
})

这是一个简单的小案例,通过注册一个v-focus指令,实现一个在页面加载完成后自动让输入框获取焦点的小功能。

局部指令:可在组件中配置directives选项来注册局部指令

directives(指令名, { 自定义指令生命周期 })
// Vue 2.X
directives: {
    focus: {
        inserted: function(el) {
            el.focus()
        }
    }
}
// Vue 3.X
directives: {
    focus: {
        mounted(el) {
            el.focus()
        }
    }
}

四、批量注册

批量注册是为了方便注册更多的自定义指令

  1. 创建专门放指令的文件夹directives,在文件夹中创建index.js文件。
  2. 在index.js文件中将所有指令引入后,写到一个对象中,并导出。
import copy from './copy'
import waterMarker from './waterMarker'

const directives = {
    copy,
    waterMarker
}

export default {
    install(Vue) {
        Object.keys(directives).forEach(key => {
            Vue.directive(key, directives[key])
        })
    }
}
  1. 在main.js文件中引入
// Vue 2.X
import Vue from 'vue'
import Directive from './directives'
   
Vue.use(Directive)
// Vue 3.X
import { createApp } from 'vue'
import App from './App.vue'
import Directive from './directives'
   
const app = createApp(App)
   
app.use(Directive)
   
app.mount('#app')

五、常用指令

防抖&&节流

当点击按钮时,通过防抖的方式触发指定的函数

// 防抖指令: 常用于输入框
// 例1:<input type="text" v-debounce="handleDebounce">

Vue.directive('debounce', {
  bind: (el, binding) => {
    // 没有绑定函数抛出错误
    if (typeof binding.value !== 'function') {
        throw 'debounce callback not a function'
    }
      
    // 获取节流事件名称(默认输入事件)
    let domEvent = binding.arg ?  binding.arg : 'input'
    let timer
    
    el.addEventListener(domEvent, ()=>{
      clearTimeout(timer)
      timer = setTimeout(()=>{
        binding.value()
      },2000)
    })
  }
})

// 节流指令: 常用于请求按钮、滚动事件
// 例1:<button v-debounce="handleClick">点击</button>
// 例2:<div v-throttle:scroll="handleThrottle" style="height: 300px;overflow: scroll;"></div>

Vue.directive('throttle', {
  bind: (el, binding) => {
    // 没有绑定函数抛出错误
    if (typeof binding.value !== 'function') {
      throw 'throttle callback not a function'
    }
      
    // 获取节流事件名称(默认点击)
    let domEvent = binding.arg ?  binding.arg : 'click'
    let timer
    
    el.addEventListener(domEvent, () => {
      if (timer) clearTimeout(timer)
      timer = setTimeout(() => {
        binding.value()
      }, 1000)
    })
  }
})

滚动加载

当页面滚动到底部时,触发指定的加载函数

// 例:<div v-scroll-load="handleLoad"></div>

Vue.directive('scroll-load', {
  bind: (el, binding) => {
    let lastScrollTop = 0;
    window.addEventListener('scroll', ()=> {
      const st = window.pageYOffset || document.documentElement.scrollTop;
      if (st > lastScrollTop) {
        const scorllHeight = document.documentElement.scrollHeight;
        const clientHeight = document.documentElement.clientHeight;
        if (st + clientHeight >= scorllHeight) {
          binding.value()
        }
      }
      lastScrollTop = st
    })
  }
})

权限验证

判断用户权限,权限不足则移除DOM元素

// 例:<div v-permission="'admin'">管理员视图</div>

Vue.directive('permission', {
  inserted: (el, binding) => {
    let roleList = JSON.parse(sessionStorage.getItem('roleList'))
    if (roleList && roleList.length > 0 && !roleList.includes(binding.value)) {
      if (!el.parentNode) {
        el.style.display = 'none'
      } else {
        el.parentNode.removeChild(el)
      }
    }
  }
})

拖拽DOM元素

场景:指定元素在可视区域内任意拖拽

思路:

  1. 设置指定元素相对定位,父元素绝对定位
  2. 鼠标按下时(触发onmousedown事件)时记录指定元素当前的lefttop
  3. 鼠标移动时(触发onmousemove事件)时计算每次移动的横向距离、纵向距离,并改变lefttop
  4. 鼠标松开时(触发onmouseup事件)时完成一次拖拽
// 例:<div v-draggable></div>

Vue.directive('draggable', {
  inserted: (el) => {
    el.style.cursor = 'move'
    el.style.position = 'absolute'
    el.onmousedown = e => {
      // 记录当前的left、top值
      let disX = e.pageX - el.offsetLeft
      let disY = e.pageY - el.offsetTop

      // 鼠标移动
      document.onmousemove = e => {
        let x = e.pageX - disX
        let y = e.pageY - disY

        // 临界值处理
        let maxX = document.body.clientWidth - parseInt(window.getComputedStyle(el).width)
        let maxY = document.body.clientHeight - parseInt(window.getComputedStyle(el).height)

        if (x < 0) {
          x = 0
        } else if (x > maxX) {
          x = maxX
        }

        if (y < 0) {
          y = 0
        } else if (y > maxY) {
          y = maxY
        }

        el.style.left = `${x}px`
        el.style.top = `${y}px`
      }
      document.onmouseup = () => {
        document.onmousemove = document.onmouseup = null
      }
    }
  }
})

密码强度验证

判断用户输入的密码强度,强度验证规则根据实际项目需求更改

// 例:<input v-password-check v-model="psw" type="password">
//    <p>强度:{{ status }}</p>

Vue.directive('password-check', {
  bind: (el, binding, vNode) => {
    el.oninput = e => {
      let value = e.target.value
      console.log(value);
      if (value.trim().length <= 0) {
        vNode.context.status = '密码不能为空'
      } else if (value.trim().length < 6) {
        vNode.context.status = '弱'
      } else if (value.trim().length < 10) {
        vNode.context.status = '中'
      } else {
        vNode.context.status = '强'
      }
    }
  }
})

图片懒加载

当img元素滚动到可见区域时,v-lazy指令会立即将img元素的src属性替换为绑定的图片url,从而实现图片的懒加载。

// 例:<img v-lazy="'https://xxxx/xxx.png'" src="../assets/logo.png" alt="懒加载图片">

Vue.directive('lazy', {
  bind: (el, binding)=>{
    // 观察者实例
    let observer = new IntersectionObserver((entries)=>{
      if (entries[0].isIntersecting) {
        // 当图片出现在视窗中,替换图片的src
        el.src = binding.value
        // 停止监听
        observer.unobserve(el)
      }
    }, {threshold: 0.01})
    // 开始监听el元素
    observer.observe(el)
  }
})

水印指令

使用canvas特性生成base64格式的图片文件,设置其字体大小,颜色等,然后将其设置为背景图,实现水印效果

// 例:<div 
//       v-waterMarker="{
//         text: 'JiangWen版权所有',
//         font: '14px Microsoft JhengHei',
//         textColor: 'rgba(180, 180, 180, 0.4)'
//       }">
//     </div>

Vue.directive('waterMarker', {
  bind: (el, binding) => {
    let str = binding.value.text
    let textColor = binding.value.textColor
    let font = binding.value.font
    let parentNode = el

    let can = 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') + ')'
  }
})

复制粘贴指令

功能:一键复制文本内容,用于粘贴

思路:

  1. 动态创建textarea标签,并设置reaOnly属性及移除可视区域
  2. 将要复制的值赋给textarea标签的value属性,并插入到body
  3. 选中值textarea并复制
  4. body中插入的textarea移除
  5. 在第一次调用时绑定事件,在解绑时移除事件
// 例子: <button v-copy="测试文本">复制</button>

Vue.directive('copy', {
  bind(el, { value }) {
    el._value = value
    el.handler = () => {
      if (!el._value) {
        // 复制的值为空时,给出的提示操作。
        console.log('无可复制的内容')
        return
      }
      // 创建 textarea 标签
      const textarea = document.createElement('textarea')
      // 设置readOnly(规定字段为只读)属性,防止 iOS下自动唤起键盘,并移除可视区域
      textarea.readOnly = 'readOnly'
      textarea.style.position = 'absolute'
      textarea.style.left = '-9999px'
      // 将需要复制的值,赋给 textarea 标签的 value  值
      textarea.value = el._value
      // 将 textarea 插入到 body 中
      document.body.appendChild(textarea)
      // 选中值并复制
      textarea.select()
      const result = document.execCommand('Copy')
      if (result) {
        console.log('复制成功')
      }
      document.body.removeChild(textarea)
    }
    // 绑定点击事件
    el.addEventListener('click', el.handler)
  },
    
  // 当传进来的值更新的时候触发
  componentUpdated(el, { value }) {
    el._value = value
  },
    
  // 指令与元素解绑的时候触发,移除事件绑定
  unbind(el) {
    el.removeEventListener('click', el.handler)
  }
})

长按指令

功能:长按超过两秒,执行回调函数

思路:

  1. 创建一个计时器,两秒后执行函数
  2. 当用户按下按钮时触发mousedown事件(移动端touchstart事件),启动计时器;
  3. 用户松开按钮时调用mouseout事件(移动端touchend事件)
  4. 如果mouseup事件在两秒内触发,此事件当作普通点击事件
  5. 如果计时器没有在两秒内清除,定为长按事件,触发相关回调函数
// 例子: <button v-longpress="handleLongpress">复制</button>

Vue.directive('longpress', {
  bind(el, binding) {
    // 没有绑定函数抛出错误
    if (typeof binding.value !== 'function') {
      throw 'longpress callback not a function'
    }

    // 计时器变量
    el._timer = null
    // 运行函数
    el._handler = e => {
      binding.value(e)
    }
    // 创建计时器(2秒后执行函数)
    el._start = e => {
      // 0为鼠标左键
      if (e.type === 'click' && e.button !== 0) return

      if (el._timer === null) {
        el._timer = setTimeout(_ => {
          el._handler()
        }, 2000)

        // 取消浏览器默认事件
        el.addEventListener('contextmenu', e => {
          e.preventDefault()
        })
      }
    }
    // 两秒内松手,取消计时器
    el._cancel = () => {
      if (el._timer !== null) {
        clearTimeout(el._timer)
        el._timer = null
      }
    }

    // 添加计时监听
    el.addEventListener('mousedown', el._start)
    el.addEventListener('touchstart', el._start)
    // 添加取消监听
    el.addEventListener('click', el._cancel)
    el.addEventListener('mouseout', el._cancel)
    el.addEventListener('touchend', el._cancel)
    el.addEventListener('touchcancel', el._cancel)
  },
  unbind(el) {
    // 移除监听
    el.removeEventListener('mousedown', el._start)
    el.removeEventListener('touchstart', el._start)
    el.removeEventListener('click', el._cancel)
    el.removeEventListener('mouseout', el._cancel)
    el.removeEventListener('touchend', el._cancel)
    el.removeEventListener('touchcancel', el._cancel)
  }
})

自定义数字输入指令

场景:根据正则表达式,设计自定义处理表单输入规则的指令

思路:

  1. 定位input输入框(el-input输入框外会包裹一层div)
  2. 监听输入事件,对只保留整数或保留小数分别处理
  3. 通过正则表达式对保留小数做处理
  4. 将匹配后的值赋值给输入框
// 例子1:    <input v-model="value1" placeholder="整数" v-inputNumber>
// 例子2:    <input v-model="value2" placeholder="保留两位小数" v-inputNumber.float="2">

Vue.directive('inputNumber', {
  bind: (el, binding, vnode) => {
    // 定位输入框
    let input = el.tagName === 'INPUT' ? el : vnode.elm.children[0]
    // compositionstart -> 开始新的输入合成时会触发
    input.addEventListener('compositionstart', ()  => {
      vnode.inputLocaking = true
    })
    // compostitonend -> 合成完成或取消时触发
    input.addEventListener('compostitonend', () => {
      vnode.inputLocaking = false
      input.dispatchEvent(new Event('input'))
    })
    input.addEventListener('input', ()  => {
      if (vnode.inputLocaking) return

      let oldValue = input.value
      let newValue = input.value

      if (binding.modifiers.float) {
        // 清除数字和‘.’以外的字符
        newValue = newValue.replace(/[^\d.]/g, '')
        // 只保留第一个‘.’,清除多余的
        newValue = newValue.replace(/\.{2,}/g, '.')
        // 第一个字符如果是‘.’,补充前缀0
        newValue = newValue.replace(/^\./g, '0.')
        // 0开头的只有保留第一个0,清除多余的
        newValue = newValue.replace(/^0{2,}/g, '0')
        // 两位数以上不能0开头
        if (/^0\d+/.test(newValue)) {
          newValue = newValue.slice(1)
        }
        // 保证‘.’只出现一次,而不能出现两次以上
        newValue = newValue.replace('.', '$#$').replace(/\./g, '').replace('$#$', '.')

        // 保留几位小数
        if (typeof binding.value !== 'undefined') {
          // 期望保留的最大小数位数
          let pointKeep = 0
          if (typeof binding.value === 'string' || typeof binding.value === 'number') {
            pointKeep = parseInt(binding.value)
          }
          if (!isNaN(pointKeep)) {
            if (!Number.isInteger(pointKeep) || pointKeep < 0) {
              pointKeep = 0
            }
            const str = '^(\\d+)\\.(\\d{' + pointKeep + '}).*$'
            const reg = new RegExp(str)
            if (pointKeep === 0) {
              // 不需要小数点
              newValue = newValue.replace(reg, '$1')
            } else {
              // 通过正则表达式保留小数点后指定的位数
              newValue = newValue.replace(reg, '$1.$2')
            }
          }
        }
      } else {
        // 只保留整数
        newValue = newValue.replace(/[^\d]/g, '')
        newValue = newValue ? parseInt(newValue) : ''
      }

      // 判断是否需要更新,避免进入死循环
      if (+newValue !== +oldValue) {
        input.value = newValue
        input.dispatchEvent(new Event('input'))
      }
    })
  }
})

元素平滑上升

场景:元素向上滚动时平滑上升

思路:

  1. 定位input输入框(el-input输入框外会包裹一层div)
  2. 监听输入事件,对只保留整数或保留小数分别处理
  3. 通过正则表达式对保留小数做处理
  4. 将匹配后的值赋值给输入框
const DISTANCE = 100
const DURATION = 500
// 建立映射
const map = new WeakMap()

const ob = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    // 出现在视口中
    if (entry.isIntersecting) {
      const animation = map.get(entry.target)
      animation && animation.play()
      ob.unobserve(entry.target)
    }
  }
})
// 判断是否在视口下面
function isBelowViewport(el) {
  const rect = el.getBoundingClientRect && el.getBoundingClientRect()
  return rect.top - window.innerHeight > 0
}

Vue.directive('slideIn', {
  inserted(el) {
    if (!isBelowViewport(el)) return
    const animation = el.animate(
      [
        {
          transform: `translateY(${DISTANCE}px)`,
          opacity: 0.5
        },
        {
          transform: `translateY(0)`,
          opacity: 1
        }
      ],
      {
        duration: DURATION,
        ease: 'ease-out',
        fill: 'forwards'
      }
    )
    animation.pause()
    map.set(el, animation)
    ob.observe(el)
  },
  
  unbind(el) {
    ob.unobserve(el)
  }
})