一、前言
我們需要明白為什麼需要自定義一個指令,其實就是想更加簡潔地重複使用操作DOM的邏輯,這就和元件化和組合式函式差不多。
不管是Vue內建的指令還是自定義的指令,都有類似於元件的生命週期,我們可以在不同的生命週期完成不同的邏輯操作,並繫結到元件元素上,這樣就產生了自定義指令。在Vue3中,我們有三種方式可以定義指令:
- 如果是在
<script setup>
定義元件內的指令,有一個語法糖可以使用:任何以v
開頭的駝峰式命名的變數都可以被用作一個自定義指令,然後在模板中使用。舉一個簡單的例子:在輸入框渲染後自動聚焦
<script setup> // 在模板中啟用 v-focus const vFocus = { mounted: (el) => el.focus() } </script> <template> <input v-focus /> </template>
- 如果是使用選項式,則自定義指令需要在
directives
選項中註冊。同上一個例子:
<script> export default{ setup() {}, directives: { // 指令名 focus: { // 生命週期 mounted(el) { // 處理DOM的邏輯 el.focus(); }, } } } </script> <template> <input v-focus /> </template>
- 除了註冊元件內指令,我們還可以自定義全域性指令,這樣在所有的元件中都可以使用該指令
// main.js import { createApp } from 'vue' import App from './App.vue' const app = createApp(App) app.directive('focus', { mounted(el) { el.focus(); } }) app.mount('#app')
這三種方式我們選擇最後一種,其他兩種方式可以按照類似的方式實現。
二、生命週期
指令的生命週期和元件的生命週期類似:
app.directive('focus', {
created() {
console.log('created');
},
beforeMount() {
console.log('beforeMount');
},
mounted() {
console.log('mounted');
},
beforeUpdate() {
console.log('beforeUpdate');
},
updated() {
console.log('updated');
},
beforeUnmount() {
console.log('beforeUnmount');
},
unmounted() {
console.log('unmounted');
}
})
注意指令沒有beforeCreated
鉤子。
- created:在繫結元素的屬性前,或者事件監聽器應用前呼叫
- beforeMount:在元素被插入到DOM前呼叫,例如我們想要實現輸入框的自動聚焦,就不能在beforeMount鉤子中實現
- mounted:在繫結元素的父元件以及自己的所有子節點都掛載完畢後呼叫,這個時候DOM已經渲染出來,我們實現輸入框自動聚焦也是在這個鉤子函式中實現
- beforeUpdate:繫結元素的父元件更新前呼叫
- updated:在繫結元素的父元件以及自己的所有子節點都更新完畢後呼叫
- beforeUnmount:繫結元素的父元件解除安裝前呼叫
- unmounted:繫結元素的父元件解除安裝後呼叫
每個鉤子函式都有對應的引數,接下來繼續看鉤子引數。
三、鉤子的引數
指令是為了能重用對DOM的操作邏輯,因此指令引數可以有1-4個引數,其中必需的引數就是當前繫結的DOM元素。
語法:
created(el, binding, vnode, preVnode) {}
引數比較多,我們一個一個來學習。
-
el
:指令繫結到的DOM元素,可以用於直接操作當前元素,預設傳入鉤子的就是el引數,例如我們開始實現的focus
指令,就是直接操作的元素DOM -
binding
:這是一個物件,包含以下屬性:value
:在元素上使用指令時,傳遞給指令的值。例如:<div v-reverse="'hello'"></div>
,傳遞給reserve
指令的值就是hello
,我們可以拿到值並做相關處理oldValue
:之前的值,一般用於beforeUpate
和updated
鉤子函式中,例如:beforeUpdate(el, {oldValue: ''})
arg
:傳遞給指令的引數,非必需,例如<div v-reverse:foo="'hello'"></div>
,那麼傳遞給指令的引數就是foo
modifiers
:一個由修飾符構成的物件,例如<div v-reverse.foo.bar="'hello'"></div>
,那麼該修飾符物件為{foo: true, bar: true}
,我們經常使用到的事件修飾符,其實和這個差不多。instance
:使用該指令的元件例項,注意不是DOMdir
:指令的定義物件
-
vnode
:繫結元素的地城VNode -
preVnode
:之前的渲染中代表指令所繫結的元素的VNode,一般用於beforeUpate
和updated
鉤子函式中
可能看這些引數會一時迷糊,我們來看一個例子:
定義一個可翻轉輸入框輸入的指令,注意鉤子函式要選擇beforeUpdate
app.directive('reserve', {
beforeUpdate(el, binding) {
console.log(binding);
el.innerText = binding.value ? binding.value.split('').reverse().join('') : '';
}
在模板中使用:輸入框輸入值,div
會顯示反轉後的值
<script setup>
import {ref} from 'vue'
let hello = ref('')
</script>
<template>
<input v-focus v-model="hello" />
<div v-reserve:foo.bar="hello"></div>
</template>
執行結果:
四、簡化形式
我們在寫指令的時候,可以具體指定在哪些鉤子中執行一些邏輯。有時候指令的鉤子不止一個,但是又是重複的邏輯操作時,重複寫一遍程式碼顯然有點不夠優雅。在Vue中,如果我們在自定義指令時,需要在mounted
和updated
中實現相同的行為,並且不關心其他鉤子的情況,那麼我們開可以採用簡寫:
app.directive('color', (el, binding) => {
// 這將會在mounted和updated時呼叫
el.style.color = binding.value;
})
五、物件字面量
我們之前的例子中,傳遞給指令的值只有一個,如果我們想給指令傳入多個值應該怎麼操作呢?很簡單,傳入一個字面量物件即可,可以直接在模板中宣告,也可以使用響應式物件,在使用時binding.value
就是一個物件了,而不是一個普通的值。
<script setup>
import {ref, reactive} from 'vue'
let hello = ref('')
const obj = reactive({
hello: '',
world: ''
})
</script>
<template>
<input v-focus v-model="obj.hello" />
<div v-reserve:foo.bar="obj"></div>
<!-- <div v-reserve:foo.bar="{hello: obj.hello, world: obj.world}"></div> -->
</template>
對應的,我們的指令也要小小的修改一下:
el.innerText = binding.value ? binding.value.hello.split('').reverse().join('') : '';
實現的效果還是和上面的保持一致。
六、在元件上使用指令
在元素上直接使用指令,我們可以在指令中操作DOM,這個已經沒有問題了。那如果在元件上使用指令會怎樣呢?元件其實就是把一些DOM元素封裝起來,Vue3和Vue2不同,Vue3的模板中可以不止一個根節點。
我們新建一個Reverse.vue
,以便後續作為元件引入。
Vue2
:模板中只能有一個根節點,因此會報錯
// Reverse.vue
<template>
<div></div>
<div></div>
</template>
Vue3
:模板中可以不止一個根節點,正常
// Reverse.vue
<template>
<div></div>
<div></div>
</template>
既然指令是為了操作DOM元素,如果只有單個根節點那不會有問題,例如:
<script setup>
...
import ReverseVue from './Reserve.vue'
...
</script>
<template>
...
<ReverseVue v-reserve="obj"/>
</template>
// Reverse.vue
<template>
<!-- v-reserve 指令會被應用在此處 -->
<div></div>
</template>
如果模板中是多個根節點,就會丟擲警告,並且不執行指令
// Reverse.vue
<template>
<!-- v-reserve 不會作用,並且會丟擲警告 -->
<div></div>
<div></div>
</template>
結論:儘量不要在元件上使用自定義指令,除非能確定只會有一個根節點
七、幾個實用的自定義指令
以下舉例的指令都是全域性指令
1、自動聚焦v-focus
聚焦比較特殊,兄弟元素間只會有一個聚焦,即將該指令作用於兩個兄弟輸入框上,只會自動聚焦一個
app.directive('focus', (el) => {
el.focus();
})
2、防抖v-debounce
在實際專案開發中,經常會聽到服務端的同事抱怨:前端怎麼不做限流呀。前端做”限流“一般會採用防抖和節流,我們先來看如何實現防抖。
步驟:
- 首先我們得知道怎麼寫一個防抖函式
- 然後需要將防抖函式與el節點繫結,為了通用的話,還需要考慮傳入事件型別
- 最後是解除安裝定時器等操作
app.directive('debounce', {
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) {
clearTimeout(el.timer);
el.timer = null;
};
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)
}
})
在模板中使用:
<script setup>
const handleClick = () => {
console.log('防抖點選');
}
</script>
<template>
<button v-debounce="{fn: handleClick, event: 'click', delay: 200}">點選試試</button>
</template>
執行結果:
快速點選按鈕並不會立即觸發handleClick
,而是會在指定的延遲時間後才會觸發。
3、節流v-throttle
節流和防抖類似,都是用於前端”限流“。不同的是,防抖是限制執行次數,多次密集的觸發只會執行最後一次,無規律,更關注結果;節流是限制執行頻率,有節奏的執行,有規律, 更關注過程。
節流的實現和防抖差不多:
app.directive('throttle', {
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)
}
})
在模板中使用:
<script setup>
import {reactive} from 'vue'
const obj = reactive({
hello: '',
world: ''
})
const handleInput = () => {
console.log('節流輸入框的值:', obj.hello);
}
</script>
<template>
<input v-throttle="{fn: handleInput, event: 'input', delay: 1000}" v-model="obj.hello" />
</template>
執行結果:
handleInput
並不會因為我在輸入框輸入時的快慢而觸發,而是在固定的時間間隔內觸發一次,這就是節流。
4、彈窗隱藏v-hide
在實際開發時會有這樣的需求:點選某一個按鈕出現一個彈窗,然後點彈窗的其他區域時需要關閉彈窗,如果是點選的彈窗本身,除非是關閉操作,否則不關閉彈窗。
想要實現這種效果,大多數人都會想到全域性監聽click
事件,並且判斷點選的目標元素和我們的彈窗元素是不是同一個,如果不是那就隱藏彈窗。那麼我們就來看看具體應該怎麼實現:
app.directive('hide', {
mounted(el, binding) {
el.handler = function(e) {
// 如果點選範圍在繫結的元素範圍內,那麼將不執行指令操作,而是執行原點選事件
if (el.contains(e.target)) return;
if (typeof binding.value.fn === 'function') {
// 繫結給指令的如果是一個函式,那麼將回撥並指定this
binding.value.fn.apply(this, arguments)
// 並不推薦使用style的方式來隱藏元素,這樣的話控制彈窗的變數就無法改變,所以推薦使用回撥函式
// el.style.display = 'none';
// 解除事件繫結
document.removeEventListener('click', el.handler)
}
}
// 監聽全域性的點選事件
document.addEventListener('click', el.handler)
// 如果同步繫結全域性事件不生效,可以採用非同步的方式
// setTimeout(() => {
// document.addEventListener('click', el.handler)
// }, 0);
},
// 解除事件繫結
beforeUnmount(el) {
document.removeEventListener('click', el.handler)
}
})
在模板中使用:
<script setup>
import {ref} from 'vue'
let isShowModal = ref(false)
const showModal = () => {
isShowModal.value = true;
}
const cancleModal = () => {
console.log('cancleModal');
isShowModal.value = false;
}
</script>
<template>
<button @click.stop="showModal">點選顯示彈窗</button>
<div class="modal" v-hide="{fn: cancleModal}" v-if="isShowModal">
<p>我是彈窗</p>
<button @click.stop="cancleModal">關閉</button>
</div>
</template>
八、總結
本文儘量採用通俗易懂的方式,完整的梳理瞭如何在Vue3中自定義指令。合理的使用指令,可以更快的幫助我們解決問題,值得注意的是:
- 選擇指令的鉤子函式時需要明確,不同的鉤子函式呈現的效果是不一樣的
- 應該及時解除安裝鉤子函式定義的全域性變數、定時器、事件繫結等,避免影響其他元件使用,以及記憶體洩漏
- 如果是涉及DOM操作的,我們第一時間應該想到是不是可以抽離成指令的方式
轉載:https://juejin.cn/post/7107477387578703885