Vue事件繫結原理

WindrunnerMax發表於2020-09-07

Vue事件繫結原理

Vue中通過v-on或其語法糖@指令來給元素繫結事件並且提供了事件修飾符,基本流程是進行模板編譯生成AST,生成render函式後並執行得到VNodeVNode生成真實DOM節點或者元件時候使用addEventListener方法進行事件繫結。

描述

v-on@用於繫結事件監聽器,事件型別由引數指定,表示式可以是一個方法的名字或一個內聯語句,如果沒有修飾符也可以省略,用在普通元素上時,只能監聽原生DOM事件,用在自定義元素元件上時,也可以監聽子元件觸發的自定義事件,在監聽原生DOM事件時,方法以事件為唯一的引數,如果使用內聯語句,語句可以訪問一個$event property:v-on:click="handle('param', $event)",自2.4.0開始v-on同樣支援不帶引數繫結一個事件或監聽器鍵值對的物件,注意當使用物件語法時,是不支援任何修飾器的。

修飾符

  • .stop: 呼叫event.stopPropagation(),即阻止事件冒泡。
  • .prevent: 呼叫event.preventDefault(),即阻止預設事件。
  • .capture: 新增事件偵聽器時使用capture模式,即使用事件捕獲模式處理事件。
  • .self: 只當事件是從偵聽器繫結的元素本身觸發時才觸發回撥。
  • .{keyCode | keyAlias}: 只當事件是從特定鍵觸發時才觸發回撥。
  • .native: 監聽元件根元素的原生事件,即註冊元件根元素的原生事件而不是元件自定義事件的。
  • .once: 只觸發一次回撥。
  • .left(2.2.0): 只當點選滑鼠左鍵時觸發。
  • .right(2.2.0): 只當點選滑鼠右鍵時觸發。
  • .middle(2.2.0): 只當點選滑鼠中鍵時觸發。
  • .passive(2.3.0): 以{ passive: true }模式新增偵聽器,表示listener永遠不會呼叫preventDefault()

普通元素

<!-- 方法處理器 -->
<button v-on:click="doThis"></button>

<!-- 動態事件 (2.6.0+) -->
<button v-on:[event]="doThis"></button>

<!-- 內聯語句 -->
<button v-on:click="doThat('param', $event)"></button>

<!-- 縮寫 -->
<button @click="doThis"></button>

<!-- 動態事件縮寫 (2.6.0+) -->
<button @[event]="doThis"></button>

<!-- 停止冒泡 -->
<button @click.stop="doThis"></button>

<!-- 阻止預設行為 -->
<button @click.prevent="doThis"></button>

<!-- 阻止預設行為,沒有表示式 -->
<form @submit.prevent></form>

<!--  串聯修飾符 -->
<button @click.stop.prevent="doThis"></button>

<!-- 鍵修飾符,鍵別名 -->
<input @keyup.enter="onEnter">

<!-- 鍵修飾符,鍵程式碼 -->
<input @keyup.13="onEnter">

<!-- 點選回撥只會觸發一次 -->
<button v-on:click.once="doThis"></button>

<!-- 物件語法 (2.4.0+) -->
<button v-on="{ mousedown: doThis, mouseup: doThat }"></button>

元件元素

<!-- 自定義事件 -->
<my-component @my-event="handleThis"></my-component>

<!-- 內聯語句 -->
<my-component @my-event="handleThis('param', $event)"></my-component>

<!-- 元件中的原生事件 -->
<my-component @click.native="onClick"></my-component>

分析

Vue原始碼的實現比較複雜,會處理各種相容問題與異常以及各種條件分支,文章分析比較核心的程式碼部分,精簡過後的版本,重要部分做出註釋,commit idef56410

編譯階段

Vue在掛載例項前,有相當多的工作是進行模板的編譯,將template模板進行編譯,解析成AST樹,再轉換成render函式,而在編譯階段,就是對事件的指令做收集處理。
template模板中,定義事件的部分是屬於XMLAttribute,所以收集指令時需要匹配Attributes以確定哪個Attribute是屬於事件。

// dev/src/compiler/parser/index.js line 23
export const onRE = /^@|^v-on:/
export const dirRE = process.env.VBIND_PROP_SHORTHAND
  ? /^v-|^@|^:|^\.|^#/
  : /^v-|^@|^:|^#/
// ...
const dynamicArgRE = /^\[.*\]$/
// ...
export const bindRE = /^:|^\.|^v-bind:/
  
