本篇文章,我们主要学习Vue
中对于事件的处理。Vue
中的事件,主要分为两个方面,一是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
上的方法包装一层,使它bind
到vm
上,这个简单提一句。
vm[key] = methods[key] == null ? noop : bind(methods[key], vm)
要说事件绑定,还是要从模板编译说起。在编译模板过程中,处理完v-if
、v-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
是我们添加的事件修饰符,value
为true
。
首先处理的是capture
和once
修饰符,仔细看过Vue
文档的人,会注意的在讲解render
的事件时,讲到我们可以用!
和~
分别表示capture
和once
。从这里我们看到Vue
在编译模板时,同样走的也是这一套。
接着如果有native
修饰符,则添加到el.nativeEvents
中,否则添加到el.events
。它们都是一个对象,键是事件名,值是一个对象或数组,代码比较清晰,这里就不多费口舌了。
在生成ast
之后,我们要做的是生成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) + '}'
}
nativeOn
和on
的区别就是前面的el.nativeEvents
和el.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
其实就是匹配函数的路径,比如name
、obj.name
、obj["$^%#"]
、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
字符串上,如果是left
或right
还会添加到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
的值,第三个参数就是key
在keyCodes
中对应的数字。
_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){}
来包裹,只不过会先执行前面生成的这一堆判断,最后执行我们添加的函数。
在render
函数执行的过程中,上面生成的函数,都会定义,但都不会执行。那事件绑定,是在哪儿进行的呢?
我们之前讲__patch__
时说过,在元素创建、替换、销毁等各个时期,都有一些钩子函数,它们在Vue
初始化时会添加到cbs
对象中,它们主要是对VNode
对象上data
数据进行处理,比如class
、style
、event
、attr
等。它们定义在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)
}
create
和update
钩子函数,调用的都是updateDOMListeners
方法。normalizeEvents
是对特殊事件的优化处理。我们注意到最终调用的updateListeners
方法,接受了五个参数,分别是新vnode
的事件,旧vnode
的事件,add
方法,remove
方法,方法的运行环境。然后该方法内部会调用add
方法来添加事件,remove
方法来销毁之前添加过的事件。add
方法内还封装了once
的处理,once
的处理其实就是把回调封装了一层,在调用的时候,销毁事件,之后再调用就无效了。
从上面定义的add
和remove
方法我们可以看到,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
方法,其实该方法就是对我们之前在处理once
和capture
时添加在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/events
的initEvents
方法中,我们有如下一段代码:
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
,不同的是这次的add
和remove
方法,内部不是调用的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
元素上。
同理,这也是为什么父组件内给自定义标签上添加的style
、class
等也可以应用在子组件上。
以上就是Vue
中事件相关的所有内容,有问题欢迎沟通~