Vue中內置了很多的指令,如v-model、v-show、v-html等,但是有時候這些指令並不能滿足我們,或者說我們想爲元素附加一些特別的功能,這時候,我們就需要用到vue中一個很強大的功能了—自定義指令。
在開始之前,我們需要明確一點,自定義指令解決的問題或者說使用場景是對普通 DOM 元素進行底層操作,所以我們不能盲目的胡亂的使用自定義指令。
如何聲明自定義指令?
就像vue中有全局組件和局部組件一樣,他也分全局自定義指令和局部指令。
let Opt = {
bind:function(el,binding,vnode){ },
inserted:function(el,binding,vnode){ },
update:function(el,binding,vnode){ },
componentUpdated:function(el,binding,vnode){ },
unbind:function(el,binding,vnode){ },
}
對於全局自定義指令的創建,我們需要使用Vue.directive
接口
Vue.directive('demo', Opt)
對於局部組件,我們需要在組件的鉤子函數directives中進行聲明
Directives: {
Demo: Opt
}
Vue中的指令可以簡寫,上面Opt是一個對象,包含了5個鉤子函數,我們可以根據需要只寫其中幾個函數。如果你想在 bind 和 update 時觸發相同行爲,而不關心其它的鉤子,那麼你可以將Opt改爲一個函數。
let Opt = function(el,binding,vnode){ }
如何使用自定義指令?
對於自定義指令的使用是非常簡單的,如果你對vue有一定了解的話。
我們可以像v-text=”’test’”
一樣,把我們需要傳遞的值放在‘=’號後面傳遞過去。
我們可以像v-on:click=”handClick”
一樣,爲指令傳遞參數’click’。
我們可以像v-on:click.stop=”handClick”
一樣,爲指令添加一個修飾符。
我們也可以像v-once
一樣,什麼都不傳遞。
每個指令,他的底層封裝肯定都不一樣,所以我們應該先了解他的功能和用法,再去使用它。
自定義指令的 鉤子函數
上面我們也介紹了,自定義指令一共有5個鉤子函數,他們分別是:bind、inserted、update、componentUpdate和unbind。
對於這幾個鉤子函數,瞭解的可以自行跳過,不瞭解的我也不介紹,自己去官網看,沒有比官網上說的更詳細的了:鉤子函數
項目中的bug
在項目中,我們自定義一個全局指令my-click
:
Vue.directive('my-click',{
bind:function(el, binding, vnode, oldVnode){
el.addEventListener('click',function(){
console.log(el, binding.value)
})
}
})
同時,有一個數組arr:[1,2,3,4,5,6]
,我們遍歷數組,生成dom元素,併爲元素綁定指令:
<ul>
<li v-for="(item,index) in arr" :key="index" v-my-click="item">{{item}}</li>
</ul>
可以看到,當我們點擊元素的時候,成功打印了元素,以及傳遞過去的數據。
可是,當我們把最後一個元素動態的改爲8之後(6 --> 8),點擊元素,元素是對的,可是打印的數據卻仍然是6.
或者,當我們刪除了第一個元素之後,點擊元素
黑人問號臉,這是爲什麼呢????帶着這個疑問,我去看了看源碼。在進行下面的源碼分析之前,先來說結論:
組件進行初始化的時候,也就是第一次運行指令的時候,會執行bind鉤子函數,我們所傳入的參數(binding)都進入到了這裏,並形成了一個閉包。
當我們進行數據更新的時候,vue虛擬dom不會銷燬這個組件(如果說刪除某個數據,會從後往前銷燬組件,前面的總是最後銷燬),而是進行更新(根據數據改變),如果指令有update鉤子會運行這個鉤子函數,但是對於元素在bind中綁定的事件,在update中沒有處理的話,他不會消失(依然引用初始化時形成的閉包中的數據),所以當我們更改數據再次點擊元素後,看到的數據還是原數據。
源碼分析
函數執行順序:createElm/initComponent/patchVnode --> invokeCreateHooks (cbs.create) --> updateDirectives --> _update
在createElm方法和initComponent方法和更新節點patchVnode時會調用invokeCreateHooks方法,它會去遍歷cbs.create中鉤子函數進行執行,cbs.create中的鉤子函數如下圖所示共8個。我們所需要看的就是updateDirectives這個函數,這個函數會繼續調用_update函數,vue中的指令操作就都在這個_update函數中了。
下面我們就來詳細看下這個_update函數。
function _update(oldVnode, vnode) {
//判斷舊節點是不是空節點,是的話表示新建/初始化組件
var isCreate = oldVnode === emptyNode;
//判斷新節點是不是空節點,是的話表示銷燬組件
var isDestroy = vnode === emptyNode;
//獲取舊節點上的所有自定義指令
var oldDirs = normalizeDirectives$1(oldVnode.data.directives, oldVnode.context);
//獲取新節點上的所有自定義指令
var newDirs = normalizeDirectives$1(vnode.data.directives, vnode.context);
//保存inserted鉤子函數
var dirsWithInsert = [];
//保存componentUpdated鉤子函數
var dirsWithPostpatch = [];
var key, oldDir, dir;
//這裏先說下callHook$1函數的作用
//callHook$1有五個參數,第一個參數是指令對象,第二個參數是鉤子函數名稱,第三個參數新節點,
//第四個參數是舊節點,第五個參數是是否爲註銷組件,默認爲undefined,只在組件註銷時使用
//在這個函數裏,會根據我們傳遞的鉤子函數名稱,運行我們自定義組件時,所聲明的鉤子函數,
//遍歷所有新節點上的自定義指令
for(key in newDirs) {
oldDir = oldDirs[key];
dir = newDirs[key];
//如果舊節點中沒有對應的指令,一般都是初始化的時候運行
if(!oldDir) {
//對該節點執行指令的bind鉤子函數
callHook$1(dir, 'bind', vnode, oldVnode);
//dir.def是我們所定義的指令的五個鉤子函數的集合
//如果我們的指令中存在inserted鉤子函數
if(dir.def && dir.def.inserted) {
//把該指令存入dirsWithInsert中
dirsWithInsert.push(dir);
}
} else {
//如果舊節點中有對應的指令,一般都是組件更新的時候運行
//那麼這裏進行更新操作,運行update鉤子(如果有的話)
//將舊值保存下來,供其他地方使用(僅在 update 和 componentUpdated 鉤子中可用)
dir.oldValue = oldDir.value;
//對該節點執行指令的update鉤子函數
callHook$1(dir, 'update', vnode, oldVnode);
//dir.def是我們所定義的指令的五個鉤子函數的集合
//如果我們的指令中存在componentUpdated鉤子函數
if(dir.def && dir.def.componentUpdated) {
//把該指令存入dirsWithPostpatch中
dirsWithPostpatch.push(dir);
}
}
}
//我們先來簡單講下mergeVNodeHook的作用
//mergeVNodeHook有三個參數,第一個參數是vnode節點,第二個參數是key值,第三個參數是回函數
//mergeVNodeHook會先用一個函數wrappedHook重新封裝回調,在這個函數裏運行回調函數
//如果該節點沒有這個key屬性,會新增一個key屬性,值爲一個數組,數組中包含上面說的函數wrappedHook
//如果該節點有這個key屬性,會把函數wrappedHook追加到數組中
//如果dirsWithInsert的長度不爲0,也就是在初始化的時候,且至少有一個指令中有inserted鉤子函數
if(dirsWithInsert.length) {
//封裝回調函數
var callInsert = function() {
//遍歷所有指令的inserted鉤子
for(var i = 0; i < dirsWithInsert.length; i++) {
//對節點執行指令的inserted鉤子函數
callHook$1(dirsWithInsert[i], 'inserted', vnode, oldVnode);
}
};
if(isCreate) {
//如果是新建/初始化組件,使用mergeVNodeHook綁定insert屬性,等待後面調用。
mergeVNodeHook(vnode, 'insert', callInsert);
} else {
//如果是更新組件,直接調用函數,遍歷inserted鉤子
callInsert();
}
}
//如果dirsWithPostpatch的長度不爲0,也就是在組件更新的時候,且至少有一個指令中有componentUpdated鉤子函數
if(dirsWithPostpatch.length) {
//使用mergeVNodeHook綁定postpatch屬性,等待後面子組建全部更新完成調用。
mergeVNodeHook(vnode, 'postpatch', function() {
for(var i = 0; i < dirsWithPostpatch.length; i++) {
//對節點執行指令的componentUpdated鉤子函數
callHook$1(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode);
}
});
}
//如果不是新建/初始化組件,也就是說是更新組件
if(!isCreate) {
//遍歷舊節點中的指令
for(key in oldDirs) {
//如果新節點中沒有這個指令(舊節點中有,新節點沒有)
if(!newDirs[key]) {
//從舊節點中解綁,isDestroy表示組件是不是註銷了
//對舊節點執行指令的unbind鉤子函數
callHook$1(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy);
}
}
}
}
callHook$1函數
function callHook$1(dir, hook, vnode, oldVnode, isDestroy) {
var fn = dir.def && dir.def[hook];
if(fn) {
try {
fn(vnode.elm, dir, vnode, oldVnode, isDestroy);
} catch(e) {
handleError(e, vnode.context, ("directive " + (dir.name) + " " + hook + " hook"));
}
}
}
解決
看過了源碼,我們再回到上面的bug,我們應該如何去解決呢?
1、事件解綁,重新綁定
我們在bind鉤子中綁定了事件,當數據更新後,會運行update鉤子,所以我們可以在update中先解綁再重新進行綁定。因爲bind和update中的內容差不多,所以我們可以把bind和update合併爲同一個函數,在用自定義指令的簡寫方法寫成下面的代碼:
Vue.directive('my-click', function(el, binding, vnode, oldVnode){
//點擊事件的回調掛在在元素myClick屬性上
el.myClick && el.removeEventListener('click', el.myClick);
el.addEventListener('click', el.myClick = function(){
console.log(el, binding.value)
})
})
可以看到,數據已經變成我們想要的數據了。
2、把binding掛在到元素上,更新數據後更新binding
我們已經知道了,造成問題的根本原因是初始化運行bind鉤子的時候爲元素綁定事件,事件內獲取的數據是初始化的時候傳遞過來的數據,因爲形成了閉包,那麼我們不使用能引起閉包的數據,把數據存到某一個地方,然後去更新這個數據。
Vue.directive('my-click',{
bind: function(el, binding, vnode, oldVnode){
el.binding = binding
el.addEventListener('click', function(){
var binding = this.binding
console.log(this, binding.value)
})
},
update: function(el, binding, vnode, oldVnode){
el.binding = binding
}
})
這樣也能達到我們想要的效果。
3、更新父元素
如果我們爲父元素ul綁定一個變化的key值,這樣,當數據變更的時候就會更新父元素,從而重新創建子元素,達到重新綁定指令的效果。
<ul :key="Date.now()">
<li v-for="(item,index) in arr" :key="index" v-my-click="item">{{item}}</li>
</ul>
這樣也能達到我們想要的效果。