在vue
內部初始化時會為每個元件例項掛載一個this._events
私有的空物件屬性:
vm._events = Object.create(null) // 沒有__proto__屬性
複製程式碼
這個裡面存放的就是當前例項上的自定義事件集合,也就是自定義事件中心,它存放著當前元件所有的自定義事件。和自定義事件相關的API
分為以下四個:this.$on
、this.$emit
、this.$off
、this.$once
,它們會往這個事件中心中新增、觸發、移除對應的自定義事件,從而組成了vue
的自定義事件系統,接下來看下它們都是怎麼實現的。
-
this.$on
描述:監聽當前例項上的自定義事件。事件可以由
vm.$emit
觸發,回撥函式會接收所有傳入事件觸發函式的額外引數。
export default {
created() {
this.$on('test', res => {
console.log(res)
})
},
methods: {
handleClick() {
this.$emit('test', 'hello-vue~')
}
}
}
複製程式碼
以上示例首先在created
鉤子內往當前元件例項的事件中心_events
中新增一個名為test
的自定義事件,第二個引數為該自定義事件的回撥函式,而觸發handleClick
這個方法後,就會在事件中心中嘗試找到test
自定義事件,觸發它並傳遞給回撥函式hello-vue~
這個字串,從而列印出來。我們來看下$on
的實現:
Vue.prototype.$on = function (event, fn) {
const hookRE = /^hook:/ //檢測自定義事件名是否是hook:開頭
const vm = this
if (Array.isArray(event)) { // 如果第一個引數是陣列
for (let i = 0; i < event.length; i++) {
this.$on(event[i], fn) // 遞迴
}
} else {
(vm._events[event] || (vm._events[event] = [])).push(fn)
// 如果有對應事件名就push,沒有建立為空陣列然後push
if (hookRE.test(event)) { // 如果是hook:開頭
vm._hasHookEvent = true // 標誌位為true
}
}
return vm
}
複製程式碼
以上就是$on
的實現了,它接受兩個引數,自定義事件名event
和對應的回撥函式fn
。主要就是往事件中心_events
下掛載對應的event
事件名key
,而事件名對應的key
又是一個陣列形式,這樣相同事件名的回撥會在一個陣列之內。而接下來的_hasHookEvent
標誌位表示是否監聽元件的鉤子函式,這個之後示例說明。
-
this.$emit
描述:觸發當前例項上的事件,附加引數都會傳給監聽器回撥。
Vue.prototype.$emit = function (event) {
const vm = this
let cbs = vm._events[event] // 找到事件名對應的回撥集合
if (cbs) {
const args = toArray(arguments, 1) // 將附加引數轉為陣列
for (let i = 0; i < cbs.length; i++) {
cbs[i].apply(vm, args) // 挨個執行對應的回撥集合
}
}
return vm
}
複製程式碼
而$emit
的實現會更好理解些,首先從事件中心中找到event
對應的回撥集合,然後將$emit
其餘引數轉為args
陣列,最後挨個執行回撥集合內的回撥並傳入args
。通過這麼一對樸實的API
可以幫我們理解三件小事:
1. 理解自定義事件原理
app.vue
<template>
<child-component @test='handleTest' />
</template>
export default {
methods: {
handleTest(res) {
console.log(res)
}
}
}
----------------------------------------
child.vue
<template>
<button @click='onClick'>btn</button>
</template>
export default {
methods: {
onClick() {
this.$emit('test', 'hello-vue~')
}
}
}
複製程式碼
以上是父子元件通過自定義事件通訊,想必大家非常熟悉。自定義事件的實現原理和通常解釋的會不同,它們的原理是父元件在經過編譯模板後,會將定義在子元件上的自定義事件test
及其回撥handleTest
通過$on
新增到子元件的事件中心中,當子元件通過$emit
觸發test
自定義事件時,會在它的事件中心中去找test
,找到後傳遞hello-vue~
給回撥函式並執行,不過因為回撥函式handleTest
是在父元件作用域內定義的,所以看起來就像是父子元件之間通訊般。
2. 監聽元件的鉤子函式
也就是$on
內自定義事件名之前是hook:
的情況,可以監聽元件的鉤子函式觸發:
app.vue
<template>
<child-component @hook:created='handleHookEvent' />
</template>
複製程式碼
以上示例為當子元件的created
鉤子觸發時,就觸發父元件內定義的handleHookEvent
回撥。接下來讓我們再看一個官網的示例,使用這個特性如何幫我們寫出更優雅的程式碼:
監聽元件鉤子之前:
mounted () {
this.picker = new Pikaday({ // Pikaday是一個日期選擇庫
field: this.$refs.input,
format: 'YYYY-MM-DD'
})
},
beforeDestroy () { // 銷燬日期選擇器
this.picker.destroy()
}
監聽元件鉤子之後:
mounted() {
this.attachDatepicker('startDateInput')
this.attachDatepicker('endDateInput') // 同時為兩個input新增日期選擇
},
methods: {
attachDatepicker(refName) { // 封裝為一個方法
const picker = new Pikaday({ // Pikaday是一個日期選擇庫
field: this.$refs[refName], // 為input新增日期選擇
format: 'YYYY-MM-DD'
})
this.$once('hook:beforeDestroy', () => { // 監聽beforeDestroy鉤子
picker.destroy() // 銷燬日期選擇器
}) // $once和$on類似,只是只會觸發一次
}
}
複製程式碼
首先不用在當前例項下掛載一個額外的屬性,其次可以封裝為一個方法,複用更方便。
3. 不借助
vuex
跨元件通訊
再開發元件庫時,因為都是獨立的元件,從而引入vuex
這種強依賴是不現實的,而且很多時候是用插槽來放置子元件,所以子元件的位置、巢狀、數量並不會確定,從而在元件庫內完成跨元件的通訊就尤為重要。
通過接下來的示例介紹元件庫中會運用到的一種,使用$on
和$emit
來實現跨元件通訊,子元件通過父元件的name
屬性找到對應的例項,找到後使用$emit
觸發父元件的自定義事件,而在這之前父元件已經使用$on
完成了自定義事件的新增:
export default {
methods: { // 混入mixin使用
dispatch(componentName, eventName, params) {
let parent = this.$parent || this.$root // 找父元件
let name = parent.$options.name // 父元件的name屬性
while (parent && (!name || name !== componentName)) { // 和傳入的componentName進行匹配
parent = parent.$parent // 一直向上查詢
if (parent) {
name = parent.$options.name // 重新賦值name
}
}
if (parent) { // 找到匹配的元件例項
parent.$emit.apply(parent, [eventName].concat(params)) // $emit觸發自定義事件
}
}
}
}
複製程式碼
接下來介紹表單驗證元件內的使用案例:
不知道大家是否對這種表單驗證好奇過,為什麼點一下提交,就可以將所有的表單項全部做驗證,接下來筆者試著寫一個極簡的表單驗證元件來說明它的原理。這裡會有兩個元件,一個是iForm
為整個表單,一個是iFormItem
為其中的某個表單項:
iForm元件:
<template>
<div> <slot /> </div> // 只有一個插槽
</template>
<script>
export default {
name: "iForm", // 元件名很重要
data() {
return {
fields: [] // 收集所有表單項的集合
};
},
created() {
this.$on("on-form-item-add", field => { // $on必須得比$emit先執行,因為要先新增嘛
this.fields.push(field) // 新增到集合內
});
},
methods: {
validataAll() { // 驗證所有的介面方法
this.fields.forEach(item => {
item.validateVal() // 執行每個表單項內的validateVal方法
});
}
}
};
</script>
複製程式碼
模板只有一個slot
插槽,這個元件主要是做兩件事,將所有的表單項的例項收集到fields
內,提供一個可以驗證所有表單項的方法validataAll
,然後看下iFormItem
元件:
<template>
<div>
<input v-model="curValue" style="border: 1px solid #aaa;" />
<span style="color: red;" v-show="showTip">輸入不能為空</span>
</div>
</template>
<script>
import emitter from "./emitter" // 引入之前的dispatch方法
export default {
name: "iFormItem",
mixins: [emitter], // 混入
data() {
return {
curValue: "", // 表單項的值
showTip: false // 是否驗證通過
};
},
created() {
this.dispatch("iForm", "on-form-item-add", this) // 將當前例項傳給iForm元件
},
methods: {
validateVal() { // 某個表單項的驗證方法
if (this.curValue === "") { // 不能為空
this.showTip = true // 驗證不通過
}
}
}
};
</script>
複製程式碼
看到這裡我們知道了原來這種表單驗證原理是將每個表單項的例項傳入給iForm
,然後在iForm
內遍歷的執行每個表單項的驗證方法,從而可以一次性驗證完所有的表單項。表單驗證呼叫方式:
<template>
<div>
<i-form ref='form'> // 引用
<i-form-item />
<i-form-item />
<i-form-item />
<i-form-item />
<i-form-item />
</i-form>
<button @click="submit">提交</button>
</div>
</template>
<script>
import iForm from "./form"
import iFormItem from "./form-item"
export default {
methods: {
submit() {
this.$refs['form'].validataAll() // 驗證所有
}
},
components: {
iForm, iFormItem
}
};
</script>
複製程式碼
這裡就使用了$on
和$emit
這麼一對API
,通過元件的名稱去查詢元件例項,不論巢狀以及數量,然後使用事件API
去跨元件傳遞引數。
注意點:當
$on
和$emit
配合使用時,$on
要優先與$emit
執行。因為首先要往例項的事件中心去新增事件,才能被觸發。
-
this.$off
描述:移除自定義事件監聽器,不過根據傳入的引數分為三種形式:
- 如果沒有提供引數,則移除所有的事件監聽器;
- 如果只提供了事件,則移除該事件所有的監聽器;
- 如果同時提供了事件與回撥,則只移除這個回撥的監聽器。
export default {
created() {
this.$on('test1', this.test1)
this.$on('test2', this.test2)
},
mounted() {
this.$off() // 沒有引數,清空事件中心
}
}
-------------------------------------------
export default {
created() {
this.$on('test1', this.test1)
this.$on('test2', this.test2)
},
mounted() {
this.$off('test1') // 在事件中心中移除test1
}
}
-------------------------------------------
export default {
created() {
this.$on('test1', this.test1)
this.$on('test1', this.test3)
this.$on('test2', this.test2)
},
mounted() {
this.$off('test1', this.test3) // 在事件中心中移除事件test1的test3回撥
}
}
複製程式碼
知道了這個API
的呼叫方式之後,接下來看下$off
的實現方式:
Vue.prototype.$off = function (event, fn) {
const vm = this
if (!arguments.length) { // 如果沒有傳遞引數
vm._events = Object.create(null) // 重置事件中心
return vm
}
if (Array.isArray(event)) { // event如果是陣列
for (let i = 0, l = event.length; i < l; i++) {
vm.$off(event[i], fn) // 遞迴清空
}
return vm
}
if (!fn) { // 只傳遞了事件名沒回撥
vm._events[event] = null // 清空對應所有的回撥
return vm
}
const cbs = vm._events[event] // 獲取回撥集合
let cb
let i = cbs.length
while (i--) {
cb = cbs[i] // 回撥集合裡的每一項
if (cb === fn || cb.fn === fn) { // cb.fn為$once時掛載的
cbs.splice(i, 1) // 找到對應的回撥,從集合內移除
break
}
}
return vm
}
複製程式碼
也是分為了三種情況,根據引數的不同做分別處理。
-
this.$once
描述:監聽一個自定義事件,但是隻觸發一次,在第一次觸發之後移除監聽器。
效果和$on
是類似的,只是說觸發一次之後會從事件中心中移除。所以它的實現思路也很好理解,首先通過$on
實現功能,當觸發之後從事件中心中移除這個事件。來看下它的實現原理:
Vue.prototype.$once = function (event, fn) {
const vm = this
function on () {
vm.$off(event, on)
fn.apply(vm, arguments)
}
on.fn = fn // 回撥掛載到on下,移除時好做判斷
vm.$on(event, on) // 將on新增到事件中心中
return vm
}
複製程式碼
首先將回撥fn
掛載到on
函式下,將on
函式註冊到事件中心去,觸發自定義事件時首先會在$emit
內執行on
函式,在on
函式內執行$off
將on
函式移除,然後執行傳入的fn
回撥。這個時候事件中心沒有了on
函式,回撥函式也執行了一次,完成$once
功能~
事件
API
總結:$on
往事件中心新增事件;$emit
是觸發事件中心裡的事件;$off
是移除事件中心裡的事件;$once
是觸發一次事件中心裡的事件。哪怕是如此不顯眼的API
,再理解了它們的實現原理後,也能讓我們再更多場景更好的使用它們~
最後按照慣例我們還是以一道vue
可能會被問到的面試題作為本章的結束(想不到事件相關特別好的題目~)。
面試官微笑而又不失禮貌的問道:
- 說下自定義事件的機制。
懟回去:
- 子元件使用
this.$emit
觸發事件時,會在當前例項的事件中心去查詢對應的事件,然後執行它。不過這個事件回撥是在父元件的作用域裡定義的,所以$emit
裡的引數會傳遞給父元件的回撥函式,從而完成父子元件通訊。
順手點個贊或關注唄,找起來也方便~
參考:
分享一個元件庫給大家,可能會用的上 ~ ↓