事情是這樣的,遇到了一個問題:
使用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實例就會被釋放了。