Vue自定义指令及使用


一、Vue内置 v- 指令

先来整理一下 Vue 中常用的一些内置v- 指令

  • v-text:元素的 innerText 属性,只能用在双标签中, 和效果是一样的,使用较少
  • v-html:元素的 innerHTML,其实就是给元素的 innerHTML 赋值
  • v-show:元素的显示与隐藏,基于 css 样式的切换。如果确定要隐藏,会给元素的 style 加上display: none
  • v-if:元素的插入和移除操作,相当于对元素的销毁和创建。如果表达式的值为 false,会留下一个 <!----> 作为标记,若未来 v-if 的值是 true 了,就在这里插入元素(如果 if 有 else 就不要单独留坑了)
  • v-else-if:前一个相邻元素必须有 v-if 或 v-else-if
  • v-else:前一个相邻元素必须有 v-if 或 v-else-if,如果 v-if 和 v-else-if 都有对应的表达式,则 v-else 可以直接写
  • v-for:用于循环渲染一组数据(数组或对象)。必须使用特定语法:v-for=”alias in expression”。

注:在vue2.0+中,当v-for 和 v-if 同处于一个节点时,v-for 的优先级比 v-if 更高。即 v-if 将运行在每个 v-for循环中,但在vue3中,v-if比v-for有更高的优先级。

  • v-on:主要用来监听 dom 时间,然后执行一些操作。简写为 @
  • v-model:用于 input/textarea 等表单控件上创建双向数据绑定
  • v-bind:动态的绑定一个或多个属性,常用于绑定 class,style,href 等,简写为:
  • v-once:组件和元素只渲染一次,当数据发生变化,也不会重新渲染

注:v-if 和 v-show 的对比

  1. v-if 是真正的条件渲染,因为它会确保在切换过程中条件块内的事件监听器和子组件适当的被销毁和重建。
  2. v-if 也是惰性的,如果在初始渲染时条件为假,则什么也不做,直到条件第一次为真时,才会开始渲染条件块。
  3. v-show 不管初始条件是什么,元素总是会被渲染,并且只是简单的基于 css 进行切换。

一般来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要非常频繁的切换,则使用 v-show 较好,如果在运行时条件很少改变,则使用 v-if 较好。

二、自定义指令的定义

除了核心功能默认内置的指令 (v-modelv-show),Vue 也允许注册自定义指令。注意,在 Vue2.0 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。也就是说自定义指令解决的问题或者说使用场景是对普通 DOM 元素进行底层操作,所以我们不能盲目的胡乱的使用自定义指令。

1.注册

注册自定义指令分为全局注册与局部注册两种:

全局注册:

方法一:(不推荐使用)

// 直接在main.js里引用注册
//main.js

// 注册一个全局自定义指令 `v-focus`
Vue.directive('focus', {
  // 当被绑定的元素插入到 DOM 中时……
  inserted: function (el) {
    // 聚焦元素
    el.focus()
  }
})

方法二:(不推荐使用)

// 直接在main.js里引用注册
//main.js

import emoji from '@/assets/js/emoji'
Vue.directive('emoji', emoji)

方法三:(推荐使用)

// 可定义多个自定义组件
//新建directives.js文件
import copy from './copy'
import emoji from './emoji'
// 自定义指令
const directives = {
    copy,
    emoji,
}

export default {
    install(Vue) {
        Object.keys(directives).forEach((key) => {
            Vue.directive(key, directives[key])
        })
    },
}


//main.js
import Directives from '@/assets/js/directives'
Vue.use(Directives)

注意:在定义的时候,指令的名称前面不需要加 v- 前缀,在调用的时候,必须在指定的名称前加上 v-前缀来进行调用。

用前2种注册方法,会导致main文件里冗杂。

局部注册:

var app = new Vue({
    el: '#app',
    directives: {
        focus: {
            // 指令的定义
            inserted: function (el) {
                el.focus()
            }
        }
    }
});

2.自定义指令的钩子函数

Vue 提供了自定义指令的5个钩子函数,它们都是可选的:

