事件监听器

我们通常通过v-on指令或其缩写@监听元素上的事件。用在普通元素标签上时,只能监听原生 DOM 事件;用在自定义子组件标签上时,也可以监听子组件触发的自定义事件。该节主要分析监听事件是如何与元素关系到一起,以及如何实现的。

模板编译

关于如何将模板里的v-on指令编译成render函数里数据对象的data.nativeOn/on,详情请参考:编译专题--event

也可以直接略过该模板编译部分,data.nativeOn/on相关信息可参考渲染函数 & JSX--深入 data 对象open in new window

组件自定义事件

在导出基础核心版 Vue构造函数时,会调用eventsMixin(Vue)Vue构造函数的原型添加一些事件相关的方法,即Vue构造函数实现了事件接口,Vue实例将具有发布订阅事件的能力。

eventsMixin

此处添加的几个方法,实现了发布订阅模式,其中:

  • $on:添加订阅事件
  • $off:删除订阅事件
  • $once:添加单次执行的订阅事件,第一次执行监听器函数之后,即删除该事件的订阅
  • $emit:发布事件
// src/core/instance/event.js

/**
 * 发布订阅模式
 *
 * 每个 Vue 实例自带发布订阅的能力,即实现了事件接口,此能力是通过在`Vue.prototype`上添加`$on`、`$off`、`$emit`、`$once`方法实现的。
 */
export function eventsMixin (Vue: Class<Component>) {
  const hookRE = /^hook:/
  Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
    const vm: Component = this
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        this.$on(event[i], fn)
      }
    } else {
      (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
      if (hookRE.test(event)) {
        vm._hasHookEvent = true
      }
    }
    return vm
  }

  Vue.prototype.$once = function (event: string, fn: Function): Component {
    const vm: Component = this
    function on () {
      vm.$off(event, on)
      fn.apply(vm, arguments)
    }
    // 挂载原始的 fn,方便通过 $off 删除
    on.fn = fn
    vm.$on(event, on)
    return vm
  }

  Vue.prototype.$off = function (event?: string | Array<string>, fn?: Function): Component {
    const vm: Component = this
    // all
    if (!arguments.length) {
      vm._events = Object.create(null)
      return vm
    }
    // array of events
    if (Array.isArray(event)) {
      for (let i = 0, l = event.length; i < l; i++) {
        this.$off(event[i], fn)
      }
      return vm
    }
    // 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]
        // cb.fn 是通过 $once 添加的
        if (cb === fn || cb.fn === fn) {
          cbs.splice(i, 1)
          break
        }
      }
    }
    return vm
  }

  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}".`
        )
      }
    }
    let cbs = vm._events[event]
    if (cbs) {
      cbs = cbs.length > 1 ? toArray(cbs) : cbs
      const args = toArray(arguments, 1)
      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
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106

发布订阅模式实现的代码里,有一点需要我们注意,就是如何删除单次执行的订阅事件。我们看到,通过$once添加的单次执行的订阅事件,会将原始的监听器函数fn挂载在封装的监听器函数onfn属性上,即on.fn = fn,这么做就方便了在用户通过$off方法删除单次执行的订阅事件时,能够找到通过$once添加的单次执行的订阅事件的监听器fn了。

通过在 Vue 原型上添加这几个方法后,就可以通过组件实例vm调用这些方法来监听、触发、移除自定义事件了。

注意,这里对自定义事件的监听、触发和移除,都是开发者添加对应的 JavaScript 代码来显式操作的。

添加自定义事件

本小节介绍的自定义事件,不是开发者在 JavaScript 代码里显示添加的,而是开发者在组件标签上添加@/v-on指令添加的自定义事件,这些自定义事件的触发方式主要有两种:

  • 开发者在组件里显式调用vm.$emit触发
  • 子组件的生命周期钩子函数被调用时,会隐式调用vm.$emit('hook:xxx')触发

而且,添加到组件标签上的自定义事件,最终会挂载在组件实例vm上,类似于显式调用了vm.$on('hook:mounted')来添加自定义事件。

initEvents

组件实例在初始化时,在_init方法里会调用initEvents,以初始化组件实例上事件相关的属性,比如在上一小节原型方法里的vm._eventsvm._hasHookEvent属性。最后调用updateComponentListeners将组件标签上添加的事件监听器添加到vm._events上。

组件标签上的这些自定义事件,是该组件的父组件在父组件模板里声明子组件标签时添加的。

// src/core/instance/event.js

export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // init parent attached events
  // 将挂载在组件标签上的 listeners 更新到组件上
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

listeners 的来源

详细分析updateComponentListeners如何将事件监听器添加到vm._events之前,我们先来了解下事件监听器数据listeners是如何而来的。

initEvents函数里的listeners来源于组件数据对象on属性上的监听器数据,在创建组件占位 VNode 时,会将这些监听器数据listeners添加到组件占位 VNode 的componentOptions属性上去。在组件_init初始化时,会将组件占位 VNode 上的componentOptions数据合并到vm.$options上,最终可以在initEvents里获取到vm.$options._parentListeners

提示

组件的模板最终将编译成render函数,在编译时,会将组件节点上的自定义事件事件转换,变成render函数里传入createElement的第二个参数即数据对象上的data.on属性上的key(自定义事件名称)和value(自定义事件处理方法)。

// src/core/vdom/create-component.js

export function createComponent (...) {
  // ...

  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  // 组件数据对象 data.on 上存储的是组件标签上的自定义事件
  const listeners = data.on
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  // 组件数据对象 data.nativeOn 上存储的是组件标签上的原生事件
  data.on = data.nativeOn

  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    // vnode.componentOptions
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/core/instance/init.js

/**
 * 针对组件实例,合并 vm.constructor.options 和 new Ctor(options) 时传入的 options
 * 请同时参考 create-component.js 里的 createComponentInstanceForVnode 函数
 */
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  // doing this because it's faster than dynamic enumeration.
  // 该组件实例对应的父占位节点,_parentVnode 的 name 属性格式为 vue-component-Ctor.cid-name
  const parentVnode = options._parentVnode
  // ...
  const vnodeComponentOptions = parentVnode.componentOptions
  // 组件实例的 opts 要挂载 parentVnode 上的 listeners
  opts._parentListeners = vnodeComponentOptions.listeners
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

updateComponentListeners

无论是组件添加、删除、更新自定义事件,都是调用updateComponentListeners完成的,只是每次传入的listenersoldListeners参数不一样罢了。

// src/core/instance/event.js

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)
}