// dev/src/compiler/parser/index.js line 757
function processAttrs (el) {
  const list = el.attrsList
  let i, l, name, rawName, value, modifiers, syncGen, isDynamic
  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.replace(dirRE, '')) // 將修飾符解析
      // support .foo shorthand syntax for the .prop modifier
      if (process.env.VBIND_PROP_SHORTHAND && propBindRE.test(name)) {
        (modifiers || (modifiers = {})).prop = true
        name = `.` + name.slice(1).replace(modifierRE, '')
      } else if (modifiers) {
        name = name.replace(modifierRE, '')
      }
      if (bindRE.test(name)) { // v-bind // 處理v-bind的情況
        // ...
      } else if (onRE.test(name)) { // v-on // 處理事件繫結
        name = name.replace(onRE, '') // 將事件名匹配
        isDynamic = dynamicArgRE.test(name) // 動態事件繫結
        if (isDynamic) { // 如果是動態事件
          name = name.slice(1, -1) // 去掉兩端的 []
        }
        addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic) // 處理事件收集
      } else { // normal directives // 處理其他指令
        // ...
      }
    } else {
      // literal attribute // 處理文字屬性
      // ...
    }
  }
}

通過addHandler方法,為AST樹新增事件相關的屬性以及對事件修飾符進行處理。

// dev/src/compiler/helpers.js line 69
export function addHandler (
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important?: boolean,
  warn?: ?Function,
  range?: Range,
  dynamic?: boolean
) {
  modifiers = modifiers || emptyObject
  // passive 和 prevent 不能同時使用,具體是由passive模式的性質決定的
  // 詳細可以參閱 https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener
  // warn prevent and passive modifier
  /* istanbul ignore if */
  if (
    process.env.NODE_ENV !== 'production' && warn &&
    modifiers.prevent && modifiers.passive
  ) {
    warn(
      'passive and prevent can\'t be used together. ' +
      'Passive handler can\'t prevent default event.',
      range
    )
  }
  // 標準化click.right和click.middle,因為它們實際上不會觸發。
  // 從技術上講,這是特定於瀏覽器的,但是至少目前來說,瀏覽器是唯一具有右鍵/中間點選的目標環境。
  // normalize click.right and click.middle since they don't actually fire
  // this is technically browser-specific, but at least for now browsers are
  // the only target envs that have right/middle clicks.
  if (modifiers.right) { // 將滑鼠右鍵點選標準化 右鍵點選預設的是 contextmenu 事件
    if (dynamic) { // 如果是動態事件
      name = `(${name})==='click'?'contextmenu':(${name})` // 動態確定事件名
    } else if (name === 'click') { // 如果不是動態事件且是滑鼠右擊
      name = 'contextmenu' // 則直接替換為contextmenu事件
      delete modifiers.right // 刪除modifiers的right屬性
    }
  } else if (modifiers.middle) { // 同樣標準化處理滑鼠中鍵點選的事件
    if (dynamic) { // 如果是動態事件
      name = `(${name})==='click'?'mouseup':(${name})` // 動態確定事件名
    } else if (name === 'click') { // 如果不是動態事件且是滑鼠中鍵點選
      name = 'mouseup' // 處理為mouseup事件
    }
  }
  // 下面是對捕獲、一次觸發、passive模式的modifiers處理,主要是為事件新增 !、~、& 標記
  // 這一部分標記可以在Vue官方文件中查閱 
  // https://cn.vuejs.org/v2/guide/render-function.html#%E4%BA%8B%E4%BB%B6-amp-%E6%8C%89%E9%94%AE%E4%BF%AE%E9%A5%B0%E7%AC%A6
  // check capture modifier
  if (modifiers.capture) {
    delete modifiers.capture
    name = prependModifierMarker('!', name, dynamic)
  }
  if (modifiers.once) {
    delete modifiers.once
    name = prependModifierMarker('~', name, dynamic)
  }
  /* istanbul ignore if */
  if (modifiers.passive) {
    delete modifiers.passive
    name = prependModifierMarker('&', name, dynamic)
  }
  
  // events 用來記錄繫結的事件
  let events
  if (modifiers.native) { // 如果是要觸發根元素原生事件則直接取得nativeEvents
    delete modifiers.native
    events = el.nativeEvents || (el.nativeEvents = {})
  } else { // 否則取得events
    events = el.events || (el.events = {})
  }
    
  // 將事件處理函式作為handler
  const newHandler: any = rangeSetItem({ value: value.trim(), dynamic }, range)
  if (modifiers !== emptyObject) {
    newHandler.modifiers = modifiers
  }

 // 繫結的事件可以多個,回撥也可以多個,最終會合併到陣列中
  const handlers = events[name]
  /* istanbul ignore if */
  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
  }

  el.plain = false
}

