Vue原理解析(十):搞懂事件API原理及在組件庫中的妙用

上一篇:Vue原理解析(九):搞懂computed和watch原理,減小使用場景思考時間html

vue內部初始化時會爲每一個組件實例掛載一個this._events私有的空對象屬性:vue

vm._events = Object.create(null) // 沒有__proto__屬性
複製代碼

這個裏面存放的就是當前實例上的自定義事件集合,也就是自定義事件中心,它存放着當前組件全部的自定義事件。和自定義事件相關的API分爲如下四個:this.$onthis.$emitthis.$offthis.$once,它們會往這個事件中心中添加、觸發、移除對應的自定義事件,從而組成了vue的自定義事件系統,接下來看下它們都是怎麼實現的。面試

  • this.$on

描述:監聽當前實例上的自定義事件。事件能夠由vm.$emit觸發,回調函數會接收全部傳入事件觸發函數的額外參數。vuex

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標誌位表示是否監聽組件的鉤子函數,這個以後示例說明。bash

  • this.$emit

描述:觸發當前實例上的事件,附加參數都會傳給監聽器回調。app

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是在父組件做用域內定義的,因此看起來就像是父子組件之間通訊般。post

2. 監聽組件的鉤子函數

也就是$on內自定義事件名以前是hook:的狀況,能夠監聽組件的鉤子函數觸發:ui

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函數內執行$offon函數移除,而後執行傳入的fn回調。這個時候事件中心沒有了on函數,回調函數也執行了一次,完成$once功能~

事件API總結:$on往事件中心添加事件;$emit是觸發事件中內心的事件;$off是移除事件中內心的事件;$once是觸發一次事件中內心的事件。哪怕是如此不顯眼的API,再理解了它們的實現原理後,也能讓咱們再更多場景更好的使用它們~

最後按照慣例咱們仍是以一道vue可能會被問到的面試題做爲本章的結束(想不到事件相關特別好的題目~)。

面試官微笑而又不失禮貌的問道:

  • 說下自定義事件的機制。

懟回去:

  • 子組件使用this.$emit觸發事件時,會在當前實例的事件中心去查找對應的事件,而後執行它。不過這個事件回調是在父組件的做用域裏定義的,因此$emit裏的參數會傳遞給父組件的回調函數,從而完成父子組件通訊。

下一篇:Vue原理解析(十一):搞懂extend和$mount原理並實現一個命令式Confirm彈窗組件

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索