Skip to content

Latest commit

 

History

History
677 lines (583 loc) · 23.4 KB

事件处理.md

File metadata and controls

677 lines (583 loc) · 23.4 KB

本篇文章,我们主要学习Vue中对于事件的处理。Vue中的事件,主要分为两个方面,一是DOM事件,另一个就是自定义事件。我们一起来看一下,它们实现方式上的异同。

DOM事件

Vue中我们是通过v-on或它的语法糖@指令来给元素绑定事件的。例子如下:

<div id="app">
  <p @click="show">{{text}}</p>
</div>
<script type="text/javascript">
  var vm = new Vue({
    data: {
      text: '要显示的文本'
    },
    methods: {
      show(){
        alert(this.text);
      }
    }
  }).$mount('#app');
</script>

上面的例子很简单,我们在点击文本的时候,会弹出文本的内容。那么,Vue是如何处理这整个流程的呢?

在数据处理的时候,Vue会把methods上的方法包装一层,使它bindvm上,这个简单提一句。

vm[key] = methods[key] == null ? noop : bind(methods[key], vm)

DOM事件的ast处理

要说事件绑定,还是要从模板编译说起。在编译模板过程中,处理完v-ifv-for等指令后,都会走到一个叫做processAttrs的方法,在这里,我们会去处理绑定的事件、绑定的数据、以及添加的其它属性等。

function processAttrs (el) {
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, isProp
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name
    value = list[i].value
    // 指令
    if (dirRE.test(name)) {
      // mark element as dynamic
      el.hasBindings = true
      // modifiers
      modifiers = parseModifiers(name)
      if (modifiers) {
        name = name.replace(modifierRE, '')
      }
      if (bindRE.test(name)) { // v-bind
        ...
      } else if (onRE.test(name)) { // v-on
        name = name.replace(onRE, '')
        addHandler(el, name, value, modifiers)
      } else { // normal directives
        ...
      }
    } else {
      ...
    }
  }
}

这一块的处理这里不再赘述,之前compile——生成ast中提到过v-bind的处理。这里我们把注意力放在事件处理上,也就是onRE.test(name)返回true的情况。我们调用了addHandler方法来处理。

export function addHandler (
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important: ?boolean
) {
  if (modifiers && modifiers.capture) {
    delete modifiers.capture
    name = '!' + name // mark the event as captured
  }
  if (modifiers && modifiers.once) {
    delete modifiers.once
    name = '~' + name // mark the event as once
  }
  let events
  if (modifiers && modifiers.native) {
    delete modifiers.native
    events = el.nativeEvents || (el.nativeEvents = {})
  } else {
    events = el.events || (el.events = {})
  }
  const newHandler = { value, modifiers }
  const handlers = events[name]

  if (Array.isArray(handlers)) {
    important ? handlers.unshift(newHandler) : handlers.push(newHandler)
  } else if (handlers) {
    events[name] = important ? [newHandler, handlers] : [handlers, newHandler]
  } else {
    events[name] = newHandler
  }
}

先来说一下modifiers,它是一个对象,key是我们添加的事件修饰符,valuetrue

首先处理的是captureonce修饰符,仔细看过Vue文档的人,会注意的在讲解render事件时,讲到我们可以用!~分别表示captureonce。从这里我们看到Vue在编译模板时,同样走的也是这一套。

接着如果有native修饰符,则添加到el.nativeEvents中,否则添加到el.events。它们都是一个对象,键是事件名,值是一个对象或数组,代码比较清晰,这里就不多费口舌了。

在生成ast之后,我们要做的是生成render函数字符串,我们看看接下来做了什么处理。

