目标:1、了解event的实现原理。2、了解Dom事件和自定义事件的区别。
平时开发过程中,组件间通讯,原生交互都离不开事件,对于一个组件元素,我们可以绑定原生JS事件(@click),也可以绑定自定义事件(@emit),非常灵活和方便。我们接下来会从源码角度看它实现原理。
首先是编译
编译-parser:
标签属性attrsList做处理,判断如果是指令,先解析出修饰符,判断如果是事件的指令,执行addHandler(el, name, value, modifiers, false, warn)。
parser建立AST节点树后,在节点处理过程中会执行processAttrs方法,它会对节点上的标签属性attrsList做处理。processAttrs,遍历节点上的attrsList,拿到name属性,先判断name是否匹配模版指令的正则表达式(比如v-,:),如果匹配到,给节点的hasBindings属性设为true(标志是动态节点)。
解析修饰符,接下来对name做parseModifiers(name)操作得到modifier。如果有modifer,把name中modifer相关字段去掉。解析事件指令,对name中'v-on'字段检测,满足的话把这个字段也去掉,name就变成click,调用addHandler。1、根据modifier修饰符对事件名作修改。如果name为click且modifer中有 .right ,把name变成'contextmenu';如果name为click且modifer中有 .middle ,把name变成'mouseup'。2、根据modifer.native判断原生事件还是普通事件。接下来构造event对象,如果有 .native ,构造nativeEvents对象,否则构造events对象。3、按照name对事件做归类,并把回调函数的字符串保留到对应的事件中。接着构造newHandler对象{value: 事件名,如果modifer存在,把其中键值对写入}。接下来,把events[name]赋值给handlers,把newHandler赋值给events[name]。最终生成的节点有event或者nativeEvent对象(以事件名为key,值可能为newHandler,或者多个newHandler构成的数组),
parseModifiers(src/compiler/helpers.js),对属性名上的修饰符做处理(如.natice,.prevent),得到modifier对象,包含了一个个 修饰符名:true 键值对。
addHandler,给AST节点添加event属性,并根据modifer解析出来的标记给name上打标记(比如.once存在,name='~'+name)。
上面这个阶段,例子中我们得到的结果是:
编译-codegen:
genData函数中根据AST元素节点上的events和nativeEvents生成data数据,它的定义在src/compiler/codegen/index.js中:
对于这两个属性,会调用 genHandlers 函数(src/compiler/codegen/events.js)。目标是生成和事件相关的代码。
genHandlers 根据isNative判断res的值为'nativeOn:{' 或者 'on:{'。遍历events对象,拼接genHandler会生成handler的代码,拼接出一个JSON代码对象。,返回值用","分割。最终返回形式是[xxx,xxx],res拼接得到{name:[function() {...},function() {...}],name1:[function() {...},function() {...}]...}
genHandlers方法遍历事件对象 events,遍历events对每个事件调用 genHandler(name, events[name]) 方法,拼接结果(res+=`${name} : ${genHandler(name, events[name])}`)
genHandler方法,如果events[name]是数组,map遍历它递归调用genHandler方法。1、正则去匹配event[name].value,判断它是一个函数的调用路径还是一个函数表达式。2、如果handler的modifers修饰符不存在,return `function($event) {${handler.value}}`;存在的话,遍历modifers对象key,根据不同的key做不同的逻辑操作,对于命中的key通过modiferCode(modiferCode是对不同修饰符key生成不同的代码)生成的代码临时储存在genModifierCode中,一步步把代码拼接起来。
这一阶段在我们的例子中得到的结果是:
整个编译过程实际上就是对整个模版做解析,解析过程中生成的代码,完整描述了事件的定义,为最终去运行做准备。
那么到这里,编译部分完了,接下来我们来看一下运行时部分是如何实现的。其实 Vue 的事件有 2 种,一种是原生 DOM 事件,一种是用户自定义事件,我们分别来看。
运行部分如何实现?
vue的事件有两种,原生DOM事件和用户自定义事件,分别来看。
DOM原生事件:
还记得我们之前在 patch 的时候执行各种 module 的钩子函数吗,当时这部分是略过的,我们之前只分析了 DOM 是如何渲染的,而 DOM 元素相关的属性、样式、事件等都是通过这些 module 的钩子函数完成设置的。
所有和 web 相关的 module 都定义在 src/platforms/web/runtime/modules 目录下,我们这次只关注目录下的 events.js 即可。
creatPatchFunction方法,创建patch方法。它先拿到modules(各个某块,由baseModules和platformModules合并而来)和nodeOps(和平台相关操作方法)。接下来遍历hooks(一个数组['creat','active','update'...]),对于每个hook,hook做为key,[]空数组做为值储存在cbs对象中。接下来遍历modules,查找modules中有没有定义这个hook,有的话,往前面的空数组[]内push, module对应的hook方法。(比如事件对应的cbs为{'creat': creatFun, 'update': updateFun })。什么时候执行cbs中hooks方法呢?
在createElm方法和creatComponent方法和更新节点patchVnode时会调用invokeCreateHooks方法,它会去遍历cbs中钩子函数进行执行。我们要看的是create钩子函数其中event相关方法,即updateDOMListener(oldVnode,newVnode)方法。
updateDOMListener,先去判断oldVnode.data和newVnode.data是否都有on属性(on属性其实就是我们前面编译阶段执行genHandlers时得到的,其值为代码JSON对象)。都没有的话直接return,有的话去取出存在on,oldOn两个变量中,再去取target=vnode.elm真实dom节点(因为我们需要在dom上添加事件)。接着执行normalizeEvents(on)(和v-model相关,先不管)。再执行updateListeners(on, oldOn, add, remove, vnode.context)方法。
updateListeners,它是被单独拿出来的文件,因为原生事件和自定义事件创建都会用到它。首次创建事件,之后更新事件。遍历on对象,拿到当前事件值cur和旧事件值old,对事件名执行normalizeEvent方法(前面对不同事件修饰符在name上做了标记,如‘~’,现在需要把它们作为Boolean返回并从name去掉)。
如果old未定义,cur=on[name]=createFnInvoker(cur),即把on[name]指向createFnInvoker返回的值。接下来执行add(event.name,cur,event.once,event.capture,event.passive,event.params)方法。它就是通过addEventListener在真实dom上绑定事件了。
createFnInvoker,最终会返回一个invoker函数(最终添加事件的函数)。invoker函数,先拿到传进来的on[name]赋值给fns。如果它是一个数组,遍历它依次去执行其内定义的函数,否则直接执行fns,通过它创建了一个回调函数。
如果old定义了且old和cur不相同,直接把old.fns指向cur,同时把on[name]指向old,我们只要把invoker.fns改变即可,不需要重新创建事件。(事件创建不用再重复执行)
了解了 updateListeners 的实现后,我们来看一下在原生 DOM 事件中真正添加回调和移除回调函数的实现( src/platforms/web/runtime/modules/event.js)
实际上就是调用原生 addEventListener 和 removeEventListener,并根据参数传递一些配置,注意这里的 hanlder 会用 withMacroTask(hanlder) 包裹一下(src/core/util/next-tick.js)
实际上就是强制在 DOM 事件的回调函数执行期间如果修改了数据,那么这些数据更改推入的队列会被当做 macroTask 在 nextTick 后执行。
自定义事件:
除了原生 DOM 事件,Vue 还支持了自定义事件,并且自定义事件只能作用在组件上,如果在组件上使用原生事件,需要加 .native 修饰符,普通元素上使用 .native 修饰符无效,接下来我们就来分析它的实现。
自定义事件只能作用在组件中,我们回顾一下组件vnode创建过程(子组件在父组件中占位符vnode的创建过程)createComponent。在创建组件vnode之前,会对事件做处理,会把data.on(自定义事件)赋值给listeners,listeners在组件实例化成VNode时会作为参数传入。
我们只关注事件相关的逻辑,可以看到,它把 data.on 赋值给了 listeners,把 data.nativeOn赋值给了 data.on,这样所有的原生 DOM 事件处理跟我们刚才介绍的一样,它是在当前组件环境中处理的(即子组件的原生dom事件在子组件占位符节点所在的父组件实例中处理)。而对于自定义事件,我们把 listeners 作为 vnode 的 componentOptions 传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件(即子组件的自定义事件在子组件本身实例环境中处理)。
然后在子组件的初始化合并options的时候,会执行 initInternalComponent 方法(src/core/instance/init.js)
这里拿到了父组件传入的 listeners,然后在执行 initEvents 的过程中,会处理这个 listeners(src/core/instance/events.js)
拿到 listeners 后,执行 updateComponentListeners(vm, listeners) 方法:
updateListeners 我们之前介绍过,所以对于自定义事件和原生 DOM 事件处理的差异就在事件添加和删除的实现上,来看一下自定义事件 add 和 remove 的实现:
实际上是利用 Vue 定义的事件中心,简单分析一下它的实现:
非常经典的事件中心的实现,把所有的事件用 vm._events 存储起来,当执行 vm.$on(event,fn) 的时候,按事件的名称 event 把回调函数 fn 存储起来 vm._events[event].push(fn)。当执行 vm.$emit(event) 的时候,根据事件名 event 找到所有的回调函数 let cbs = vm._events[event],然后遍历执行所有的回调函数。当执行 vm.$off(event,fn) 的时候会移除指定事件名 event 和指定的 fn 。当执行 vm.$once(event,fn) 的时候,内部就是执行 vm.$on,并且当回调函数执行一次后再通过 vm.$off 移除事件的回调,这样就确保了回调函数只执行一次。
所以对于用户自定义的事件添加和删除就是利用了这几个事件中心的 API。需要注意的事一点,vm.$emit 是给当前的 vm 上派发的实例,之所以我们常用它做父子组件通讯,是因为它的回调函数的定义是在父组件中,对于我们这个例子而言,当子组件的 button 被点击了,它通过 this.$emit('select') 派发事件,那么子组件的实例就监听到了这个 select 事件,并执行它的回调函数——定义在父组件中的 selectHandler 方法,这样就相当于完成了一次父子组件的通讯。