export function updateComponentListeners (
  vm: Component,
  listeners: Object,
  oldListeners: ?Object
) {
  target = vm
  updateListeners(listeners, oldListeners || {}, add, remove, vm)
  target = undefined
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

updateComponentListeners实际是对updateListeners的封装。注意到组件初始化时调用updateComponentListeners并没有传入第三个oldListeners,因为是组件首次初始化,肯定没有老的监听器数据。

在调用updateListeners时会传入addremove参数,这两个参数都是函数,函数内分别是调用了vm.$on/$oncevm.$off来添加或删除组件的订阅事件。

注意事项

add函数和remove函数都是将自定义事件注册在target上以及从target上移除,而target是子组件实例。这也就是说,尽管自定义事件的事件处理方法是父组件的方法,但是最终事件是注册在子组件实例上的。(但是事件处理方法里的this已经绑定了父组件实例)

updateListeners

// src/core/vdom/helpers/update-listeners.js

/**
 * 标准化事件名称,返回一个对象,包含:
 *   文件名
 *   是否监听一次
 *   是否采取捕获模式
 *   是否 passive
 *
 * PS: 在模板编译阶段,会将事件的修饰符变成对应的符号添加在事件名称之前,这里是从事件名称里解析出各个修饰符
 */
const normalizeEvent = cached((name: string): {
  name: string,
  once: boolean,
  capture: boolean,
  passive: boolean,
  handler?: Function,
  params?: Array<any>
} => {
  const passive = name.charAt(0) === '&'
  name = passive ? name.slice(1) : name
  const once = name.charAt(0) === '~' // Prefixed last, checked first
  name = once ? name.slice(1) : name
  const capture = name.charAt(0) === '!'
  name = capture ? name.slice(1) : name
  return {
    name,
    once,
    capture,
    passive
  }
})

/**
 * 封装 fns 函数,返回新的函数 invoker,将原始的 fns 挂载在 invoker.fns 上
 *
 * 封装的目的是,fns 参数可以传入函数数组,即同时添加多个监听器
 */
export function createFnInvoker (fns: Function | Array<Function>): Function {
  function invoker () {
    const fns = invoker.fns
    if (Array.isArray(fns)) {
      const cloned = fns.slice()
      for (let i = 0; i < cloned.length; i++) {
        cloned[i].apply(null, arguments)
      }
    } else {
      // return handler return value for single handlers
      return fns.apply(null, arguments)
    }
  }
  invoker.fns = fns
  return invoker
}

/**
 * 更新 listeners
 *
 * 1、新的 listener 不存在:报错
 * 2、新的 listener 存在 && 旧的 listener 不存在:调用 createFnInvoker 生成新的 listener 并添加到 vm 上
 * 3、新的 listener 存在 && 旧的 listener 存在 && 新旧 listener 不相等:新的替换掉旧的
 *
 * 注意:通过调用 createFnInvoker 标准化 listener ,最终调用 listener 时,实际上是调用 listener.fns 上个每个函数(fns 可能是单个函数,也可能是数组)
 */
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 = on[name]
    old = oldOn[name]
    event = normalizeEvent(name)
    /* 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)) {
      if (isUndef(cur.fns)) {
        cur = on[name] = createFnInvoker(cur)
      }
      add(event.name, cur, event.once, event.capture, event.passive, event.params)
    } else if (cur !== old) {
      old.fns = cur
      on[name] = old
    }
  }
  // 去除 oldListeners 有但新 listeners 里没有的事件
  for (name in oldOn) {
    if (isUndef(on[name])) {
      event = normalizeEvent(name)
      remove(event.name, oldOn[name], event.capture)
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

updateListeners函数的逻辑是,遍历新的listeners,将每个监听器cur封装一下,并添加到vm._events对应的事件名称的数组里;移除掉存在在旧的listeners里但不存在在新的listeners里的监听器。

对新的监听器封装的目的是,cur可以是个监听器函数数组,而不仅仅是单个监听器函数。

更新自定义事件

更新自定义事件包括自定义事件监听器的修改及删除。

组件首次初始化时是通过在initEvents里调用updateComponentListeners来首次添加自定义事件监听器,而在以后的每次更新自定义事件时,仍然是调用updateComponentListeners来更新,只是是在组件patch时触发的。

// src/core/vdom/patch.js
export function createPatchFunction (backend) {
  // ...
  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // ...
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
      // 调用 vnode 的 prepatch 钩子
      i(oldVnode, vnode)
    }
    // ...
  }
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/core/vdom/create-component.js
const componentVNodeHooks = {
  // ...
  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/core/instance/lifecycle.js

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // ...
  // update listeners
  listeners = listeners || emptyObject
  const oldListeners = vm.$options._parentListeners
  vm.$options._parentListeners = listeners
  updateComponentListeners(vm, listeners, oldListeners)
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

发布自定义事件

组件内部,我们直接调用vm.$emit()就可以发布事件。

原生事件

原生事件(包括 DOM 元素节点上的原生事件和组件节点上带native修饰符的原生事件)的处理,独立成为了一个模块,模块对外暴露了两个方法createupdate,分别用来创建和更新原生事件,但模块内部这两个方法实际上调用的是同一个函数。

添加原生事件

组件上、HTML 元素上的原生事件,都是在其所在的父组件patch的过程中添加/更新的。

父组件首次patch的过程中,无论是 HTML 元素创建完成之后还是子组件创建完成之后,都会调用invokeCreateHooks函数来调用create钩子,而原生事件相关的添加也会在此进行。

// src/core/vdom/patch.js
export function createPatchFunction (backend) {
  // ...
  function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (let i = 0; i < cbs.create.length; ++i) {
      // 调用元素的 create 钩子,包括
      // - 注册 ref
      // - 注册 directives
      // - 添加 class 特性
      // - 添加 style 属性
      // - 添加其他 attrs 特性
      // - 添加原生事件处理
      // - 添加 dom-props,如 textContent/innerHTML/value 等
      // - (待补充)
      cbs.create[i](emptyNode, vnode)
    }
    i = vnode.data.hook // Reuse variable
    if (isDef(i)) {
      if (isDef(i.create)) i.create(emptyNode, vnode)
      if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
  }
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

更新原生事件

若节点是可patch的,在patchVnode时将传入oldVnodevnode调用update钩子,原生事件模块也包含在内。

// src/core/vdom/patch.js
export function createPatchFunction (backend) {
  // ...
  function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // ...
    if (isDef(data) && isPatchable(vnode)) {
      // 调用 vnode 的 update 钩子
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // ...
  }
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

原生事件模块

// normalize v-model event tokens that can only be determined at runtime.
// it's important to place the event as the first in the array because
// the whole point is ensuring the v-model callback gets called before
// user-attached handlers.
function normalizeEvents (on) {
  /* istanbul ignore if */
  if (isDef(on[RANGE_TOKEN])) {
    // IE input[type=range] only supports `change` event
    const event = isIE ? 'change' : 'input'
    on[event] = [].concat(on[RANGE_TOKEN], on[event] || [])
    delete on[RANGE_TOKEN]
  }
  // This was originally intended to fix #4521 but no longer necessary
  // after 2.5. Keeping it for backwards compat with generated code from < 2.4
  /* istanbul ignore if */
  if (isDef(on[CHECKBOX_RADIO_TOKEN])) {
    on.change = [].concat(on[CHECKBOX_RADIO_TOKEN], on.change || [])
    delete on[CHECKBOX_RADIO_TOKEN]
  }
}