DOM事件的render字符串生成

  if (el.events) {
    data += `${genHandlers(el.events)},`
  }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true)},`
  }

可以看到,无论el.events还是el.nativeEvents都会通过genHandlers方法生成字符串,最终的数据是添加在data上的。重头戏来了,因为我们调用render函数之后,会直接绑定事件,所以对各种修饰符等的处理,就都是在这里进行的。打开src/compiler/codegen/events.js文件,这个文件的所有内容,都是和修饰符处理相关。

入口是genHandlers方法,从这里看起:

export function genHandlers (events: ASTElementHandlers, native?: boolean): string {
  let res = native ? 'nativeOn:{' : 'on:{'
  for (const name in events) {
    res += `"${name}":${genHandler(name, events[name])},`
  }
  return res.slice(0, -1) + '}'
}

nativeOnon的区别就是前面的el.nativeEventsel.events,然后依次调用genHandler来生成对每个事件处理之后的函数字符串。

function genHandler (
  name: string,
  handler: ASTElementHandler | Array<ASTElementHandler>
): string {
  // handler为空,则返回一个空函数的字符串
  if (!handler) {
    return 'function(){}'
  }
  // 如果handler是一个数组,说明一个事件添加了多个处理函数,依次调用genHandler生成字符串并合到一个数组中
  if (Array.isArray(handler)) {
    return `[${handler.map(handler => genHandler(name, handler)).join(',')}]`
  }
  
  const isMethodPath = simplePathRE.test(handler.value)
  const isFunctionExpression = fnExpRE.test(handler.value)

  if (!handler.modifiers) {
    return isMethodPath || isFunctionExpression
      ? handler.value
      : `function($event){${handler.value}}` // inline statement
  } else {
    let code = ''
    let genModifierCode = ''
    const keys = []
    for (const key in handler.modifiers) {
      if (modifierCode[key]) {
        genModifierCode += modifierCode[key]
        // left/right
        if (keyCodes[key]) {
          keys.push(key)
        }
      } else {
        keys.push(key)
      }
    }
    if (keys.length) {
      code += genKeyFilter(keys)
    }
    // Make sure modifiers like prevent and stop get executed after key filtering
    if (genModifierCode) {
      code += genModifierCode
    }
    const handlerCode = isMethodPath
      ? handler.value + '($event)'
      : isFunctionExpression
        ? `(${handler.value})($event)`
        : handler.value
    return `function($event){${code}${handlerCode}}`
  }
}

有两个正则,简单说一下:

const fnExpRE = /^\s*([\w$_]+|\([^)]*?\))\s*=>|^function\s*\(/
const simplePathRE = /^\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['.*?']|\[".*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*\s*$/

fnExpRE匹配箭头函数,或者普通的函数定义。simplePathRE其实就是匹配函数的路径,比如nameobj.nameobj["$^%#"]obj[0]等。

没有修饰符处理比较简单,isMethodPath || isFunctionExpression为真则直接返回handler.value,否则用function($event){}包一下,这种情况是我们在绑定事件时进行了传参,例如<p @click="show('test')">{{text}}</p>

如果有修饰符,情况就比较复杂。

1、修饰符是如下修饰符。

const genGuard = condition => `if(${condition})return null;`

const modifierCode: { [key: string]: string } = {
  stop: '$event.stopPropagation();',
  prevent: '$event.preventDefault();',
  self: genGuard(`$event.target !== $event.currentTarget`),
  ctrl: genGuard(`!$event.ctrlKey`),
  shift: genGuard(`!$event.shiftKey`),
  alt: genGuard(`!$event.altKey`),
  meta: genGuard(`!$event.metaKey`),
  left: genGuard(`'button' in $event && $event.button !== 0`),
  middle: genGuard(`'button' in $event && $event.button !== 1`),
  right: genGuard(`'button' in $event && $event.button !== 2`)
}

则直接返回响应的字符串,并添加到genModifierCode字符串上,如果是leftright还会添加到keys数组中。比如"@click.stop.ctrl="show""最终会生成如下字符串:

"{on:{"click":function($event){$event.stopPropagation();if(!$event.ctrlKey)return null;show($event)}}}"

2、如果修饰符不是以上修饰符,则会添加到keys数组中。然后先执行genKeyFilter方法来处理:

const keyCodes: { [key: string]: number | Array<number> } = {
  esc: 27,
  tab: 9,
  enter: 13,
  space: 32,
  up: 38,
  left: 37,
  right: 39,
  down: 40,
  'delete': [8, 46]
}

function genKeyFilter (keys: Array<string>): string {
  return `if(!('button' in $event)&&${keys.map(genFilterCode).join('&&')})return null;`
}
function genFilterCode (key: string): string {
  const keyVal = parseInt(key, 10)
  if (keyVal) {
    return `$event.keyCode!==${keyVal}`
  }
  const alias = keyCodes[key]
  return `_k($event.keyCode,${JSON.stringify(key)}${alias ? ',' + JSON.stringify(alias) : ''})`
}

genKeyFilter返回的也是一个判断不符合一定条件就return null字符串。其中genFilterCode是对每个key进行遍历。如果key是数字,则直接返回$event.keyCode!==${keyVal},否则会返回_k函数,它的第一个参数是$event.keyCode,第二个参数是key的值,第三个参数就是keykeyCodes中对应的数字。

_k函数如下:

export function checkKeyCodes (
  eventKeyCode: number,
  key: string,
  builtInAlias: number | Array<number> | void
): boolean {
  const keyCodes = config.keyCodes[key] || builtInAlias
  if (Array.isArray(keyCodes)) {
    return keyCodes.indexOf(eventKeyCode) === -1
  } else {
    return keyCodes !== eventKeyCode
  }
}

因为我们的keyCodes是可以自己配的,这里其实就是查找我们自己的配置来进行判断。

最终,生成的字符串中,同样通过function($event){}来包裹,只不过会先执行前面生成的这一堆判断,最后执行我们添加的函数。

DOM事件的添加

render函数执行的过程中,上面生成的函数,都会定义,但都不会执行。那事件绑定,是在哪儿进行的呢?

我们之前讲__patch__时说过,在元素创建、替换、销毁等各个时期,都有一些钩子函数,它们在Vue初始化时会添加到cbs对象中,它们主要是对VNode对象上data数据进行处理,比如classstyleeventattr等。它们定义在src/platforms/web/runtime/modules文件夹中。我们来看一下event的处理:

function normalizeEvents (on) {
  let event
  /* istanbul ignore if */
  if (on[RANGE_TOKEN]) {
    // IE input[type=range] only supports `change` event
    event = isIE ? 'change' : 'input'
    on[event] = [].concat(on[RANGE_TOKEN], on[event] || [])
    delete on[RANGE_TOKEN]
  }
  if (on[CHECKBOX_RADIO_TOKEN]) {
    // Chrome fires microtasks in between click/change, leads to #4521
    event = isChrome ? 'click' : 'change'
    on[event] = [].concat(on[CHECKBOX_RADIO_TOKEN], on[event] || [])
    delete on[CHECKBOX_RADIO_TOKEN]
  }
}

function add (
  event: string,
  handler: Function,
  once: boolean,
  capture: boolean
) {
  if (once) {
    const oldHandler = handler
    const _target = target // save current target element in closure
    handler = function (ev) {
      const res = arguments.length === 1
        ? oldHandler(ev)
        : oldHandler.apply(null, arguments)
      if (res !== null) {
        remove(event, handler, capture, _target)
      }
    }
  }
  target.addEventListener(event, handler, capture)
}

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

function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (!oldVnode.data.on && !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)
}

createupdate钩子函数,调用的都是updateDOMListeners方法。normalizeEvents是对特殊事件的优化处理。我们注意到最终调用的updateListeners方法,接受了五个参数,分别是新vnode的事件,旧vnode的事件,add方法,remove方法,方法的运行环境。然后该方法内部会调用add方法来添加事件,remove方法来销毁之前添加过的事件。add方法内还封装了once的处理,once的处理其实就是把回调封装了一层,在调用的时候,销毁事件,之后再调用就无效了。

从上面定义的addremove方法我们可以看到,Vue中给DOM元素添加事件是通过addEventListener方法来添加的,因为Vue本身只支持ie9+,所以不同做其它事件的兼容,这也说明,所有浏览器支持的DOM事件,我们都可以添加到元素上。

export function updateListeners (
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  vm: Component
) {
  let name, cur, old, event
  for (name in on) {
    cur = on[name]
    old = oldOn[name]
    event = normalizeEvent(name)
    if (!cur) {
      process.env.NODE_ENV !== 'production' && warn(
        `Invalid handler for event "${event.name}": got ` + String(cur),
        vm
      )
    } else if (!old) {
      if (!cur.fns) {
        cur = on[name] = createFnInvoker(cur)
      }
      add(event.name, cur, event.once, event.capture)
    } else if (cur !== old) {
      old.fns = cur
      on[name] = old
    }
  }
  for (name in oldOn) {
    if (!on[name]) {
      event = normalizeEvent(name)
      remove(event.name, oldOn[name], event.capture)
    }
  }
}

updateListeners方法中会遍历新添加进来的事件,这里调用了一个normalizeEvent方法,其实该方法就是对我们之前在处理oncecapture时添加在name最前面的符合进行翻译。

const normalizeEvent = cached((name: string): {
  name: string,
  once: boolean,
  capture: boolean
} => {
  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
  }
})

如果旧事件中没有该name事件,则调用add方法添加事件。createFnInvoker方法就是会返回一个函数,最终该函数会调用添加到它上面的fns,只不过还封装了对数组的处理。

export function createFnInvoker (fns: Function | Array<Function>): Function {
  function invoker () {
    const fns = invoker.fns
    if (Array.isArray(fns)) {
      for (let i = 0; i < fns.length; i++) {
        fns[i].apply(null, arguments)
      }
    } else {
      // return handler return value for single handlers
      return fns.apply(null, arguments)
    }
  }
  invoker.fns = fns
  return invoker
}

如果新旧事件都有相同的name事件,则替换事件的回调,这里类似于对dom元素的复用,它对之前绑定的事件做了一个复用。

最后,如果是旧事件中独有的,则调用remove方法销毁。

以上就是Vue中,对于DOM事件的添加销毁处理。

自定义事件

相对于DOM事件,自定义事件的处理就显得比较简单了。Vue中,给vm对象添加了几个用于事件处理的方法,分别是$on$once$off$emit。我们一个一个看一下:

  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
  }

该方法是添加事件,其实它的原理很简单,用的就是我们经常用的事件订阅和发布的机制。在每个vm对象上,都有一个vm._events对象,当我们添加事件时,就往该对象上添加一个属性,属性值是一个数组,毕竟我们可能给同一个事件添加多个方法。

  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 (arguments.length === 1) {
      vm._events[event] = null
      return vm
    }
    // 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
  }

有添加就有销毁,销毁的方式也很简单。如果传入的参数为空,则直接重置vm._events。如果传入的是一个数组,则依次销毁。如果之传入了一个字符串,则销毁对应的这一个事件。如果同时传入了fn,则从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}".`
        )
      }
    }
    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++) {
        cbs[i].apply(vm, args)
      }
    }
    return vm
  }

