1. 程式人生 > >Vue事件繫結原理

Vue事件繫結原理

# Vue事件繫結原理 `Vue`中通過`v-on`或其語法糖`@`指令來給元素繫結事件並且提供了事件修飾符,基本流程是進行模板編譯生成`AST`,生成`render`函式後並執行得到`VNode`,`VNode`生成真實`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()`。 ### 普通元素 ```html ``` ### 元件元素 ```html ``` ## 分析 `Vue`原始碼的實現比較複雜,會處理各種相容問題與異常以及各種條件分支,文章分析比較核心的程式碼部分,精簡過後的版本,重要部分做出註釋,`commit id`為`ef56410`。 ### 編譯階段 `Vue`在掛載例項前,有相當多的工作是進行模板的編譯,將`template`模板進行編譯,解析成`AST`樹,再轉換成`render`函式,而在編譯階段,就是對事件的指令做收集處理。 在`template`模板中,定義事件的部分是屬於`XML`的`Attribute`,所以收集指令時需要匹配`Attributes`以確定哪個`Attribute`是屬於事件。 ```javascript // 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`樹新增事件相關的屬性以及對事件修飾符進行處理。 ```javascript // 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`函式。 ```javascript // 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`物件屬性,並根據屬性上的事件物件拼接成字串。 ```javascript // 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 } = { 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 } = { // #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): 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`時會進行事件註冊的相關鉤子處理。 ```javascript // 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`節點註冊事件任務。 ```javascript // 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) } } } ``` 最終新增與移除事件都是呼叫的`add`與`remove`方法,最終呼叫的方法即`DOM`的`addEventListener`方法與`removeEventListener`方法。 ```javascript // 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