程式碼生成

接下來需要將AST語法樹轉render函式,在這個過程中會加入對事件的處理,首先模組匯出了generate函式,generate函式即會返回render字串,在這之前會呼叫genElement函式,而在上述addHandler方法處理的最後執行了el.plain = false,這樣在genElement函式中會呼叫genData函式,而在genData函式中即會呼叫genHandlers函式。

// dev/src/compiler/codegen/index.js line 42
export function generate (
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`, // 即render字串
    staticRenderFns: state.staticRenderFns
  }
}

// dev/src/compiler/codegen/index.js line 55
export function genElement (el: ASTElement, state: CodegenState): string {
    // ...
    let code
    if (el.component) {
      code = genComponent(el.component, el, state)
    } else {
      let data
      if (!el.plain || (el.pre && state.maybeComponent(el))) {
        data = genData(el, state)
      }

      const children = el.inlineTemplate ? null : genChildren(el, state, true)
      code = `_c('${el.tag}'${
        data ? `,${data}` : '' // data
      }${
        children ? `,${children}` : '' // children
      })`
    }
    // ...
}

// dev/src/compiler/codegen/index.js line 219
export function genData (el: ASTElement, state: CodegenState): string {
  let data = '{'
  // ...
  // event handlers
  if (el.events) {
    data += `${genHandlers(el.events, false)},`
  }
  if (el.nativeEvents) {
    data += `${genHandlers(el.nativeEvents, true)},`
  }
  // ...
  data = data.replace(/,$/, '') + '}'
  // ...
  return data
}

// dev/src/compiler/to-function.js line 12 
function createFunction (code, errors) {
  try {
    return new Function(code) // 將render字串轉為render函式
  } catch (err) {
    errors.push({ err, code })
    return noop
  }
}

可以看到無論是處理普通元素事件還是元件根元素原生事件都會呼叫genHandlers函式,genHandlers函式即會遍歷解析好的AST樹中事件屬性,拿到event物件屬性,並根據屬性上的事件物件拼接成字串。

// dev/src/compiler/codegen/events.js line 3
const fnExpRE = /^([\w$_]+|\([^)]*?\))\s*=>|^function(?:\s+[\w$]+)?\s*\(/
const fnInvokeRE = /\([^)]*?\);*$/
const simplePathRE = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*|\['[^']*?']|\["[^"]*?"]|\[\d+]|\[[A-Za-z_$][\w$]*])*$/

// dev/src/compiler/codegen/events.js line 7
// KeyboardEvent.keyCode aliases
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]
}
// KeyboardEvent.key aliases
const keyNames: { [key: string]: string | Array<string> } = {
  // #7880: IE11 and Edge use `Esc` for Escape key name.
  esc: ['Esc', 'Escape'],
  tab: 'Tab',
  enter: 'Enter',
  // #9112: IE11 uses `Spacebar` for Space key name.
  space: [' ', 'Spacebar'],
  // #7806: IE11 uses key names without `Arrow` prefix for arrow keys.
  up: ['Up', 'ArrowUp'],
  left: ['Left', 'ArrowLeft'],
  right: ['Right', 'ArrowRight'],
  down: ['Down', 'ArrowDown'],
  // #9112: IE11 uses `Del` for Delete key name.
  'delete': ['Backspace', 'Delete', 'Del']
}
}

// dev/src/compiler/codegen/events.js line 37
// #4868: modifiers that prevent the execution of the listener
// need to explicitly return null so that we can determine whether to remove
// the listener for .once
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`)
}

// dev/src/compiler/codegen/events.js line 55
export function genHandlers (
  events: ASTElementHandlers,
  isNative: boolean
): string {
  const prefix = isNative ? 'nativeOn:' : 'on:'
  let staticHandlers = ``
  let dynamicHandlers = ``
  for (const name in events) { // 遍歷AST解析後的事件屬性
    const handlerCode = genHandler(events[name]) // 將事件物件轉換成可拼接的字串
    if (events[name] && events[name].dynamic) {
      dynamicHandlers += `${name},${handlerCode},`
    } else {
      staticHandlers += `"${name}":${handlerCode},`
    }
  }
  staticHandlers = `{${staticHandlers.slice(0, -1)}}`
  if (dynamicHandlers) {
    return prefix + `_d(${staticHandlers},[${dynamicHandlers.slice(0, -1)}])`
  } else {
    return prefix + staticHandlers
  }
}

