關於vue元件的destory和事件傳遞的一些問題
事情是這樣的,遇到了一個問題:
使用vue進行開發的時候,在一個元件中使用事件匯流排進行事件監聽之後,當元件銷燬後該事件依然被監聽。
背景
vue對於跨元件的事件監聽處理有一個逐漸變遷的過程。
$dispath和$broadcast
在新版vue中廢棄了舊版的一種事件傳遞方式。使用dispath和broadcast兩種方式進行事件的傳遞響應。
-
是由子元件發起事件通知,向其父元件鏈中尋找對應的事件監聽。直到找到最近的父元件的一個事件監聽之後停止尋找,除非監聽器返回true。(如果該子元件存在對該事件的監聽也會被觸發)
Usage: Dispatch an event, first triggering it on the instance itself, and then propagates upward along the parent chain. The propagation stops when it triggers a parent event listener, unless that listener returns true. Any additional arguments will be passed into the listener’s callback function.
-
則是由父元件向其子元件中傳遞訊息,在子元件鏈路中找到事件監聽之後,停止尋找,除非監聽器返回true
Usage: Broadcast an event that propagates downward to all descendants of the current instance. Since the descendants expand into multiple sub-trees, the event propagation will follow many different “paths”. The propagation for each path will stop when a listener callback is fired along that path, unless the callback returns true.
後來這種方案就被廢棄了。因為
因為基於元件樹結構的事件流方式實在是讓人難以理解,並且在元件結構擴充套件的過程中會變得越來越脆弱。這種事件方式確實不太好,我們也不希望在以後讓開發者們太痛苦。並且 broadcast 也沒有解決兄弟元件間的通訊問題。
後來的設計也就改成了子元件使用$emit可以通知父元件,然後父元件在該元件的子元件上用@事件名稱監聽回撥。但是這種方式無法進行更廣泛的事件通知監聽。官方文件建議在簡單的情況下可以使用使用全域性vue例項的方法進行事件通知。
使用vue事件匯流排
在簡單情況下,我們可以新建一個全域性的vue例項,使用$on, $off和$emit三個函式構建一個事件通知體系。
let bus = new Vue()
... A Vue Component
methods: {
hello () {
bus.$emit('eventName')
}
}
... B Vue Component
methods: {
mounted () {
bus.$on('eventName', () => {
// answer the event
})
},
destoryed () {
bus.$off('eventName')
}
}
使用這種方式就可以實現在整個工程中的事件通知操作。
回到問題
有一次筆者在進行開發的時候沒有對事件進行解綁的操作,也就是沒有在destoryed函式中呼叫$off進行事件解綁。事件中包含http請求的函式。然後在後續的操作中有一些重新掛載該元件的操作。檢查devtool的network時發現多次發出該請求。定位後發現是$on的事件被多次執行。遂在destoryed中$off掉該事件則沒有該問題。
筆者於是產生了些疑惑,決定模擬一下該問題並去vue的原始碼一探究竟,畢竟vue的原始碼也就萬把來行嘛(手動滑稽)
情景復現
編寫了一段程式碼復現了上文中出現的問題。不是特別長就貼上來了。
復現程式碼
<html>
<body>
<div id="app">
<button v-on:click="hide">{{ message }}</button>
<my v-if="show"></my>
<my v-if="show"></my>
<my v-if="show"></my>
<button v-on:click="print">觸發匯流排事件</button>
<button v-on:click="printBus">列印事件匯流排</button>
<button v-on:click="printApp">列印app例項</button>
</div>
<script src="./vue.js"></script>
<script type="text/javascript">
let bus = new Vue()
Vue.component('my', {
data () {
return {
nowIndex: 1
}
},
template: `
<div>
元件運算元:{{ nowIndex }} </br>
<button v-on:click="nowIndex ++">新增</button><button v-on:click="selfDestory">呼叫元件destory函式</button>
</div>
`,
mounted () {
bus.$on('eventone', value => {
console.log('id: ', this._uid, ' \'s nowIndex is', this.nowIndex)
})
},
destroyed () {
console.log('hello destoryed')
},
methods: {
selfDestory () {
this.$destroy()
}
}
})
let app = new Vue({
el: '#app',
data: {
message: '隱藏',
show: true
},
methods: {
hide () {
this.show = !this.show
if (this.show) {
this.message = '隱藏'
} else {
this.message = '顯示'
}
},
print () {
bus.$emit('eventone')
},
printBus () {
console.log(bus)
},
printApp () {
console.log(this)
}
}
})
</script>
</body>
</html>
復現操作
先對頁面進行操作之後,觸發匯流排事件
隨後取消三個元件的掛載
再度掛載三個元件
可以看出控制檯依舊打印出了三個已經通過v-if取消掛載的元件,destoryed函式也被觸發了
提出問題
- 為什麼事件會觸發多次?
- 為什麼已經銷燬的元件裡面的事件依舊會被觸發?
尋找答案
先去到了vue的事件繫結函式$on中
Vue.prototype.$on = function (event, fn) { var this$1 = this; var vm = this; if (Array.isArray(event)) { for (var i = 0, l = event.length; i < l; i++) { this$1.$on(event[i], fn); } } else { (vm._events[event] || (vm._events[event] = [])).push(fn); // optimize hook:event cost by using a boolean flag marked at registration // instead of a hash lookup if (hookRE.test(event)) { vm._hasHookEvent = true; } } return vm };
這段程式碼其實已經比較清晰了,通過
(vm._events[event] || (vm._events[event] = [])).push(fn)
可以看出vue對事件監聽的繫結方法其實是將同名事件的處理函式存放到一個數組中後續按存放順序呼叫。所以事件可以被觸發多次。再看一下vue的銷燬元件函式。
Vue.prototype.$destroy = function () { var vm = this; if (vm._isBeingDestroyed) { return } callHook(vm, 'beforeDestroy'); vm._isBeingDestroyed = true; // remove self from parent var parent = vm.$parent; if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) { remove(parent.$children, vm); } // teardown watchers if (vm._watcher) { vm._watcher.teardown(); } var i = vm._watchers.length; while (i--) { vm._watchers[i].teardown(); } // remove reference from data ob // frozen object may not have observer. if (vm._data.__ob__) { vm._data.__ob__.vmCount--; } // call the last hook... vm._isDestroyed = true; // invoke destroy hooks on current rendered tree vm.__patch__(vm._vnode, null); // fire destroyed hook callHook(vm, 'destroyed'); // turn off all instance listeners. vm.$off(); // remove __vue__ reference if (vm.$el) { vm.$el.__vue__ = null; } // release circular reference (#6759) if (vm.$vnode) { vm.$vnode.parent = null; } };
筆者看這段程式碼原先以為vue通過這個函式對元件進行了銷燬,可是事情並沒有想象中的這麼簡單。甚至有那麼點疑惑。於是決定在控制檯試一下這個方法。
從圖可以看出呼叫$destroy()函式後,vue元件確實走了destoryed()方法,也就是說確實成功銷燬了。而此時該元件的事件響應依然被觸發,而且此時瀏覽器上的dom並沒有被移除。於是又回去看原始碼,發現有remove和patch兩個類似移除的函式,這裡篇幅問題不再貼上原始碼了,不過看完後發現後整個刪除邏輯只是把虛擬dom給刪除了而已,並沒有刪除已經渲染的dom。文件中給出的解釋是
完全銷燬一個例項。清理它與其它例項的連線,解綁它的全部指令及事件監聽器。
文件這句話其實是有點迷的,個人覺得後面一句是在對第一句的解釋。$destory函式只是在清理它和其它例項的連線和解除指令以及事件監聽器,還有斷掉虛擬dom和真實dom之間的聯絡。而並真正地沒有回收這個vue例項。而且由於vue的$on只是綁定了函式,\$destory也沒有將註冊在其它vue例項的事件給銷燬掉,所以這個及時destory後匯流排的事件依舊被執行,而且由於註冊事件的vue例項沒有被回收,所以還可以進行常規的資料互動操作。
至於vue例項什麼時候回收,這其實本質上是一個js的記憶體回收問題。只要存在還有其他物件對該例項的引用的話,這個例項還是不會被回收的。噹噹前程式沒有對這個例項的引用的時候,這個vue例項就會被釋放了。