前言
本篇文章带来Vue.js的事件机制,具体的分析点如下:
- $emit、$on、$off、$once背后的处理逻辑
- @click形式背后的处理逻辑
具体逻辑梳理
实际上在 Vue初始化 这篇文章中就提及了事件相关实例方法的创建,这里就在具体说下。
在Vue.js文件加载执行,其中会执行eventsMixin函数,该函数的作用就是:
创建事件相关的的原型方法,即$on、$once、$off、$emit
主要源码如下:
function eventsMixin (Vue) {
var hookRE = /^hook:/;
Vue.prototype.$on = function(event, fn) { // codes };
Vue.prototype.$once = function(event, fn) { // codes };
Vue.prototype.$off = function(event, fn) { // codes };
Vue.prototype.$emit = function(event) { // codes };
}
下面就来具体看看每个事件方法背后的处理逻辑(每个方法的处理逻辑不是很复杂,这里会把源码贴出来)。
$emit
该方法用于事件的触发操作
Vue.prototype.$emit = function (event) {
var vm = this;
{
var lowerCaseEvent = event.toLowerCase();
// 如果事件名是大写并且该事件存在
if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
// 输出提示
}
}
// 支持事件对应多个处理函数
var cbs = vm._events[event];
if (cbs) {
cbs = cbs.length > 1 ? toArray(cbs) : cbs;
// $emit支持传参
var args = toArray(arguments, 1);
for (var i = 0, l = cbs.length; i < l; i++) {
try {
// 执行对应的事件处理程序
cbs[i].apply(vm, args);
} catch (e) {
handleError(e, vm, ("event handler for \"" + event + "\""));
}
}
}
return vm
};
从上面的源码中可以知道三件事:
- 非小写事件名会有提示
- $emit支持传递多个参数
- 事件的注册中心就是Vue实例的_events变量,该变量保存中当前Vue实例的所有事件
疑问1: _events是如何收集事件的以及在哪里收集的呢?
$on
注册事件
Vue.prototype.$on = function (event, fn) {
var this$1 = this;
var vm = this;
// 支持批量注册事件
if (Array.isArray(event)) {
for (var i = 0, l = event.length; i < l; i++) {
this$1.$on(event[i], fn);
}
} else {
// 注册事件到_events中,并且知道_events是个对象
(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
// 判断是否是hook:开头的事件,这类事件会触发hook:开头的生命周期事件
if (hookRE.test(event)) {
vm._hasHookEvent = true;
}
}
return vm
};
通过$on方法的逻辑可以知道:
- 事件注册是通过
o
n
来
实
现
的
(
解
答
了
上
面
on来实现的(解答了上面
on来实现的(解答了上面emit的疑问)
- events是一个对象,实际上再准确点,vm._events是在init方法中initEvents函数中定义的
vm._events = Object.create(null);
vm._hasHookEvent = false;- 针对hook:开头的事件,实际上在callHook中调用
这里展开下callHook方法的,该方法主要执行生命周期函数,例如beforeCreate、created、mounted等。
callHook中涉及到hook:事件
if (vm._hasHookEvent) {
/*
请注意这里hook就是生命周期函数的名称,$emit会触发它们
hook:beforeCreate
hook:created
...
*/
vm.$emit('hook:' + hook);
}
疑问2:hook:开头的生命周期对应的事件是用来做什么的
如果这里使用者自定义事件名与Vue的生命周期同名,就有意思了,例如:
created() {
this.$on('hook:created', () => {})
}
那实际上按照上面的逻辑,因为callHook中$emit触发这里会自动执行。
$once和$off
只响应一次事件处理
Vue.prototype.$once = function (event, fn) {
var vm = this;
function on () {
vm.$off(event, on);
fn.apply(vm, arguments);
}
on.fn = fn;
vm.$on(event, on);
return vm
};
$off方法的处理就不贴代码了,主要就是调用数组的splice来删除保存的事件处理函数
@click或v-on:click的相关处理
以下面实例为主,分析关于事件的处理逻辑:
<button @click="handleClick">
点击
</button>
这里按照Vue流程阶段来具体说明:init阶段、render函数创建阶段、render函数执行阶段、patch阶段。实际上对应init阶段这里就不在细说,基本上都是相关属性的定义而已。
render函数构建阶段
通过之前的文章render函数生成细节和Vnode生成实际上可知道,对于template会通过HTML解释器完成ast生成和编译最终生成对应的code,上面实例生成的render函数内容如下:
with(this){
return _c('div',{
attrs:{"id":"app"}
},[
_c('button',{
on:{"click":handleClick}}
)
])
}
实际上就是Vue官方JSX那边的格式(详情可点击查看 )。如下几点关键信息需要注意:
- Vue中无论是自定义组件还是HTML原生标签、SVG原生标签,在render函数中都是调用_c实例方法来调用的
- 数据对象nativeOn是用于DOM原生事件,数据对象on存储所有除了DOM原生事件的
这个阶段主要就是生成render函数,相应事件存储在对应对象中。
render函数执行
虽然原生标签和组件都是调用_c实例方法来生成对应的虚拟对象VNode,但实际上它们的处理是存在很大的区别的。
_c实例方法中关于这边的处理差异逻辑具体如下:
if (config.isReservedTag(tag)) {
// platform built-in elements
vnode = new VNode(
config.parsePlatformTagName(tag), data, children,
undefined, undefined, context
);
} else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
vnode = createComponent(Ctor, data, context, children, tag);
}
对于浏览器原生标签,直接就创建虚拟对象VNode,而对于组件需要调用createComponent做进一步的处理。而createComponent的对于事件的具体处理可以查看文章Vue之v-model。
这里简单概括下:
- 对于原生标签,实例中button的click事件保存在数据对象on中,作为了虚拟对象VNode的data属性值
- 对于组件,组件非native事件都保存在虚拟对象VNode的componentOptions中listeners属性中,其data属性中数据对象on实际上native事件
对于数据对象nativeOn有一个非常重要的说明:
原生标签使用.native修饰符的事件,是没有任何效果的,.native修饰符修饰的事件仅仅作用于组件
对于原生标签只关心数据对象on中事件就行,而组件需要关注数据对象on和componentOptions中listeners,其数据对象nativeOn没有参入任何逻辑。
组件componentOptions中listeners是render函数生成阶段数据对象on的内容,即非.native修饰符修饰的事件,其相关注册逻辑查看文章Vue之v-model,其事件注册会在patch阶段Vue实例创建时通过updateComponentListeners来处理
render执行阶段实际上就是创建虚拟对象VNode,VNode中数据对象on中相关事件还未注册到events中心,而实际注册逻辑是在patch阶段。
patch阶段
patch阶段主要是使用diff算法进行DOM复用、删除和创建等工作,具体逻辑可查看文章Vue之patch。
在patch阶段除了子组件生成Vue实例外,还有一个create hooks执行逻辑,这两个逻辑是组件非native事件、组件native事件以及原生标签非native事件注册的关键逻辑。
Vue源码中对于数据对象的类别定义有相关对象,对象有一组hooks,例如:
// 处理attrs
var attrs = {
create: updateAttrs,
update: updateAttrs
}
// 处理class
var klass = {
create: updateClass,
update: updateClass
}
// 处理事件
var events = {
create: updateDOMListeners,
update: updateDOMListeners
}
// 处理style
var style = {
create: updateStyle,
update: updateStyle
}
还有其他的定义,这里就不一一列举。在patch阶段会对新的节点添加新的事件、class等,对旧的节点会删除事件、class等,Vue中通过一组组对象hooks让逻辑和结构更加清晰。
实际上patch阶段有很多hooks的执行,比如VNode的init、prefetch等来创建Vue实例、更新组件等操作,这里都暂不关心。这里主要关心updateDOMListeners方法,而该方法中主要逻辑如下:
function updateDOMListeners (oldVnode, vnode) {
// 新旧vnode都不存在事件相关
if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {
return
}
var on = vnode.data.on || {};
var oldOn = oldVnode.data.on || {};
target$1 = vnode.elm;
normalizeEvents(on);
// 更新事件监听
updateListeners(on, oldOn, add$1, remove$2, vnode.context);
target$1 = undefined;
}
从上面可知,内部实际上是调用了updateListeners方法,具体看看updateListeners的处理逻辑,流程逻辑如下:
从上面的逻辑中需要关注两个步骤的处理逻辑,这里就具体暂开:
- 相关处理步骤
- add$1函数的具体处理逻辑
updateListener中的相关处理
if (isUndef(cur)) {
// 报错提示
} else if (isUndef(old)) {
// 旧vnode没有事件
// 新vnode事件处理函数没有fns属性
if (isUndef(cur.fns)) {
cur = on[name] = createFnInvoker(cur);
}
// 调用add(即add$1)函数
add(event.name, cur, event.once, event.capture, event.passive, event.params);
} else if (cur !== old) {
old.fns = cur;
on[name] = old;
}
createFnInvoker函数
function createFnInvoker (fns) {
function invoker () {
var arguments$1 = arguments;
var fns = invoker.fns;
// 可知vue中html中函数定义支持多个函数公共处理
if (Array.isArray(fns)) {
var cloned = fns.slice();
for (var i = 0; i < cloned.length; i++) {
cloned[i].apply(null, arguments$1);
}
} else {
// return handler return value for single handlers
return fns.apply(null, arguments)
}
}
// 定义fns属性,实际上fns就是事件处理函数
invoker.fns = fns;
return invoker
}
add$1函数
核心函数,实现函数的注册
function add$1 (
event,
handler,
once$$1,
capture,
passive
) {
handler = withMacroTask(handler);
if (once$$1) { handler = createOnceHandler(handler, event, capture); }
target$1.addEventListener(
event,
handler,
supportsPassive
? { capture: capture, passive: passive }
: capture
);
}
从add$1函数的逻辑中,很清晰的知道下面三点信息:
- 使用addEventListener来实现事件绑定
- withMacroTask函数处理了事件处理处理
- 事件只响应一次调用createOnceHandler函数做了特殊处理
function withMacroTask (fn) {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true;
var res = fn.apply(null, arguments);
useMacroTask = false;
return res
})
}
从上面源码可知,就是定义了_withTask方法,而该方法中在事件处理之前设置了全局useMacroTask为true。该全局属性只会在$nextTick函数中使用到,实际上就是用来控制是使用macroTask API还是microTask API。用来解决一些场景下事件的问题(具体可看Vue Github对应的issue:#4521、 #6690、#6566)。
createOnceHandler函数
function createOnceHandler (handler, event, capture) {
var _target = target$1; // save current target element in closure
return function onceHandler () {
var res = handler.apply(null, arguments);
if (res !== null) {
// 从上面add$1可知,该函数必然调用了removeEventListener
remove$2(event, onceHandler, capture, _target);
}
}
}
总结
结合之前文章v-model和本文总结事件注册细节点:
-
_events中保存的当前实例对象的所有的事件定义
-
对于事件支持多个事件处理函数,也支持一次响应
-
hook:开头的对应的生命周期名称事件会被自动执行,例如:hook:created
-
原生标签不支持.native修饰的,对于原生标签的事件:
render创建时仅仅获取事件和事件处理程序还未注册,在patch阶段在调用events create hook即updateDOMListener,底层使用addEventListener来实现事件绑定
-
组件支持.native修饰符修饰的事件以及非.native修饰符修饰的事件,二者的处理不同
- .native修饰符修饰的事件,跟原生标签的事件处理逻辑是是相同的(组件的.native修饰的事件在render执行期间从数据对象nativeOn赋值给了数据对象on,数据对象on中保存的非.native修饰的事件保存在componentOptions中listeners中)
- 非.native修饰符修饰的事件,在其组件Vue实例化时调用updateComponentListeners函数注册到events中心了
实际上无论是组件事件还是原生事件背后最后都是调用updateListenrs函数,只不过是最后注册逻辑不同(原生标签使用addEventListenr注册,而组件非.native修饰的事件注册到events中心)。