$emit是触发事件,它的工作,就是依次调用第一个参数传入的event对应的事件,并且给每个事件传入后续的参数。

  Vue.prototype.$once = function (event: string, fn: Function): Component {
    const vm: Component = this
    function on () {
      vm.$off(event, on)
      fn.apply(vm, arguments)
    }
    on.fn = fn
    vm.$on(event, on)
    return vm
  }

$once方法,和绑定DOM事件时的add方法类似,就是把添加的事件封装了一下,在该函数调用时销毁事件的绑定。

总的来说,自定义事件的处理还是非常简单的。所以,我们可以用一个Vue对象作为event bus,其实就是用了它的事件订阅和发布。

补充

看如下例子:

<div id="app">
  <my-component @click="change"></my-component>
</div>
<script type="text/javascript">
  var vm = new Vue({
    el: '#app',
    methods: {
      change(){
        alert('hah');
      }
    },
    components: {
      myComponent: {
        data(){
          return {
            haha: 'gagag'
          }
        },
        template: `<p >{{haha}}</p>`,
      }
    }
  })
</script>

运行后,我们点击p标签,发现没有任何反应。我们试着在click后面添加一个.native,再次点击p标签,页面中就会弹出测试代码。

我们再换一种用法,不改变click,把myComponent的定义修改如下,同样点击p标签,页面中会弹出测试代码。

  myComponent: {
    data(){
      return {
        haha: 'gagag'
      }
    },
    template: `<p @click="h">{{haha}}</p>`,
    methods: {
      h(){
        this.$emit('click');
      }
    }
  } 