// dev/src/compiler/codegen/events.js line 96
function genHandler (handler: ASTElementHandler | Array<ASTElementHandler>): string {
  if (!handler) {
    return 'function(){}'
  }

  // 事件繫結可以多個,多個在解析AST樹時會以陣列的形式存在,如果有多個則會遞迴呼叫getHandler方法返回陣列。
  if (Array.isArray(handler)) {
    return `[${handler.map(handler => genHandler(handler)).join(',')}]`
  }

  const isMethodPath = simplePathRE.test(handler.value) // 呼叫方法為 doThis 型
  const isFunctionExpression = fnExpRE.test(handler.value) // 呼叫方法為 () => {} or function() {} 型
  const isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, '')) // 呼叫方法為 doThis($event) 型

  if (!handler.modifiers) { // 沒有修飾符
    if (isMethodPath || isFunctionExpression) { // 符合這兩個條件則直接返回
      return handler.value
    }
    /* istanbul ignore if */
    if (__WEEX__ && handler.params) {
      return genWeexHandler(handler.params, handler.value)
    }
    return `function($event){${ // 返回拼接的匿名函式的字串
      isFunctionInvocation ? `return ${handler.value}` : handler.value
    }}` // inline statement
  } else { // 處理具有修飾符的情況
    let code = ''
    let genModifierCode = ''
    const keys = []
    for (const key in handler.modifiers) {  // 遍歷modifiers上記錄的修飾符
      if (modifierCode[key]) {
        genModifierCode += modifierCode[key]  // 根據修飾符新增對應js的程式碼
        // left/right
        if (keyCodes[key]) {
          keys.push(key)
        }
      } else if (key === 'exact') { // 針對exact的處理
        const modifiers: ASTModifiers = (handler.modifiers: any)
        genModifierCode += genGuard(
          ['ctrl', 'shift', 'alt', 'meta']
            .filter(keyModifier => !modifiers[keyModifier])
            .map(keyModifier => `$event.${keyModifier}Key`)
            .join('||')
        )
      } else {
        keys.push(key) // 如果修飾符不是以上修飾符,則會新增到keys陣列中
      }
    }
    if (keys.length) {
      code += genKeyFilter(keys) // 處理其他修飾符 即keyCodes中定義的修飾符
    }
    // Make sure modifiers like prevent and stop get executed after key filtering
    if (genModifierCode) {
      code += genModifierCode
    }
    // 根據三種不同的書寫模板返回不同的字串
    const handlerCode = isMethodPath
      ? `return ${handler.value}($event)`
      : isFunctionExpression
        ? `return (${handler.value})($event)`
        : isFunctionInvocation
          ? `return ${handler.value}`
          : handler.value
    /* istanbul ignore if */
    if (__WEEX__ && handler.params) {
      return genWeexHandler(handler.params, code + handlerCode)
    }
    return `function($event){${code}${handlerCode}}`
  }
}

// dev/src/compiler/codegen/events.js line 175
function genFilterCode (key: string): string {
  const keyVal = parseInt(key, 10)
  if (keyVal) { // 如果key是數字,則直接返回$event.keyCode!==${keyVal}
    return `$event.keyCode!==${keyVal}`
  }
  const keyCode = keyCodes[key]
  const keyName = keyNames[key]
  // 返回_k函式,它的第一個引數是$event.keyCode,
  // 第二個引數是key的值,
  // 第三個引數就是key在keyCodes中對應的數字。
  return (
    `_k($event.keyCode,` +
    `${JSON.stringify(key)},` +
    `${JSON.stringify(keyCode)},` +
    `$event.key,` +
    `${JSON.stringify(keyName)}` +
    `)`
  )
}

事件繫結

前面介紹瞭如何編譯模板提取事件收集指令以及生成render字串和render函式,但是事件真正的繫結到DOM上還是離不開事件註冊,此階段就發生在patchVnode過程中,在生成完成VNode後,進行patchVnode過程中建立真實DOM時會進行事件註冊的相關鉤子處理。

// dev/src/core/vdom/patch.js line 33
const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

// dev/src/core/vdom/patch.js line 125
function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
  // ...
  if (isDef(data)) {
    invokeCreateHooks(vnode, insertedVnodeQueue)
  }
  // ...
}

