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

飛躍瘋人院發表於2019-09-09

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

vue內部初始化時會為每個元件例項掛載一個this._events私有的空物件屬性:

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

這個裡面存放的就是當前例項上的自定義事件集合,也就是自定義事件中心,它存放著當前元件所有的自定義事件。和自定義事件相關的API分為以下四個:this.$onthis.$emitthis.$offthis.$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是在父元件作用域內定義的,所以看起來就像是父子元件之間通訊般。

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

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觸發自定義事件
      }
    }
  }
}
複製程式碼

接下來介紹表單驗證元件內的使用案例:

Vue原理解析(十):搞懂事件API原理及在元件庫中的妙用
不知道大家是否對這種表單驗證好奇過,為什麼點一下提交,就可以將所有的表單項全部做驗證,接下來筆者試著寫一個極簡的表單驗證元件來說明它的原理。這裡會有兩個元件,一個是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功能元件庫,持續完善中...

相關文章