钩子函数 说明
bind 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
inserted 被绑定的元素,插入到父节点的 DOM 中时调用(父节点只要存在即可调用,不必存在于 document 中)。
update 组件更新时调用。 无论绑定值是否发生变化,只要被绑定元素所在的模板被更新即可调用。Vue.js 会通过比较更新前后的绑定值,忽略不必要的模板更新操作。
componentUpdated 组件与子组件更新(被绑定元素所在模板完成一次更新)时调用。
unbind 只调用一次,指令与元素解绑时调用。

3.钩子函数参数

入参 说明
el 指令所绑定的元素,可以用来直接操作 DOM。
binding 一个绑定对象(如下表),包含以下 property。
vnode Vue 编译生成的虚拟节点。
oldVnode 上一个虚拟节点,仅在 updatecomponentUpdated 钩子中可用。

binding绑定对象属性说明

入参 说明 示例
name 指令名,不包含前缀 v-。 v-focus 中的 focus
value 指令所绑定的值(计算后)。 v-my-directive="1 + 1" 中,绑定值为 `2
oldValue 指令绑定的前一个值,仅在 updatecomponentUpdated 钩子中可用。无论值是否改变都可用。 -
expression 绑定的值的字符串形式(不计算)。 v-my-directive="1 + 1" 中,表达式为 "1 + 1"
arg 传给指令的参数,可选。 v-my-directive:foo 中,参数为 "foo"
modifiers 一个包含修饰符的对象。 v-my-directive.foo.bar 中,修饰符对象为 { foo: true, bar: true }

注意:

  1. 除 update 与 componentUpdated 钩子函数之外,每个钩子函数都含有 el、binding、vnode 这三个参数
  2. 在每个函数中,第一个参数永远是 el, 表示被绑定了指令的那个 dom 元素,这个el 参数,是一个原生的 JS 对象,所以 Vue 自定义指令可以用来直接和 DOM 打交道
  3. binding 是一个对象,它包含以下属性:name、value、oldValue、expression、arg、modifiers
  4. oldVnode 只有在 update 与 componentUpdated 钩子中生效
  5. 除了 el 之外,binding、vnode 属性都是只读的

钩子函数说白了也就是生命周期,即当一个指令绑定到一个元素上时,这个指令内部有5个生命周期事件函数。接下来创建一个案例来看看这几个钩子函数的触发情况:

<p v-test>这是一段文字</p>
export default {
    ... ...
    directives: {
        test: {
            bind () {
                console.log('bind')
            },
            inserted () {
                console.log('inserted')
            },
            update () {
                console.log('update')
            },
            componentUpdated () {
                console.log('componentUpdated')
            },
            unbind () {
                console.log('unbind')
            }
        }
    }
}

结果:

页面渲染时,触发了 bindinserted 函数。那么另外三个钩子函数什么时候会触发呢?

关于 update 的官方解释:

update:所在组件的 VNode 更新时调用,但是可能发生在其子 VNode 更新之前。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新 (详细的钩子函数参数见下)。

有点疑惑,‘所在组件的 VNode’ 是指当前绑定了该指令的 dom 元素吗?如果是的话,是不是只要当前元素的状态发生了变化就会触发 update 呢?如下通过 v-show 来切换元素显示隐藏:

<p v-test v-show="show">这是另外一段文字</p>
<button @click="show = !show">toggle</button>
export default {
    data () {
        return {
            show: true
        }
    }
}

默认还是触发 bindinserted ,当点击按钮切换元素的 display 时,结果如下:

即:改变元素的样式的时候触发了 updatecomponentUpdated 函数。

如果使用 v-if 会触发哪个事件呢?

<p v-test v-if="show">这是另外一段文字</p>
<button @click="show = !show">toggle</button>

结果:

发现 unbind 被触发,因为 v-if 是删除或者重建 dom 元素,当指令绑定的元素被销毁时,会触发指令的 unbind 事件,新建显示仍是触发 bindinserted

总结:

  • bind():当指令绑定在 HTML 元素上时触发
  • inserted():当指令绑定的元素插入到父节点中的时候触发
  • update():当指令绑定的元素状态/样式、内容(这里指元素绑定的 vue 数据) 发生改变时触发
  • componentUpdated():当 update() 执行完毕之后触发
  • unbind():当指令绑定的元素从 dom 中删除时触发

三、带参数的自定义指令

<div v-bgcolor="{color: 'orange'}"></div>
Vue.directive('bgcolor', {
    bind: function(el, binding) {
        el.style.backgroundColor = binding.value.color;
    }
})

四、函数简写

在很多时候,你可能想在 bindupdate 时触发相同行为,而不关心其它的钩子。比如这样写:

// 全局
Vue.directive('bgcolor', function (el, binding) {
      el.style.backgroundColor = binding.value
})

// 局部
directives: {
    bgcolor: (el, binding) => {
        el.style.backgroundColor = binding.value  
    }
}

五、对象字面量

如果指令需要多个值,可以传入一个 JavaScript 对象字面量。记住,指令函数能够接受所有合法的 JavaScript 表达式。

<div v-demo="{ color: 'white', text: 'hello!' }"></div>
Vue.directive('demo', function (el, binding) {
    console.log(binding.value.color) // => "white"
    console.log(binding.value.text)  // => "hello!"
})

六、自定义指令的使用

下面分享八个实用的全局 Vue 自定义指令

  • 复制粘贴指令 v-copy
  • 长按指令 v-longpress
  • 输入框防抖指令 v-debounce
  • 禁止表情及特殊字符 v-emoji
  • 图片懒加载 v-LazyLoad
  • 权限校验指令 v-premission
  • 实现页面水印 v-waterMarker
  • 拖拽指令 v-draggable

1.复制粘贴指令 v-copy

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

思路:

  1. 动态创建 textarea 标签,并设置 readOnly 属性及移出可视区域
  2. 将要复制的值赋给 textarea 标签的 value 属性,并插入到 body
  3. 选中值 textarea 并复制
  4. 将 body 中插入的 textarea 移除
  5. 在第一次调用时绑定事件,在解绑时移除事件
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 及复制的文本即可

<template>
    <button v-copy="copyText">复制</button>
</template>

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

2.长按指令 v-longpress

需求:实现长按,用户需要按下并按住按钮几秒钟,触发相应的事件

思路:

  1. 创建一个计时器, 2 秒后执行函数
  2. 当用户按下按钮时触发 mousedown 事件,启动计时器;用户松开按钮时调用mouseout 事件。
  3. 如果 mouseup 事件 2 秒内被触发,就清除计时器,当作一个普通的点击事件
  4. 如果计时器没有在 2 秒内清除,则判定为一次长按,可以执行关联的函数。
  5. 在移动端要考虑 touchstart,touchend 事件

代码实现:

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

使用:给 Dom 加上 v-longpress 及回调函数即可

<template>
    <button v-longpress="longpress">长按</button>
</template>

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

3.防抖指令 v-debounce

需求:防止按钮在短时间内被多次点击,使用防抖函数限制规定时间内只能点击一次。

思路:

  1. 定义一个延迟执行的方法,如果在延迟时间内再调用该方法,则重新计算执行时间。
  2. 将时间绑定在 click 方法上。
const debounce = {
    inserted: function (el, binding) {
        let timer="";
        el.addEventListener('keyup', () => {
            if (timer) {
                clearTimeout(timer)
            }
            timer = setTimeout(() => {
                binding.value()
            }, 1000)
        })
    },
}

export default debounce

使用:给 Dom 加上 v-debounce 及回调函数即可

<template>
    <button v-debounce="debounceClick">防抖</button>
</template>

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

4.禁止表情及特殊字符 v-emoji

背景:开发中遇到的表单输入,往往会有对输入内容的限制,比如不能输入表情和特殊字符,只能输入数字或字母等。我们常规方法是在每一个表单的 on-change 事件上做处理。

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

<template>
    <input type="text" v-model="note" @change="vaidateEmoji" />
</template>

<script>
    export default {
        methods: {
            vaidateEmoji() {
                var reg = /[^\u4E00-\u9FA5|\d|\a-zA-Z|\r\n\s,.?!,。?!…—&$=()-+/*{}[\]]|\s/g
                this.note = this.note.replace(reg, '')
            },
        },
    }
</script>

这样代码量比较大而且不好维护,所以我们需要自定义一个指令来解决这问题。

实现代码:

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

const emoji = {
    bind: function (el, binding, vnode) {
        // 正则规则可根据需求自定义
        var regRule = /[^\u4E00-\u9FA5|\d|\a-zA-Z|\r\n\s,.?!,。?!…—&$=()-+/*{}[\]]|\s/g
        let $inp = findEle(el, 'input')
        el.$inp = $inp
        $inp.handle = function () {
            let val = $inp.value
            $inp.value = val.replace(regRule, '')

            trigger($inp, 'input')
        }
        $inp.addEventListener('keyup', $inp.handle)
    },
    unbind: function (el) {
        el.$inp.removeEventListener('keyup', el.$inp.handle)
    },
}

export default emoji

使用:将需要校验的输入框加上 v-emoji 即可

<template>
    <input type="text" v-model="note" v-emoji />
</template>

5.图片懒加载 v-LazyLoad

背景:在类电商类项目,往往存在大量的图片,如 banner 广告图,菜单导航图,美团等商家列表头图等。图片众多以及图片体积过大往往会影响页面加载速度,造成不良的用户体验,所以进行图片懒加载优化势在必行。

需求:实现一个图片懒加载指令,只加载浏览器可见区域的图片。

懒加载思路:

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

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

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

实现代码:

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

使用,将组件内 标签的 src 换成 v-LazyLoad

<img v-LazyLoad="xxx.jpg" />

6.权限校验指令 v-premission

背景:在一些后台管理系统,我们可能需要根据用户角色进行一些操作权限的判断,很多时候我们都是粗暴地给一个元素添加 v-if / v-show 来进行显示隐藏,但如果判断条件繁琐且多个地方需要判断,这种方式的代码不仅不优雅而且冗余。针对这种情况,我们可以通过全局自定义指令来处理。

需求:自定义一个权限指令,对需要权限判断的 Dom 进行显示隐藏。

思路:

  1. 自定义一个权限数组
  2. 判断用户的权限是否在这个数组内,如果是则显示,否则则移除 Dom
function checkArray(key) {
    let arr = ['1', '2', '3', '4','5']
    let index = arr.indexOf(key)
    if (index > -1) {
        return true // 有权限
    } else {
        return false // 无权限
    }
}

const permission = {
    inserted: function (el, binding) {
        let permission = binding.value // 获取到 v-permission的值
        if (permission) {
            let hasPermission = checkArray(permission)
            if (!hasPermission) {
                // 没有权限 移除Dom元素
                el.parentNode && el.parentNode.removeChild(el)
            }
        }
    },
}

export default permission

使用:给 v-permission 赋值判断即可

<div class="btns">
    <!-- 显示 -->
    <button v-permission="'1'">权限按钮1</button>
    <!-- 不显示 -->
    <button v-permission="'10'">权限按钮2</button>
</div>

7.实现页面水印 v-waterMarker

需求:给整个页面添加背景水印

思路:

  1. 使用 canvas 特性生成 base64 格式的图片文件,设置其字体大小,颜色等。
  2. 将其设置为背景图片,从而实现页面或组件水印效果
function addWaterMarker(str, parentNode, font, textColor) {
    // 水印文字,父元素,字体,文字颜色
    var can = document.createElement('canvas')
    parentNode.appendChild(can)
    can.width = 200
    can.height = 150
    can.style.display = 'none'
    var 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 = {
    bind: function (el, binding) {
        addWaterMarker(binding.value.text, el, binding.value.font, binding.value.textColor)
    },
}

export default waterMarker

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

<template>
    <div v-waterMarker="{text:'lzg版权所有',textColor:'rgba(180, 180, 180, 0.4)'}"></div>
</template>

8.拖拽指令 v-draggable

需求:实现一个拖拽指令,可在页面可视区域任意拖拽元素。

思路:

  1. 设置需要拖拽的元素为相对定位,其父元素为绝对定位。
  2. 鼠标按下(onmousedown)时记录目标元素当前的 left 和 top 值。
  3. 鼠标移动(onmousemove)时计算每次移动的横向距离和纵向距离的变化值,并改变元素的 left 和 top 值
  4. 鼠标松开(onmouseup)时完成一次拖拽
const draggable = {
    inserted: function (el) {
        el.style.cursor = 'move'
        el.onmousedown = function (e) {
            let disx = e.pageX - el.offsetLeft
            let disy = e.pageY - el.offsetTop
            document.onmousemove = function (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 = function () {
                document.onmousemove = document.onmouseup = null
            }
        }
    },
}
export default draggable

使用:在 Dom 上加上 v-draggable 即可

<template>
    <div class="el-dialog" v-draggable></div>
</template>

文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录