Vue原始碼探究-事件系統

jylzs369發表於2019-02-26

本篇程式碼位於vue/src/core/instance/events.js

緊跟著生命週期之後的就是繼續初始化事件相關的屬性和方法。整個事件系統的程式碼相對其他模組來說非常簡短,分幾個部分來詳細看看它的具體實現。

頭部引用

import {
  tip,
  toArray,
  hyphenate,
  handleError,
  formatComponentName
} from '../util/index'
import { updateListeners } from '../vdom/helpers/index'
複製程式碼

頭部先是引用了的一些工具方法,沒有什麼難點,具體可以檢視相應檔案。唯一值得注意的是引用自虛擬節點模組的一個叫 updateListeners 方法。顧名思義,是用來更新監聽器的,至於為什麼要有這樣的一個方法,主要是因為如果該例項的父元件已經存在一些事件監聽器,為了正確捕獲到事件並向上冒泡,父級事件是需要繼承下來的,這個原因在下面的初始化程式碼中有佐證;另外,如果在例項初始化的時候繫結了同名的事件處理器,也需要為同名事件新增新的處理器,以實現同一事件的多個監聽器的繫結。

事件初始化

// 定義並匯出initEvents函式,接受Component型別的vm引數
export function initEvents (vm: Component) {
  // 建立例的_events屬性,初始化為空物件
  vm._events = Object.create(null)
  // 建立例項的_hasHookEvent屬性,初始化為false
  vm._hasHookEvent = false
  // 初始化父級附屬事件
  // init parent attached events
  const listeners = vm.$options._parentListeners
  // 如果父級事件存在,則更新例項事件監聽器
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}

// 設定target值,目標是引用例項
let target: any

// 新增事件函式,接受事件名稱、事件處理器、是否一次性執行三個引數
function add (event, fn, once) {
  if (once) {
    target.$once(event, fn)
  } else {
    target.$on(event, fn)
  }
}

// 移除事件函式,接受事件名稱和時間處理器兩個引數
function remove (event, fn) {
  target.$off(event, fn)
}

// 定義並匯出函式updateComponentListeners,接受例項物件,新舊監聽器引數
export function updateComponentListeners (
  vm: Component,
  listeners: Object,
  oldListeners: ?Object
) {
  // 設定target為vm
  target = vm
  // 執行更新監聽器函式,傳入新舊事件監聽物件、新增事件與移除事件函式、例項物件
  updateListeners(listeners, oldListeners || {}, add, remove, vm)
  // 置空引用
  target = undefined
}
複製程式碼

如上述程式碼所示,事件監聽系統的初始化首先是建立了私有的事件物件和是否有事件鉤子的標誌兩個屬性,然後根據父級是否有事件處理器來決定是否更新當前例項的事件監聽器,具體如何實現監聽器的更新,貼上這段位於虛擬節點模組的輔助函式中的程式碼片段來仔細看看。

更新事件監聽器

// 定義並匯出updateListeners哈數
// 接受新舊事件監聽器物件,事件新增和移除函式以及例項物件引數。
export function updateListeners (
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  vm: Component
) {
  // 定義一些輔助變數
  let name, def, cur, old, event
  // 遍歷新的監聽器物件
  for (name in on) {
    // 為def和cur賦值為新的事件物件
    def = cur = on[name]
    // 為old賦值為舊的事件物件
    old = oldOn[name]
    // 標準化事件物件並賦值給event。
    // normalizeEvent函式主要用於將傳入的帶有特殊字首的事件修飾符分解為具有特定值的事件物件
    event = normalizeEvent(name)
    // 下面程式碼是weex框架專用,處理cur變數和格式化好的事件物件的引數屬性
    /* istanbul ignore if */
    if (__WEEX__ && isPlainObject(def)) {
      cur = def.handler
      event.params = def.params
    }
    // 如果新事件不存在,在非生產環境中提供報錯資訊,否則不執行任何操作
    if (isUndef(cur)) {
      process.env.NODE_ENV !== 'production' && warn(
        `Invalid handler for event "${event.name}": got ` + String(cur),
        vm
      )
    // 當舊事件不存在時
    } else if (isUndef(old)) {
      // 如果新事件物件cur的fns屬性不存在
      if (isUndef(cur.fns)) {
        // 建立函式呼叫器並重新複製給cur和on[name]
        cur = on[name] = createFnInvoker(cur)
      }
      // 新增新的事件處理器
      add(event.name, cur, event.once, event.capture, event.passive, event.params)
    // 如果新舊事件不完全相等
    } else if (cur !== old) {
      // 用新事件處理函式覆蓋舊事件物件的fns屬性
      old.fns = cur
      // 將事件物件重新複製給on
      on[name] = old
    }
  }
  // 遍歷舊事件監聽器
  for (name in oldOn) {
    // 如果新事件物件不存在
    if (isUndef(on[name])) {
      // 標準化事件物件
      event = normalizeEvent(name)
      // 移除事件處理器
      remove(event.name, oldOn[name], event.capture)
    }
  }
}
複製程式碼

