前言
通过这篇文章可以了解如下内容
- 原生事件原理
- 事件修饰符原理
- 自定义事件原理
$emit原理$on原理$off原理$once原理
钩子函数
执行patch函数之前,会注册一些钩子函数。用于设置DOM元素相关的属性、样式、事件、指令等。这些钩子函数在 patch 不同时机执行,比如create、update
events也是通过这些钩子初始化绑定的,定义在src/platforms/web/runtime/modules/events.js中
export default {
create: updateDOMListeners,
update: updateDOMListeners
}
可以看到,events会抛出两个钩子,一个是create,另一个是update。这俩钩子都是执行updateDOMListeners这个方法
在 patch 过程中,有三个地方会执行create钩子函数
- 通过
createElm创建 DOM 元素时,执行create钩子并将当前VNode传入;cbs.create[i](emptyNode, vnode) - 其次是在更新过程中,如果组件的新老根元素不同,当组件的渲染VNode更新完成后会更新组件占位符VNode 的
elm属性,此时会执行create钩子并将当前组件占位符VNode传入 - 通过
createComponent创建完组件的渲染VNode后,执行create钩子并将当前组件占位符VNode传入
只有一个地方会执行update钩子函数
- 在
patchVnode函数中,更新子组件后、比对新老子节点前会执行update钩子,传入新老节点cbs.update[i](oldVnode, vnode)
普通VNode
原生事件
对于事件来说,它的create和update钩子就是执行updateDOMListeners函数
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, createOnceHandler, vnode.context)
target = undefined
}
create钩子执行updateDOMListeners时,oldVnode始终为空VNode,只传入了第二个参数VNode
首先会判断oldVnode.data.on和vnode.data.on是不是为空;然后调用normalizeEvents函数,normalizeEvents 主要是对 v-model 相关的处理;接下来会执行updateListeners函数
updateListeners函数定义在src/core/vdom/helpers/update-listeners.js中
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)
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, 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) { // 更新时触发, 条件是 cur 和 old 不同
old.fns = cur
on[name] = old
}
}
// 遍历 oldOn,如果事件中没有 名为 name 的事件,则删除 oldOn 中的事件
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name)
// 这里的目的是删除 name 对应的方法
remove(event.name, oldOn[name], event.capture)
}
}
}
updateListeners函数会遍历新VNode中所有的事件,获取事件名,并调用normalizeEvent
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
}
})
normalizeEvent函数根据事件名中一些特殊标识,区分出这个事件是否有 once、capture、passive 等修饰符;最终返回一个对象赋值给event变量,即 updateListeners 函数中的event变量的值为
{
name: 'click',
once: false,
capture: false,
passive: false
}
回到updateListeners,对于第一次创建,如果定义的事件没有fns属性,则调用createFnInvoker函数创建一个回调函数
if (isUndef(cur.fns)) {
cur = on[name] = createFnInvoker(cur, vm)
}
export function createFnInvoker (fns: Function | Array<Function>, vm: ?Component): Function {
function invoker () {}
invoker.fns = fns
return invoker
}
createFnInvoker函数定义了一个invoker函数,给invoker函数添加了一个属性fns,属性值就是定义事件的回调函数;最终返回invoker函数并赋值给on[name]。
更新时,再次执行updateListeners函数,判断如果 cur !== old,那么只需要更改 old.fns = cur 把之前绑定的 involer.fns 赋值为新的回调函数即可,并且通过 on[name] = old 保留引用关系,这样就保证了事件回调只添加一次,之后仅仅去修改它的回调函数的引用。
else if (cur !== old) { // 更新时触发, 条件是 cur 和 old 不同
old.fns = cur
on[name] = old
}
回到updateListeners,对于第一次创建会调用传入的add函数,并通过addEventListener给DOM绑定事件。
function add (
name: string,
handler: Function,
capture: boolean,
passive: boolean
) {
if (useMicrotaskFix) {
const attachedTimestamp = currentFlushTimestamp
const original = handler
handler = original._wrapper = function (e) {
if (
e.target === e.currentTarget ||
e.timeStamp >= attachedTimestamp ||
e.timeStamp <= 0 ||
e.target.ownerDocument !== document
) {
return original.apply(this, arguments)
}
}
}
target.addEventListener(
name,
handler,
supportsPassive
? { capture, passive }
: capture
)
}
至于上面的逻辑是解决一个在微任务下 事件冒泡的bug,将 Vue 版本改成2.4.0,会发现每次点击,A、B变量都会增加,并且文字不变。这个原因其实就是因为点击父元素触发组件更新,因为更新是在微任务中,导致执行顺序优先于事件冒泡机制。也就是说当组件更新完成之后事件冒泡机制继续执行,发现div标签上也有点击事件,接着触发点击事件导致上面的bug。在最后我会放一个 Demo,感兴趣的可以试下
这个的解决方案首先如果e.target === e.currentTarget成立说明是当前元素的回调被触发,会执行这个回调。反之,说明当前阶段是事件冒泡阶段,判断e.timeStamp >= attachedTimestamp(e.timeStamp打开页面到执行事件回调的时间),如果成立说明这期间没有更新组件,从而继续执行回调
attachedTimestamp是一个时间戳,在触发组件更新时获取的。如果组件更新了attachedTimestamp肯定大于e.timeStamp。
绑定完事件后,在更新阶段,还会执行下面的逻辑
for (name in oldOn) {
if (isUndef(on[name])) {
event = normalizeEvent(name)
// 这里的目的是删除 name 对应的方法
remove(event.name, oldOn[name], event.capture)
}
}
遍历oldVnode的所有事件,如果新事件中没有当前事件名,则通过remove删除之前绑定的事件
function remove (
name: string,
handler: Function,
capture: boolean,
_target?: HTMLElement
) {
(_target || target).removeEventListener(
name,
handler._wrapper || handler,
capture
)
}
.once修饰符
在updateListeners中如果设置了.once修饰符,会执行传入的createOnceHandler函数
if (isTrue(event.once)) {
cur = on[name] = createOnceHandler(event.name, cur, event.capture)
}
createOnceHandler会返回一个函数,函数内部就是调用定义的回调,如果返回值不为null,取消监听,也就是说如果定义的回调返回的是null的话,.once会失效
function createOnceHandler (event, handler, capture) {
const _target = target
return function onceHandler () {
const res = handler.apply(null, arguments)
if (res !== null) {
remove(event, onceHandler, capture, _target)
}
}
}
触发执行
以点击为例,当用户点击时,触发回调执行invoker函数
function invoker () {
const fns = invoker.fns
if (Array.isArray(fns)) {
const cloned = fns.slice()
for (let i = 0; i < cloned.length; i++) {
invokeWithErrorHandling(cloned[i], null, arguments, vm, `v-on handler`)
}
} else {
// return handler return value for single handlers
return invokeWithErrorHandling(fns, null, arguments, vm, `v-on handler`)
}
}
invoker函数先获取fns属性,并通过invokeWithErrorHandling去执行定义的回调
小结
总流程如下
在patch过程中,会触发create和update钩子函数,遍历所有的事件,获取事件名和使用到的修饰符;如果是第一次创建,会创建一个invoker方法,并给这个方法绑定一个属性fns用于存储定义的回调函数;然后通过addEventListener绑定事件。如果是更新过程并且事件名对应的回调函数和之前的不同,则会修改绑定属性fns的值;触发的回调就是fns`的属值;这样做的好处是不需要二次绑定,只绑定一次就行。最后,如果新事件中没有对应事件名,会取消事件监听。
组件占位符VNode
接下来看下组件占位符VNode的事件;分为两种,一种是原生事件,一种是自定义事件
原生事件
通过createComponent创建组件VNode时,有这样一段逻辑
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
data = data || {}
// 拿到自定义事件
const listeners = data.on
// 将 nativeOn 赋值给 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 },
asyncFactory
)
return vnode
}
将原生事件添加到data.on中,而自定义事件添加到组件VNode 的componentOptions.listeners中;接下来进入patch过程,对于组件VNode会执行createComponent创建组件实例和组件的DOM树,创建完成后,修改组件VNode的elm属性并执行cbs.create中所有的钩子函数,剩下逻辑就已经和普通DOM的事件绑定流程一样了。
需要注意的是,对组件占位符VNode调用cbs.create时,会将组件标签上的原生事件挂载到vnode.elm上,也就是组件的根元素上
自定义事件
上面代码中,自定义事件添加到了组件VNode 的componentOptions.listeners中;接下来分别说下创建和更新两个过程是怎么处理自定义事件的
创建过程
在patch过程中,如果VNode是组件占位符VNode,会调用createComponent函数,createComponent函数内又调用init钩子函数,而init钩子函数内会调用createComponentInstanceForVnode去创建组件实例;
export function createComponentInstanceForVnode (
vnode: any, // we know it's MountedComponentVNode but flow doesn't
parent: any, // activeInstance in lifecycle state
): Component {
// 给组件vue实例的 options 添加 _isComponent、_parentVnode、parent 属性
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
return new vnode.componentOptions.Ctor(options)
}
在创建实例的过程中调用initEvents函数
export function initEvents (vm: Component) {
vm._events = Object.create(null)
vm._hasHookEvent = false
// 获取自定义事件,合并 options 时添加的 _parentListeners 属性
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}
initEvents函数内如果有自定义事件,会调用updateComponentListeners函数;更新过程也会调用updateComponentListeners函数,下面一起说
更新过程
在patch过程中,如果VNode是组件VNode,会调用prepatch钩子函数
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props 传入子组件的最新的 props 值
options.listeners, // updated listeners 自定义事件
vnode, // new parent vnode
options.children // new children
)
},
prepatch钩子函数内又调用updateChildComponent去更新自定义事件
export function updateChildComponent (
vm: Component, // 子组件实例
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode, // 组件 vnode
renderChildren: ?Array<VNode>
) {
// ...
// update listeners
listeners = listeners || emptyObject
// 获取上一次绑定的自定义事件
const oldListeners = vm.$options._parentListeners
// 将此次的自定义事件赋值给 _parentListeners
vm.$options._parentListeners = listeners
updateComponentListeners(vm, listeners, oldListeners)
// ...
}
首先会获取上一次绑定的自定义事件,然后将此次的自定义事件赋值给_parentListeners;调用updateComponentListeners,并将新老自定义事件传入
updateComponentListeners
export function updateComponentListeners (
vm: Component,
listeners: Object,
oldListeners: ?Object
) {
target = vm
updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
target = undefined
}
updateComponentListeners函数内调用updateListeners函数;上面已经说过,在首次创建时,会创建一个invoker方法,并给这个方法添加一个属性fns用于存放定义的回调。然后就是调用传入的add函数。
对于自定义事件的add函数就是调用Vue.prototype.$on方法
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++) {
vm.$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.$on(event, fn) 时,根据事件名称 event把回调函数 fn 存到当前实例的_events属性中,vm._events[event].push(fn);注意vm._events[event]是一个数组
回到updateListeners,如果是更新流程,并且新老回调不同,则修改fns属性值。最后会遍历oldVnode的所有自定义事件,如果在新VNode中没有相同事件名,则通过vm.$off(event, fn)删除事件
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++) {
vm.$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
}
// specific handler
let cb
let i = cbs.length
while (i--) {
cb = cbs[i]
// cb.fn === fn:看 $once 的实现
if (cb === fn || cb.fn === fn) {
cbs.splice(i, 1)
break
}
}
return vm
}
Vue.prototype.$off流程如下
- 如果没有参数,清空
vm._events,并返回实例 - 如果传入的
event是数组,对数组每个元素调用Vue.prototype.$off,并返回实例 - 如果传入的
event是字符串,根据event获取回调函数- 如果回调函数不为空并且没有传入
fn参数,则将event从vm._events清除,vm._events[event] = null,并返回实例 - 如果传入了
fn参数,遍历回调函数数组,如果传入的fn参数和回调函数数组中某元素或某元素的fn属性相同,则将这个元素从数组中删除
- 如果回调函数不为空并且没有传入
.once修饰符
在updateListeners中还有一个逻辑
if (isTrue(event.once)) {
cur = on[name] = createOnceHandler(event.name, cur, event.capture)
}
就是对于有.once修饰符的自定义事件,调用的是Vue.prototype.$once方法
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
}
Vue.prototype.$once方法就是将fn包装了一层,当触发回调时,先将当前fn从vm._events中删除,然后再执行。
触发回调
触发方式就是通过this.$emit方法
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()
}
}
let cbs = vm._events[event]
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs
const args = toArray(arguments, 1)
const info = `event handler for "${event}"`
for (let i = 0, l = cbs.length; i < l; i++) {
invokeWithErrorHandling(cbs[i], vm, args, vm, info)
}
}
return vm
}
Vue.prototype.$emit方法就是根据传入的event从vm._events中获取对应的回调,然后执行回调
小结
组件VNode有两种事件一种是有.native修饰符的原生事件,另一种是自定义事件
对于原生事件:在创建组件VNode时,将有native修饰符的事件添加到data.on中;接着进入patch过程
- 创建阶段,会创建组件VNode的Vue实例和组件的DOM树;创建完成后执行
create钩子函数;create钩子函数中会获取组件根元素(elm),接下来会创建一个invoker方法并给这个方法添加一个属性值fns用于存放定义的回调函数;最后通过addEventListener对 组件的根元素 添加事件监听 - 更新过程就是调用
update钩子函数,如果没有老事件,就创建一个invoker方法,如果新老事件的回调函数不同,修改invoker方法的fns属性;最后取消监听新节点中没有的事件。
对于自定义事件:
- 创建阶段:在创建组件Vue实例时,为自定义事件创建
invoker方法并对方法设置fns属性;收集这些自定义事件到vm._events中;当调用this.$emit时,会从vm._events中拿到对应回调函数并触发。 - 更新过程:遍历新VNode的自定义事件,如果老VNode中没有相同事件名,则创建
invoker方法并对方法设置fns属性,将invoker方法通过Vue.prototype.$on添加到vm._events中;如果在老VNode中有相同事件名的自定义事件,并且新老回调不同,会修改invoker方法的fns属性,并将invoker方法赋值给新事件。最后将通过Vue.protorype.$off将新自定义事件中没有的事件名从vm._events中删除
Demo
<!DOCTYPE html>
<html lang="en">
<head>
<title>Document</title>
<style>
.i {
width: 100px;
height: 100px;
background-color: #f00;
}
</style>
</head>
<body>
<div id="div1">
<div class="i">asdfasdfsadf</div>
</div>
<script>
const oDiv = document.querySelector('#div1')
const oI = document.querySelector('.i')
oDiv.addEventListener('click', () => {
console.log('点击了 div')
})
oI.addEventListener('click', () => {
console.log('点击了 i')
new Promise((resolve) => {
resolve()
}).then(() => {
console.log('微任务触发')
})
})
</script>
</body>
</html>
总结
原生事件原理
有两种原生事件,一种是普通标签上的事件、另一种是组件标签上有.native修饰符的原生事件,这两种的逻辑相同都是通过addEventListener绑定给真实元素的。
自定义事件原理
Vue 实例上有一个_events属性,当创建组件实例时,将自定义事件通过$on添加到_events属性中。当通过$emit触发事件时,从_events属性上查找事件名对应的回调。
$on、$emit 是基于发布订阅模式的,维护一个事件中心,$on的时候将事件按名称存在事件中心(vm._events)里,称之为订阅者,然后$emit将对应的事件进行发布,去执行事件中心里的对应的回调
事件修饰符原理
常见的修饰符如下
.stop # 阻止冒泡
.prevent # 阻止默认事件
.self # 只当在 event.target 是当前元素自身时触发处理函数
.capture # 从设置该修饰符元素开始捕获,然后再冒泡
.passive # 告诉浏览器不想阻止事件的默认行为
.once # 只执行一次
.native # 指定该事件为原生事件
他们的原理也不同
编译时处理
stop: '$event.stopPropagation();'
prevent: '$event.preventDefault();'
self: ' if($event.target !== $event.currentTarget) return null;'
对于上述的.stop、.prevent、.self都是在编译时处理,原理就是创建一个接收$event参数的新函数。新函数内注入修饰符对应的代码并返回回调函数的执行结果。
如果标签上的回调函数没有括号,则会将$event传给回调函数
const Child = {
template: '<button @click.stop="clickHandle">click me</button>',
methods: {
clickHandle (e) {
console.log(e) // 打印 button 对象
}
}
}
// 编译过程创建的新函数
function($event){
$event.stopPropagation();
return clickHandle($event)
}
如果标签上的回调函数有括号,不会将$event传给回调函数
const Child = {
template: '<button @click="clickHandle()">click me</button>',
methods: {
clickHandle (e) {
console.log(e) // 打印 undefined
}
}
}
// 编译过程创建的新函数
function($event){
return clickHandle()
}
.native修饰符
在编译阶段,如果有.native修饰符,会将这个事件添加到data.nativeOn中
绑定时处理
.capture
.passive
.once
这三个都是在绑定的时候处理的
target.addEventListener(
name,
handler,
supportsPassive
? { capture, passive }
: capture
)
通过addEventListener绑定事件时,如果有.capture、.passive修饰符,则对应的变量为true,会将其设置到addEventListener上。 addEventListener 中的 passive 选项
.once修饰符
普通标签的.once修饰符,创建一个函数,函数内执行回调,执行完后通过removeEventListener删除监听。原生事件
组件标签上的.once修饰符和普通标签的类似,只不过使用的是$once方法。自定义事件
vm.$on( event, callback )原理
- 参数:
{string | Array<string>} event(数组只在 2.2.0+ 中支持){Function} callback
将event添加到当前Vue实例的_events中,属性值是callback数组
vm.$emit( eventName, […args] )
-
参数:
{string} eventName[...args]
触发当前实例上的事件。附加参数都会传给监听器回调
根据传入的事件名从当前Vue实例的_events属性上查找对应回调;将参数传入回调并执行
vm.$once( event, callback )
- 参数:
{string} event{Function} callback
创建一个新函数,将这个新函数添加到当前Vue实例的_events中。当执行这个新函数时,先调用vm.$off(事件名, 新函数)将事件从vm._events上删除;然后执行这个回调。
vm.$off( [event, callback] )
- 参数:
{string | Array<string>} event(只在 2.2.2+ 支持数组){Function} [callback]
Vue.prototype.$off流程如下
- 如果没有参数,清空
vm._events,并返回实例 - 如果传入的
event是数组,对数组每个元素调用Vue.prototype.$off,并返回实例 - 如果传入的
event是字符串,根据event获取回调函数- 如果回调函数不为空并且没有传入
fn参数,则将event从vm._events清除,vm._events[event] = null,并返回实例 - 如果传入了
fn参数,遍历回调函数数组,如果传入的fn参数和回调函数数组中某元素或某元素的fn属性相同,则将这个元素从数组中删除
- 如果回调函数不为空并且没有传入