惊喜不惊喜?我们前面说过,通过v-on@绑定的都是原生的DOM事件,而这里我们直接点击却无效,而通过$emit可以触发,这是为什么呢?前面的事件添加过程,我们也只用到了data.on,而在模板编译时,明明还有一个data.nativeOn

回到子组件的创建,在vdom——VNode中,我们讲过创建新的子组件的处理流程。

  const listeners = data.on

  data.on = data.nativeOn
  ...
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }
  )
  return vnode

我们会把data.on赋值给listeners,然后用data.nativeOn覆盖data.on。在创建子组件的实例时,我们会把listeners作为参数传入。

src/core/instance/eventsinitEvents方法中,我们有如下一段代码:

  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }

这里的vm.$options._parentListeners,就是我们上面传入的listeners,接着我们会调用updateComponentListeners方法来绑定事件。

let target: Component

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

它内部同样调用了上面提到的updateListeners,不同的是这次的addremove方法,内部不是调用的addEventListener,而是我们自己定义的$once$on$off。所以,我们子组件中可以通过$emit方法来触发函数的调用。

那为什么加上了.native就可以直接触发了呢?我们上面提到,它会把data.nativeOn赋值给data.on。但是我们子模板的编译,render函数的执行等,都没有用到父组件中定义的vnode上的data属性。这一点我也是找了好久,在patch.js文件中,我们在创建自定义组件时会调用一个initComponent方法,该方法中有如下片段:

  vnode.elm = vnode.componentInstance.$el
  if (isPatchable(vnode)) {
    invokeCreateHooks(vnode, insertedVnodeQueue)
    setScope(vnode)
  }

initComponent方法调用之前,我们会先调用子组件的init钩子函数,在这个过程中会初始化子组件并挂载。vnode.componentInstance.$el就是我们子组件的根元素,也即是上面例子中的p标签,它里面有一个isPatchable方法的判断,定义如下:

  function isPatchable (vnode) {
    while (vnode.componentInstance) {
      vnode = vnode.componentInstance._vnode
    }
    return isDef(vnode.tag)
  }

它会去判断vnode.componentInstance._vnode是不是一个标签元素,如果是则调用invokeCreateHooks方法。此时我们的vnode还是父组件中的vnode,所以它上面的data.on就是模板解析时的data.nativeOn,且vnode.elm也指向了实际的p标签,所以添加了.native修饰符的DOM事件会添加到p元素上。

同理,这也是为什么父组件内给自定义标签上添加的styleclass等也可以应用在子组件上。

以上就是Vue中事件相关的所有内容,有问题欢迎沟通~