這段程式碼中用到了 normalizeEventcreateFnInvoker 兩個主要的函式來完成更新監聽器的實現,程式碼與 updateListeners 函式位於同一檔案中。

  • normalizeEvent:主要是用於返回一個定製化的事件物件,這個函式接受4個必選引數和2兩個可選引數,分別是事件名稱name屬性、是否一次性執行的once屬性、是否捕獲事件的capture屬性、是否使用被動模式passive屬性、事件處理器handler方法、事件處理器引數params陣列。屬性的含義都比較好理解,特別注意一下 oncecapturepassive 屬性,這三個屬性是用來修飾事件的,分別對應了 ~!& 修飾符,貼上一個官方文件中的使用示例,引用自事件 & 按鍵修飾符。啟動被動模式的用途是使事件處理器無法阻止預設事件,比如 <a> 標籤自帶的連結跳轉事件,如果設定passive為true,則事件處理器即便是設定了阻止預設事件也是沒辦法阻止跳轉的。
on: {
  '!click': this.doThisInCapturingMode,
  '~keyup': this.doThisOnce,
  '~!mouseover': this.doThisOnceInCapturingMode
}
複製程式碼
  • createFnInvoker: 接受一個fns引數,可以傳入一個事件處理器函式,也可以傳入一個包含多個處理器的陣列。在該函式內部定義了一個 invoker 函式並且最終返回它,函式有一個fns屬性是用來存放所傳入的處理器的,呼叫這個函式後,會按fns的型別來分別執行處理器陣列的呼叫或單個處理器的呼叫。這個實現即是真正執行事件處理器呼叫的過程。

事件相關的原型方法

在事件的初始化過程裡有用到幾個以 & 開頭的類原型方法,它們是在mixin函式裡掛載到核心類上的。初始化的時候定義的方法都是在這些方法的基礎上再進行了一次封裝,其繫結事件、觸發事件和移除事件的具體實現都在這些方法中,當然不會放過對這些細節的探索。

