vue源码阅读—10—扩展:原生event和自定义event
let Child = { template: '<button @click="clickHandler($event)">' + 'click me' + '</button>', methods: { clickHandler(e) { console.log('Button clicked!', e)
this.$emit('select') } } } let vm = new Vue({ el: '#app', template: '<div>' + '<child @select="selectHandler" @click.native.prevent="clickHandler"></child>' + '</div>', methods: {
//原生dom事件;这里要添加native,否则无法生效; clickHandler() { console.log('Child clicked!') }, selectHandler() { console.log('Child select!') } }, components: { Child } })
https://blog.csdn.net/A444477/article/details/122272089?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-122272089-blog-104938984.pc_relevant_multi_platform_whitelistv3&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-122272089-blog-104938984.pc_relevant_multi_platform_whitelistv3&utm_relevant_index=2
注意,原生dom事件这里要添加native,否则无法生效;
为什么?
可能就像上篇文章说的,我们在子组件上,增加监听事件,只能监听子组件的自定义事件,比如@select这种;
如果想监听子组件的原生dom事件,比如click、mouseup、doubleClick这种,都必须加上native,否则还是当做监听自定义事件来处理;
1.为什么需要.native修饰符?
因为vue的监听都是监听自定义事件的,如果我们想监听原生事件,所以需要.native,如果没有.native,当我们监听@click时还会一直等待子组件触发this.$emit('click')。
至于我们写得elemenui组件,好像监听时不需要加.native,我猜测,是因为elemenui内部触发了this.$emit('click')。
2.为什么我们加了.native修饰符,就可以监听到子组件的原生事件?
很简单,这个就是js高级的内容。原生事件是会冒泡的,从子元素开始,一直冒泡到顶层元素,这一路的元素只要有监听都会触发,所以父组件可以监听到。
其次,我们给子组件添加原生dom事件,实际上是会加到子组件的根元素上的,
所以无论怎么说,都会监听到;
3.原生dom事件和自定义事件的区别?
原生dom事件是在vnode.data.on上;自定义事件是在vnode.data.listener上;
4.添加原生dom事件的总结:
1.给普通html元素:
在pathc阶段,给普通vnode创建完dom后、和createChilren后,会调用invokeCreateHooks函数,
这里面会执行属性、事件、指令等的create钩子函数(注意不是组件实例的create钩子函数)
在事件的create钩子函数中,会调用updateDomLIsteners方法的updateListeners方法,因为是创建阶段,所以又会调用add方法,add方法首先把回调函数使用withMacroTask函数中包裹,然后就给目标真实dom元素,使用target.addEventListeners添加监听事件;
2.给组件
在patch阶段,给组件创建完真实dom后会执行initComponent方法,这个方法也会调用invokeCreateHooks函数,
然后也会调用事件的updateDomLIsteners方法的updateListeners方法去给子组件的根元素上添加原生监听方法;
注意这个target是子组件的根元素,所以我们是给子组件的根元素添加了监听事件;
5.添加自定义事件的总结:
在子组件实例化阶段,
在mergeOptions时会把父组件给子组件占位符定义的listeners都赋值给opt._parentListwners。
然后在子组件initEvents时,会调用updateComponentListeners方法,这个方法实际上还是和原生事件定义一样,继续调用updateListeners的add方法。
但是不一样的地方在这,
原生事件通过add方法实际是给真实元素添加一个addEventListeners。
但是自定义事件,其实是给子组件实例打开了一个事件中信this.$on(父组件传递的回调函数),然后当子组件实例调用this.$emit时,就会触发$on事件,然后执行回调函数。
所以,子父组件其实是通过事件的回调函数通信的。
6.vue内部
- 父子组件通信props
- 子父组件通信event
- 插槽
- 内置指令v-model
- 内置组件keeplive
一、事件的编译阶段
事件具体的 编译流程暂不考虑,condergen之后的代码如下:
我们刚刚的例子中,父组件,自定义事件@select和原生事件@click.native,经过parse、optimize、genHandler后生成的data串如图所示;

子组件的事件如图:

二、原生dom事件
给普通元素添加原生 dom事件
给组件添加原生dom事件(通过.native);
我们的这个例子中,child组件给button元素添加了 click原生dom事件;
父组件给child子组件添加了 click.native原生dom事件;
2.1原生dom事件用到的api
比如click、mouseup、doubleClick等;
还记得我们之前在 patch 的时候执行各种 module 的钩子函数吗,当时这部分是略过的,我们之前只分析了 DOM 是如何渲染的,而 DOM 元素相关的属性、样式、事件等都是通过这些 module 的钩子函数完成设置的。
这些moudule其实主要是在src/platforms/web/runtime/modules路径下定义的;主要就是一个对象,然后厘米有create和update两个属性;

我们在获取patch函数时,会使用createPatchFunction({nodeOps, modules}),然后会传递modules;
在执行createPatchFunction时,通过一个对象结构cbs,可以保存每一个hooks会用到的回调函数;

那么什么时候执行这些hooks呢? invokeCreateHooks函数就是执行cbs对象里属性为create的钩子函数;

然后invokeCreateHooks会在什么时候执行?
原生dom事件是怎么添加到真实dom上的?
我们执行invokeCreateHooks,实际上就是执行updateDomListeners,然后会执行add方法,原生dom事件的add方法会使用target.addListernesEvents();所以监听事件被添加;
被添加在哪里呢?
实际上是添加在根组件元素上;在这个例子中,根组件元素button会有两个click事件;一个是普通html元素定义的click事件,一个是父组件给子组件child添加的click事件然后被绑定在根元素button上。
2.2原生dom事件被添加到的流程
在patch父组件时,会先createChildren,

由于child是一个组件,所以走这一步,后续就是实例化子组件;

然后子组件child也到了createChildren的时候,不过它的子节点是一个按钮button普通节点并没有包含组件节点,所以就不进入细看了。
然后开始执行invokeCreateHooks方法,这个方法我们前面介绍过,主要就是执行dom的各种属性attr、各种事件event的钩子函数;
所以这里,我们会执行event.js里定义的create属性,也就是updateDomListeners方法;
然后执行updateListeners方法;
1.由于我们是create阶段,所以老的监听器肯定是为空的,所以isUndef(old)为true,所以命中这一块逻辑;
2.然后通过createFnInvoker函数,返回一个invoker函数赋值给cur; (为什么需要这个invoke函数?invoke是援助辅助的意思,因为我们的监听事件的回调函数有可能是一个函数也有可能是一个数组,如果想全部通过cur()的方式去执行会报错,因为数组不能通过括号()的方法执行呀; 所以就搞一个createFnInvoke函数,把cur穿进去,然后返回一个invoker函数,我们执行invoker函数,里面会自动判断cur是一个数组还是一个单纯的回调函数,然后里面会自己全部执行;这就是辅助函数的作用;)
3.执行add方法
原生dom事件的add方法和自定义事件的add方法是不一样的;
原生dom事件的add方法是先把cur即invoke函数即handler放入到宏任务中;
然后通过element.addEventListener的方法把监听事件加入到html元素上;
(回过头来说说,放到宏任务这个具体什么意思呢?就是说,我们在执行我们自己定义的回调函数的时候,如果回调函数里都是单纯的同步代码,没什么区别,但如果我们在回调函数里,执行了this.$nexttick(函数aaa),那么这个函数aaa会在宏任务里执行,而不是传统的使用微任务了。
)
然后到了这里,我们子组件自己给button定义的click事件就被添加了。
那么我们的父组件通过<child @click.native='xxx'></child>给子组件根元素添加的click事件什么时候被加入呢?
别急,等我们子组件的render阶段和patch阶段都完成后, 也就是 i(vnode, false /* hydrating */, parentElm, refElm)都执行完后,
会执行initComponent方法;这个方法的第一个形参是组件vnode也可以说是占位符vnode,vnode是子组件vnode即vue-compoment-1-child;
但是这个组件vnode的data上有我们给子组件添加的监听事件@select和@click;
然后initComponent也会执行invokeCreateHooks方法;
然后继续执行updateDomListeners方法;
注意:在render阶段,原有的vnode.data.on保存的是自定义事件,但已经赋值给了vnode.listeners;原生事件重新赋值给了vnode.date.on;
后续操作就和子组件添加自己定义的click事件一样了。注意这个target是子组件的根元素,所以我们是给子组件的根元素添加了监听事件;
因为原生dom事件的add方法是通过element.addEventListeners的方式添加的,所以当我们点击子组件时,两个click事件所对应的回调函数都会执行,但是由于子组件自定义的click是先添加的,所以会先执行,父组件给子组件添加的监听方法是后添加的所以后执行;

至此,子组件的两个原生dom事件---click事件全部添加成功;
2.4为什么事件回调函数里要使用宏任务
https://www.lmlphp.com/user/6824/article/item/348675/
主要是解决一种由事件冒泡引起的edge-case;
第一,我们知道事件回调执行完成之后,会马上执行微任务。
所以说,如果我们在回调函数里修改了数据,那么会触发响应式的setter,然后this.$nexttick(flushScheduleQueue),因为没有useMacroTask = true,所以这是个微任务,
然后因为事件回调函数执行完成后,会立马执行微任务,所以会触发渲染watcher的重新渲染;
第二,我们知道,事件冒泡,那么如果最内层的div和外面几层的dev都监听这个事件呢?
那就会造成连续多个事件回调同时执行,就会导致连续多次执行微任务
如果连续多个事件回调中,都有修改数据,如下this.state = xxxxx
那么很明显,会导致页面频繁的更新,这显然不是我们想要的结果
举个例子:
<body>
<div class="div1" style="height: 100px; width: 100px; background: red">
<div class="div2" style="height: 60px; width: 60x; background: black">
<div
class="div3"
style="height: 30px; width: 30px; background: blue"
></div>
</div>
</div>
<script>
document.querySelector(".div1").addEventListener("click", () => {
console.log("div1");
//this.state = xxx; 模拟数据被修改
// 然后watcher会重新渲染
Promise.resolve().then(() => {
console.log("promise1");
});
});
document.querySelector(".div2").addEventListener("click", () => {
console.log("div2");
//this.state = xxx; 模拟数据被修改
// 然后watcher会重新渲染
Promise.resolve().then(() => {
console.log("promise2");
});
});
document.querySelector(".div3").addEventListener("click", () => {
console.log("div3");
//this.state = xxx; 模拟数据被修改
// 然后watcher会重新渲染
Promise.resolve().then(() => {
console.log("promise3");
});
});
</script>
</body>
当我们点击蓝色方块时能看到,click事件从内部往外部传;先是div3答打印再是div2打印,最后div1打印;
还能看到最重要的一点,那就是,事件div3监听到后,并没有打印div2,而是立马执行微任务队列里的promise3了。

所以说,如果我们回调函数里的任务队列也是微任务队列的话,会很麻烦,会造成渲染watcher的重复渲染,等到前面一个promise3渲染好之后,才会处理div2的回调函数,然后又要渲染watcher重新渲染,
所以这个这个时候我们应该使用witchMarcoTask,这样会先等到所有的回调函数都执行完后,才去渲染;
三、自定义事件
总结:
在子组件实例化阶段,
在mergeOptions时会把父组件给子组件占位符定义的listeners都赋值给opt._parentListwners。
然后在子组件initEvents时,会调用updateComponentListeners方法,这个方法实际上还是和原生事件定义一样,继续调用updateListeners的add方法。
但是不一样的地方在这,
原生事件通过add方法实际是给真实元素添加一个addEventListeners。
但是自定义事件,其实是给子组件实例打开了一个事件中信this.$on(父组件传递的回调函数),然后当子组件实例调用this.$emit时,就会触发$on事件,然后执行回调函数。
所以,子父组件其实是通过事件的回调函数通信的。
子组件child不会再给自己的根html元素button再添加自定义事件了。
因为自定义事件必须是定义在组件上的,button是原生html元素,只可以添加原生dom事件,
所以我们分析自定义事件时,不需要考虑子组件自己给自己根元素添加自定义事件这样一种情况了。
3.1render阶段即vm_render()阶段
在 render 阶段,如果是一个组件节点,则通过 createComponent 创建一个组件 vnode,我们再来回顾这个方法,定义在 src/core/vdom/create-component.js 中:

我们只关注事件相关的逻辑,可以看到,它把 data.on (这里面是父组件要监听子组件的自定义事件比如@select)赋值给了 listeners,把 data.nativeOn (这里面是父组件要监听子组件的原生dom事件,比如@click.native)赋值给了 data.on,
这样对于所有的原生 DOM 事件处理跟我们刚才介绍的一样,它是在当前组件环境中即父组件处理的。
这样对于所有自定义事件,我们把 listeners 作为 vnode 的 第7个参数即componentOptions对象 的第三个属性listeners传入,它是在子组件初始化阶段中处理的,所以它的处理环境是子组件。
3.2pathc阶段即vm_update()阶段
父组件即vm根组件在patch过程中,会走createChildren方法,去创建子组件。

如果vnode是组件类型,则初始化子组件;

所以,子组件会实例化,
然后子组件实例化时,会调用this_init()方法,然后会调用initInternalComponent方法;

因为父组件监听子组件的事件都保存在父vnode的componentOptions里(参考上文的render阶段),
所以我们通过第二步 opts._parentListeners = vnodeComponentOptions.listeners,就可以把父组件的监听事件@select和@click.native.prevent都拿到,并赋值给子组件vm.$options._parentListeners;

继续走,我们走到了initEvent;

拿到 listeners 后,执行 updateComponentListeners(vm, listeners) 方法:

然后执行方法updateListeners

然后执行updateListeners方法里的add方法;调用了add方法后,就算开始监听这个事件了,等待后续的emit方法即可;

至此自定义事件的监听到此结束!!
但是这个add和remove跟原生dom事件有什么不同?我们来看一下:
这个add方法正是和原生dom事件的add方法不一样的方法;
updateListeners 我们之前介绍过,所以对于自定义事件和原生 DOM 事件处理的差异就在事件添加和删除的实现上即调用updateListener函数时传递的第三个参数add和第四个参数remove有所不同;
来看一下自定义事件 中add 和 remove 的实现有多么不同:
在eventsMixin函数里,定义了vm实例的$on、$off、$once、$off方法;


这是非常经典的事件中心的实现,把所有的事件用一个数组即 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 方法,这样就相当于完成了一次父子组件的通讯。
四、总结
父子组件可以通信,是因为子组件会触发this.$emit(), 然后会执行一个保存在vm._event里的回调函数,而这个回调函数是父组件传过来的,所以可以把数据传过去;










浙公网安备 33010602011771号