let target: any

function createOnceHandler (handler, event, capture) {
  const _target = target // save current target element in closure
  return function onceHandler () {
    const res = handler.apply(null, arguments)
    if (res !== null) {
      remove(event, onceHandler, capture, _target)
    }
  }
}

function add (
  event: string,
  handler: Function,
  once: boolean,
  capture: boolean,
  passive: boolean
) {
  handler = withMacroTask(handler)
  if (once) handler = createOnceHandler(handler, event, capture)
  target.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}

function remove (
  event: string,
  handler: Function,
  capture: boolean,
  _target?: HTMLElement
) {
  (_target || target).removeEventListener(
    event,
    handler._withTask || handler,
    capture
  )
}

function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
    return
  }
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  target = vnode.elm
  normalizeEvents(on)
  updateListeners(on, oldOn, add, remove, vnode.context)
  target = undefined
}

export default {
  create: updateDOMListeners,
  update: updateDOMListeners
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

原生事件也是调用的updateListeners函数,只是传入的addremove方法不同,这两个方法都是使用的 DOM Node 的addEventListener方法来添加浏览器原生的事件监听器。

这里有一点需要额外注意,原生事件的监听器函数在绑定到 DOM 之前,都要先用withMacroTask封装一下,详见nextTick - withmacrotask

WARNING

原生事件是添加和删除都发生在vnode.elm上。对于 DOM 元素类型的 VNode 来说,vnode.elm是对应的 DOM 元素节点;对于组件占位 VNode 来说,vnode.elm是组件 DOM Tree 的根 DOM 元素节点。

once 的疑惑

TODO: 创建单次执行的事件监听器时,当封装后的监听器执行完成之后,若返回值为null,将不移除事件监听器,这是基于什么考虑?

function createOnceHandler (handler, event, capture) {
  const _target = target // save current target element in closure
  return function onceHandler () {
    const res = handler.apply(null, arguments)
    if (res !== null) {
      remove(event, onceHandler, capture, _target)
    }
  }
}
1
2
3
4
5
6
7
8
9

验证代码:

<div class="outer" @click.once="oneClick">Click</div>
1
export default {
  name: 'HelloWorld',
  data () {
    return {
      i: 0
    }
  },
  methods: {
    oneClick () {
      console.log(++this.i)
      return null
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

总结

组件的自定义事件与原生事件的对比:

类别原生事件组件自定义事件
实现方式通过addEventListener方法添加原生事件处理订阅发布模式
事件挂载点对应的 DOM Node子组件实例
对事件监听器的处理监听器需要用withMacroTask封装一层无处理
是否可以取消删除单次执行监听器原始监听器返回null可以取消删除单次执行监听器不可取消删除

HTML 元素和组件的对比:

类别HTML 元素组件
事件类型只能有原生事件既能有原生事件,又能有自定义事件;原生事件是添加到vnode.elm元素上
事件存放处data.on模板编译时,原生事件在data.nativeOn里;自定义事件在data.on里。但是在创建组件的 VNode 时,data.on数据会赋给listenersdata.nativeOn会赋给data.on,即最终组件的data.on放的是原生事件