一、初始位置
平常項目中寫邏輯,避免不了注冊/觸發各種事件
今天來研究下 Vue 中,我們平常用到的關於 on/emit/off/once 的實現原理
關於事件的方法,是在 Vue 項目下面文件中的 eventsMixin 注冊的
src/core/instance/index.js
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
function Vue(options) {
if (process.env.NODE_ENV !== 'production' && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue) // 此處初始化 Vue 關於事件相關的實例方法
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue
二、源碼解析
進入到 src/core/instance/events.js
文件中
這邊提取了 on/emit/off/once 的相關代碼,並做了注釋
src/core/instance/events.js
/**
* @describtion 注冊事件以及觸發事件時要執行的函數
* @param event {string | Array<string>} 要注冊的事件名,可以是個字符串,也可以是個數組,數組元素也是字符串
* @param fn {Function} 要注冊的事件函數
* @return Component 返回 Vue 實例
*/
Vue.prototype.$on = function(event: string | Array<string>, fn: Function): Component {
const vm: Component = this
// 先判斷傳進來的 event 是否是個數組
if (Array.isArray(event)) {
// 是數組,則循環進行事件注冊
// 多個事件名可以綁定同個函數
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
// event 不是數組
// event 是個字符串
// 先判斷 vm._events[event] 是否存在, 不存在則設置為空數組 []
// 這個 vm._events 在 new Vue 時候, Vue 里面 執行 this._init() 中
// 執行了 initEvents(vm)
// 在 initEvents(vm) 中
// export function initEvents(vm: Component) {
// vm._events = Object.create(null) // 這里創建了 _events 這個對象,用來存儲事件
// vm._hasHookEvent = false
// // init parent attached events
// const listeners = vm.$options._parentListeners
// if (listeners) {
// updateComponentListeners(vm, listeners)
// }
// }
;(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
// 在注冊的時候使用標記過的布爾值代替哈希查找,消費hook事件
if (hookRE.test(event)) {
vm._hasHookEvent = true
}
}
return vm
}
/**
* @describtion 和 $on 一樣,注冊事件以及觸發事件時要執行的函數,但是只執行一次就銷毀
* @param event {string} 要注冊的事件名,是個字符串
* @param fn {Function} 要注冊的事件函數
* @return Component 返回 Vue 實例
*/
Vue.prototype.$once = function(event: string, fn: Function): Component {
const vm: Component = this
// 將目標函數 fn 包裝起來
// 注冊時候使用包裝的 on 函數注冊
// 這樣 on 函數被執行一次時,首先把自己從注冊事件列表中銷毀
// 然后執行實際的目標函數 fn
// 如果是一開始就使用目標函數 fn 注冊
// 然后在目標函數 fn 執行時候,銷毀fn
// 做不到銷毀自己的同時還能執行自己,所以需要把fn進行一次包裝
function on() {
vm.$off(event, on)
fn.apply(vm, arguments)
}
// 因為對目標函數做了包裝,此處是方便銷毀事件時候做判斷是否有事件要銷毀以及要銷毀的是哪個 fn
on.fn = fn
vm.$on(event, on)
return vm
}
/**
* @describtion 銷毀事件以及觸發事件時要執行的函數
* @param event? {string | Array<string>} 可選。要銷毀的事件名,可以是個字符串,也可以是個數組,數組元素也是字符串
* @param fn? {Function} 要銷毀的事件函數 可選。
* @return Component 返回 Vue 實例
*/
Vue.prototype.$off = function(event?: string | Array<string>, fn?: Function): Component {
const vm: Component = this
// all
// 如果沒有參數,則將 vm_events 設置為空,表示銷毀全部事件
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// array of events
// 如果 event 是個數組,則遍歷 event,對每個事件進行銷毀
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// specific event
// 上面兩個是特殊情況,這里才是正常銷毀邏輯
// 先通過傳入的 event 字符串從 _events 對象中去取值
// 判斷該 事件名底下是否有綁定的目標函數,沒有則返回當前組件實例,啥也不做
const cbs = vm._events[event]
if (!cbs) {
return vm
}
// 或者沒有傳入之前注冊時候的目標函數
// 那么就將 event 對應的所有目標函數都銷毀
// vm._events[event] = null
if (!fn) {
vm._events[event] = null
return vm
}
// specific handler
// 如果有傳入 目標函數
// 對取出的 event 對應的 目標函數進行倒序遍歷
// vm._events[event] 的值,經過前面的過濾,到這里一定是個數組
// 倒序遍歷一個個數組元素,判斷每一個元素與傳入要銷毀的目標函數是否相等
// 相等,則使用 splice 進行刪除
// 刪除數組的操作使用倒序處理,不至於在刪除元素的時候,后續的元素序號向前進位,導致處理結果有誤
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
/**
* @describtion 觸發事件
* @param event {string} 要觸發的事件名,是個字符串
* @return Component 返回 Vue 實例
*/
Vue.prototype.$emit = function(event: string): Component {
const vm: Component = this
// 此處是開發環境代碼,可以忽略
if (process.env.NODE_ENV !== 'production') {
const lowerCaseEvent = event.toLowerCase()
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
tip(`Event "${lowerCaseEvent}" is emitted in component ` + `${formatComponentName(vm)} but the handler is registered for "${event}". ` + `Note that HTML attributes are case-insensitive and you cannot use ` + `v-on to listen to camelCase events when using in-DOM templates. ` + `You should probably use "${hyphenate(event)}" instead of "${event}".`)
}
}
// 通過傳入的 event 從 _events 對象中獲取目標函數
let cbs = vm._events[event]
if (cbs) {
// 如果有相應的目標函數
// Convert an Array-like object to a real Array.
// toArray 將一個類數組轉換成真正的數組
cbs = cbs.length > 1 ? toArray(cbs) : cbs
// 獲取除了第一個事件名之外的其他參數
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
// 對得到的目標函數進行遍歷,並傳入相關參數
for (let i = 0, l = cbs.length; i < l; i++) {
// 該函數調用了當前目標函數,並處理目標函數的異常
// 比如 目標函數 返回一個 Promise 這里添加了 catch 處理
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
三、實現例子
項目地址放在github上了,有需要的可以看下
Vue 的事件方法類實現例子
模式實現一個 Vue 的事件方法類
class VueEvent {
constructor() {
this._events = Object.create(null)
}
$on(event, fn) {
const vm = this
// 先判斷傳進來的 event 是否是個數組
if (Array.isArray(event)) {
// 是數組,則循環進行事件注冊
// 多個事件名可以綁定同個函數
for (let i = 0, l = event.length; i < l; i++) {
vm.$on(event[i], fn)
}
} else {
// 先判斷 vm._events[event] 是否存在, 不存在則設置為空數組 []
;(vm._events[event] || (vm._events[event] = [])).push(fn)
}
return vm
}
$once(event, fn) {
const vm = this
// 將目標函數 fn 包裝起來
// 注冊時候使用包裝的 on 函數注冊
// 這樣 on 函數被執行一次時,首先把自己從注冊事件列表中銷毀
// 然后執行實際的目標函數 fn
// 如果是一開始就使用目標函數 fn 注冊
// 然后在目標函數 fn 執行時候,銷毀fn
// 做不到銷毀自己的同時還能執行自己,所以需要把fn進行一次包裝
function on() {
vm.$off(event, on)
fn.apply(vm, arguments)
}
// 因為對目標函數做了包裝,此處是方便銷毀事件時候做判斷,是否有事件要銷毀以及要銷毀的是哪個 fn
on.fn = fn
vm.$on(event, on)
return vm
}
$off(event, fn) {
const vm = this
// 如果沒有參數,則將 vm_events 設置為空,表示銷毀全部事件
if (!arguments.length) {
vm._events = Object.create(null)
return vm
}
// 如果 event 是個數組,則遍歷 event,對每個事件進行銷毀
if (Array.isArray(event)) {
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn)
}
return vm
}
// 上面兩個是特殊情況,這里才是正常銷毀邏輯
// 先通過傳入的 event 字符串從 _events 對象中去取值
// 判斷該 事件名底下是否有綁定的目標函數,沒有則返回當前組件實例,啥也不做
const cbs = vm._events[event]
if (!cbs) {
return vm
}
// 或者沒有傳入之前注冊時候的目標函數
// 那么就將 event 對應的所有目標函數都銷毀
// vm._events[event] = null
if (!fn) {
vm._events[event] = null
return vm
}
// 如果有傳入 目標函數
// 對取出的 event 對應的目標函數進行倒序遍歷
// vm._events[event] 的值,經過前面的過濾,到這里一定是個數組
// 倒序遍歷一個個數組元素,判斷每一個元素與傳入要銷毀的目標函數是否相等
// 相等,則使用 splice 進行刪除
// 刪除數組的操作使用倒序處理,不至於在刪除元素的時候,后續的元素序號向前進位,導致處理結果有誤
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
$emit(event) {
const vm = this
// 通過傳入的 event 從 _events 對象中獲取目標函數
let cbs = vm._events[event]
if (cbs) {
// 如果有相應的目標函數
// 獲取除了第一個事件名之外的其他參數
const args = Array.prototype.slice.call(arguments, 1)
// 對得到的目標函數進行遍歷,並傳入相關參數
for (let i = 0, l = cbs.length; i < l; i++) {
// 這里就不做 promise 的處理了,直接調用
cbs[i].apply(vm, args)
}
}
return vm
}
}
四、使用
let ev = new VueEvent()
// test $on
ev.$on('onEv', function(emitParam) {
console.log('test $on: ', emitParam)
console.log('onEv on')
console.log('\n************\n')
})
setTimeout(() => {
ev.$emit('onEv', 'emit 1')
}, 0)
setTimeout(() => {
ev.$emit('onEv', 'emit 2')
}, 1000)
// 輸出
// test $on: emit 1
// onEv on
// ************
// test $on: emit 2
// onEv on
// test $once
ev.$once('onceEv', function(emitParam) {
console.log('test $once: ', emitParam)
console.log('onceEv on')
console.log('\n************\n')
})
setTimeout(() => {
ev.$emit('onceEv', 'emit 3')
}, 2000)
setTimeout(() => {
ev.$emit('onceEv', 'emit 4')
}, 3000)
// 輸出
// test $once: emit 3
// onceEv on
// test $off
ev.$on('offEv', function(emitParam) {
console.log('test $off: ', emitParam)
console.log('offEv on')
console.log('\n************\n')
})
setTimeout(() => {
ev.$emit('offEv', 'emit 5')
}, 4000)
setTimeout(() => {
ev.$emit('offEv', 'emit 6')
ev.$off('offEv')
}, 5000)
setTimeout(() => {
ev.$emit('offEv', 'emit 7')
}, 6000)
// 輸出
// test $off: emit 5
// offEv on
// ************
// test $off: emit 6
// offEv on