// dev/src/core/vdom/patch.js line 303
// 在之前cbs經過處理 
// 這裡cbs.create包含如下幾個回撥:
// updateAttrs、updateClass、updateDOMListeners、updateDOMProps、updateStyle、update、updateDirectives
function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (let i = 0; i < cbs.create.length; ++i) {
      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)
    }
}

invokeCreateHooks就是一個模板指令處理的任務,他分別針對不同的指令為真實階段建立不同的任務,針對事件,這裡會調updateDOMListeners對真實的DOM節點註冊事件任務。

// dev/src/platforms/web/runtime/modules/events.js line 105
function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {  // on是事件指令的標誌
    return
  }
  // 新舊節點不同的事件繫結解綁
  const on = vnode.data.on || {}
  const oldOn = oldVnode.data.on || {}
  // 拿到需要新增事件的真實DOM節點
  target = vnode.elm
  // normalizeEvents是對事件相容性的處理
  normalizeEvents(on)
  // 呼叫updateListeners方法,並將on作為引數傳進去
  updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)
  target = undefined
}

// dev/src/core/vdom/helpers/update-listeners.js line line 53
export function updateListeners (
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  createOnceHandler: 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)) { // createFunInvoker返回事件最終執行的回撥函式
        cur = on[name] = createFnInvoker(cur, vm)
      }
      if (isTrue(event.once)) {  // 只觸發一次的事件
        cur = on[name] = createOnceHandler(event.name, cur, event.capture)
      }
      // 執行真正註冊事件的執行函式
      add(event.name, cur, event.capture, event.passive, event.params)
    } else if (cur !== old) {
      old.fns = cur
      on[name] = old
    }
  }
  for (name in oldOn) { // 舊節點存在,解除舊節點上的繫結事件
    if (isUndef(on[name])) {
      event = normalizeEvent(name)
      // 移除事件監聽
      remove(event.name, oldOn[name], event.capture)
    }
  }
}

// dev/src/platforms/web/runtime/modules/events.js line 32
// 在執行完回撥之後,移除事件繫結
function createOnceHandler (event, handler, 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)
    }
  }
}

最終新增與移除事件都是呼叫的addremove方法,最終呼叫的方法即DOMaddEventListener方法與removeEventListener方法。

// dev/src/platforms/web/runtime/modules/events.js line 46
function add (
  name: string,
  handler: Function,
  capture: boolean,
  passive: boolean
) {
  // async edge case #6566: inner click event triggers patch, event handler
  // attached to outer element during patch, and triggered again. This
  // happens because browsers fire microtask ticks between event propagation.
  // the solution is simple: we save the timestamp when a handler is attached,
  // and the handler would only fire if the event passed to it was fired
  // AFTER it was attached.
  if (useMicrotaskFix) {
    const attachedTimestamp = currentFlushTimestamp
    const original = handler
    handler = original._wrapper = function (e) {
      if (
        // no bubbling, should always fire.
        // this is just a safety net in case event.timeStamp is unreliable in
        // certain weird environments...
        e.target === e.currentTarget ||
        // event is fired after handler attachment
        e.timeStamp >= attachedTimestamp ||
        // bail for environments that have buggy event.timeStamp implementations
        // #9462 iOS 9 bug: event.timeStamp is 0 after history.pushState
        // #9681 QtWebEngine event.timeStamp is negative value
        e.timeStamp <= 0 ||
        // #9448 bail if event is fired in another document in a multi-page
        // electron/nw.js app, since event.timeStamp will be using a different
        // starting reference
        e.target.ownerDocument !== document
      ) {
        return original.apply(this, arguments)
      }
    }
  }
  target.addEventListener(
    name,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}

// dev/src/platforms/web/runtime/modules/events.js line 92
function remove (
  name: string,
  handler: Function,
  capture: boolean,
  _target?: HTMLElement
) {
  (_target || target).removeEventListener(
    name,
    handler._wrapper || handler,
    capture
  )
}

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://cn.vuejs.org/v2/api/#v-on
https://juejin.im/post/6844903919290679304
https://juejin.im/post/6844904061897015310
https://juejin.im/post/6844904126250221576
https://segmentfault.com/a/1190000009750348
https://blog.csdn.net/weixin_41275295/article/details/100549145
https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener
https://github.com/liutao/vue2.0-source/blob/master/%E4%BA%8B%E4%BB%B6%E5%A4%84%E7%90%86.md

相關文章