// 匯出eventsMixin函式,接收形參Vue,
// 使用Flow進行靜態型別檢查指定為Component類
export function eventsMixin (Vue: Class<Component>) {
  // 定義hook正則檢驗
  const hookRE = /^hook:/
  // 給Vue原型物件掛載$on方法
  // 引數event可為字串或陣列型別,fn是事件監聽函式
  // 方法返回例項物件本身
  Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
    // 定義例項變數
    const vm: Component = this
    // 如果傳入的event引數是陣列,遍歷event陣列,為所有事件註冊fn監聽函式
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        this.$on(event[i], fn)
      }
    } else {
      // event引數為字串時,檢查event事件監聽函式陣列是否存在
      // 已存在事件監聽陣列則直接新增新監聽函式
      // 否則建立空的event事件監聽函式陣列,再新增新監聽函式
      (vm._events[event] || (vm._events[event] = [])).push(fn)
      // 此處做了效能優化,使用正則檢驗hook:是否存在的布林值
      // 而不是hash值查詢設定例項物件的_hasHookEvent值
      // 此次優化是很久之前版本的修改,暫時不太清楚以前hash值查詢是什麼邏輯,留待以後查證
      // 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
  }
  // 為Vue原型物件掛載$once方法
  // 引數event只接受字串,fn是監聽函式
  Vue.prototype.$once = function (event: string, fn: Function): Component {
    // 定義例項變數
    const vm: Component = this
    // 建立on函式
    function on () {
      // 函式執行後先清除event事件繫結的on監聽函式,即函式本身
      // 這樣以後就不會再繼續監聽event事件
      vm.$off(event, on)
      // 在例項上執行fn監聽函式
      fn.apply(vm, arguments)
    }
    // 為on函式設定fn屬性,保證在on函式內能夠正確找到fn函式
    on.fn = fn
    // 為event事件註冊on函式
    vm.$on(event, on)
    // 返回例項本身
    return vm
  }
  // 為Vue原型物件掛載$off方法
  // event引數可為字串或陣列型別
  // fn是監聽函式,為可選引數
  Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
    // 定義例項變數
    const vm: Component = this
    // 如果沒有傳入引數,則清除例項物件的所有事件
    // 將例項物件的_events私有屬性設定為null,並返回例項
   // all
    if (!arguments.length) {
      vm._events = Object.create(null)
      return vm
    }
    // 如果event引數傳入陣列,清除所有event事件的fn監聽函式返回例項
    // 這裡是$off方法遞迴執行,最終會以單一事件為基礎來實現監聽的清除
    // array of events
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        this.$off(event[i], fn)
      }
      return vm
    }
    // 如果指定單一事件,將事件的監聽函式陣列賦值給cbs變數
    // specific event
    const cbs = vm._events[event]
    // 如果沒有註冊此事件監聽則返回例項
    if (!cbs) {
      return vm
    }
    // 如果沒有指定監聽函式,則清除所有該事件的監聽函式,返回例項
    if (!fn) {
      vm._events[event] = null
      return vm
    }
    // 如果指定監聽函式,則遍歷事件監聽函式陣列,移除指定監聽函式返回例項
    if (fn) {
      // specific handler
      let cb
      let i = cbs.length
      while (i--) {
        cb = cbs[i]
        if (cb === fn || cb.fn === fn) {
          cbs.splice(i, 1)
          break
        }
      }
    }
    return vm
  }
  // 為Vue原型物件掛載$emit方法,只接受單一event
  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}".`
        )
      }
    }
    // 將事件監聽函式陣列賦值 給cbs
    let cbs = vm._events[event]
    // 如果監聽函式陣列存在
    if (cbs) {
      // 重置cbs變數,為何要使用toArray方法轉換一次陣列不太明白?
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      // 將event之後傳入的所有引數定義為args陣列
      const args = toArray(arguments, 1)
      // 遍歷所有監聽函式,為例項執行每一個監聽函式,並傳入args引數陣列
      for (let i = 0, l = cbs.length; i < l; i++) {
        try {
          cbs[i].apply(vm, args)
        } catch (e) {
          handleError(e, vm, `event handler for "${event}"`)
        }
      }
    }
    return vm
  }
}
複製程式碼

eventsMixin的內容非常直觀,分別為例項原型物件掛載了$on$once$off$emit四個方法。這是例項事件監聽函式的註冊、一次性註冊、移除和觸發的內部實現。在使用的過程中會對這些實現有一個更清晰的理解。


終於對Vue的事件系統的實現有了一個大致瞭解,沒有什麼特別高深的處理,但完整的事件系統的實現有很多細緻的功能這裡其實並沒有特別詳細地探討,比如事件修飾符,可以參考官方文件裡的解說會有一個更清晰的瞭解。事件系統的重要作用首先是為例項制定了一套處理事件的方案和標準,其次是在例項資料更新的過程中保持對事件監聽器的更新,這兩個部分的處理是最需要細緻去琢磨的。

相關文章