Vue-事件的注册和触发
写Vue
最常用的就是各种交互事件,各种v-on:xxx
比如DOM
事件click、hover
,还有自定义的那些更加乱七八糟。
不同于React
,Vue
里的事件是否为自定义事件,是按照是否写在组件标签上来区分的。如果是组件,除加额外的:native
等属性,否则一众事件都使用$on/$emit
的模式来调用,哪怕是on:click
这些DOM
事件。如果是普通tag
,哪怕是自定义事件,也会以addEventListener
添加。
比如分别在组件和普通tag
上分别定义了DOM
事件和自定义事件
1 2 3 4 5 |
<button-counter v-on:click="btnClickHandler" v-on:counter="counterHandler"></button-counter> <div v-on:click="btnClickHandler" v-on:counter="counterHandler"></div> |
相应的处理方法定义(在根Vue
实例上)
1 2 3 4 5 6 |
btnClickHandler () { console.log('btnClickHandler'); }, counterHandler () { console.log('counterHandler'); } |
之后,经过模板解析,上面的内容变为
1 2 3 4 5 6 |
_h(tagName, { // 'button-counter'或'div' on: { 'click': btnClickHandler, 'counter': counterHandler } }) |
一眼看上去,除了tagName
,其他地方没什么区别,下面createElement
的地方才是区别所在,这个在上一篇(Vue-组件的初始化和渲染过程)讲过。除了渲染的区别,事件的注册也有差别。
这里把事件的流程单独拿出来看一下,基本的渲染步骤是一样的。
事件注册
首先,模板解析之后,事件会被放入data
,所有的节点都会这样处理,没差别
1 2 3 4 5 6 |
data: { on: { click: boundFn counter: boundFn } } |
开始渲染
1 2 |
vm._render -createElm |
如果是普通tag,会使用无componentOptions
参数的方式初始化VNode
1 |
new VNode(...) // 无componentOptions参数 |
之后,就会创建普通DOM
元素document.createElement(tag)
,然后调用addEventListeners
注册事件。
如果是组件,会使用有componentOptions
参数的方式初始化VNode
1 2 3 4 5 6 7 8 9 10 |
-new VNode(..., componentOptions) // componentOptions的结构 componentOptions = { Ctor: Ctor, propsData: propsData, listeners: listeners, // data.on tag: tag, children: children } |
接着,实例化组件
1 2 3 4 5 6 7 |
new VueComponent(options) // options结构中包含了组件标签上的事件(listeners) options = { ... _parentListeners: vnode.componentOptions.listeners ... } |
之后,不同的地方来了。Vue
把这部分时间通过vm.$on
的方式,放进了实例的_events
对象中。这个对象干啥用呢?_events
存放的这些functions
,会在调用$emit
的时候,作为回调事件触发。
之后按照之前的流程,组件会在初始化所有子内容之后,把第一个子DOM
元素放入vnode.elm
。然后在这个元素上执行addEventListeners
,这里对事件会有一些过滤。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
-Vue._init -initEvents -updateListeners -vm.$on // 组件事件被存入实例的_events中,供$emit触发时,回调使用 -vm._events = { click: [array of function], counter: [array of function] } // 初始化组件的后续(hooks)工作 -initComponent -invokeCreateHooks // 这里会做一系列工作,后面说 -updateDOMListeners -vnode.elm.addEventListeners |
简单画了个流程图
Vue
的事件可以不用bind
绑定作用域(像react
一样)?
看模板解析后的结果:
1 2 3 4 |
(function anonymous( ) { with(this){return _h('div',{attrs:{"id":"demo"},on:{"click":function($event){return 0}}},[_h('div',{on:{"click":clickHandler}},[_s(foo)+", "+_s(bar)])])} }) |
其中this
就是vm
实例,在调用_h
(即createElement
)时会使用这个作为作用域。
问题:如何让组件可以监听DOM
事件?
添加.native关键字,如v-on:click.native='xxx'
。
这样,在处理的时候,事件会被分为on
(如果有的话)和nativeOn
存放
1 2 3 4 |
data = { nativeOn: function, on: function } |
在new VNode
的时候,会分别处理:on
里的放vm._events
,nativeOn
通过addEventListener
注册。
1 2 |
var listeners = data.on; data.on = data.nativeOn; |
由于后面vnode.elm
会直接取第一个子元素,所以这个操作实际是把事件透传给渲染出的第一个子元素。但是如果这个子元素不支持这个事件(比如给div
添加了focus
事件),那么就会无效(addEventListener
不支持这个事件,静默失败)。
解决方法是另外一个“补丁”-$listeners
。这里不讲(当前代码版本还未支持)。
问题:为什么普通tag
不能用v-on:counter
绑定自定义事件?
普通tag
也是调用了addEventListener
了的,但是对于DOM元素来说,DOM事件
是固定类别的,所以会静默失败。
补充:initEvents的处理过程
initEvents使用了闭包处理,这里直接贴代码吧,因为代码太清楚了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
var on = bind(vm.$on, vm); var off = bind(vm.$off, vm); vm._updateListeners = function (listeners, oldListeners) { updateListeners(listeners, oldListeners || {}, on, off); }; if (listeners) { vm._updateListeners(listeners) } // 其中bind函数,接收两个参数(fn,ctx),返回一个函数 function boundFn (a){ fn.apply(ctx, arguments) / fn.call(ctx, a) / fn.call (ctx) } function updateListeners (on, oldOn, add, remove) { // ... fn = cur = on[name]; // 这里简略,实际是遍历name in on cur = on[name] = {}; cur.fn = fn; cur.invoker = fnInvoker(cur); // fnInvoker是个统一处理函数,调用cur.fn add(event, cur.invoker, capture); // ... } |
这段代码的入口是vm._updateListeners。
1 2 3 |
-updateListeners //传入了两个函数on/off,都用闭包添加了vm实例作为作用域 -on(event, cur.invoker, capture) // cur.invoker又是一个闭包,cur处在其作用域里 -vm._events[event].push(cur.invoker) |
这样,回调函数都使用闭包绑定了对应的环境变量。
补充:invokeCreateHooks做了哪些工作
invokeXXXHook(s)
里定义了一系列的节点操作,这些函数会依次在节点上调用。类似的还有invokeDestroyHook
、invokeInsertHook
。
这一步会依次执行预制的create
处理函数(包含以下7个)
1 2 3 4 5 6 7 |
function updateAttrs (oldVnode, vnode) { } function updateClass (oldVnode, vnode) { } function updateDOMListeners (oldVnode, vnode) { } function updateDOMProps (oldVnode, vnode) {} function updateStyle (oldVnode, vnode) {} function create (_, vnode) {} function bindDirectives (oldVnode, vnode) {} |
事件触发
事件注册之后就要触发。触发主要做的在于更新操作,所以在 Vue-初始化和渲染过程 里收集的依赖,这里也要挨个处理啦!
首先,比如一个点击事件,就会在DOM
上触发处理函数fnInvoker
1 2 |
-boundFn(event) -clickHandler.call(ctx, event) |
比如这里进行data
的更改(set),那么就会触发使用defineProperty
定义好的reactiveSetter
。
前面说过,一个data
属性对应一个Observer
,一个Observer
里包含一个dep
实例。dep.subs
中存储了所有观察者(Watcher
),属性变更,就会触发(notify
)这些watcher
。这里就是要走一遍这个流程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
clickHandler -proxySetter -reactiveSetter // 触发watcher.update dep.notify() -subs[i].update =watcher.update -queueWatcher(this) // this == 当前Watcher // 放入队列 -queue.push(watcher) if (!waiting) { waiting = true; nextTick(flushSchedulerQueue); } // 执行watcher flushSchedulerQueue // Flush both queues and run the watchers -Watcher.run -Watcher.get |
触发一次渲染,渲染流程就和初始化时一样了。
注意,flushSchedulerQueue
时,会对watcher
进行一个排序,前面说过,watcher
是按id自增的,所以id为0的是根Vue
实例的,按照层级加深,id递增。所以这个顺序会保证:
1、组件是从父节点到子节点更新的;
2、用户的watcher
永远在render
的watcher
之前执行(用户watcher
以后再看);
3、如果在父组件watcher执行过程中,某个子组件销毁了,这个子组件不会影响进度(直接被跳过)。
如果没有排序,那么如果先更新子组件,向上到某一级,这个子组被销毁了,那么就导致状态不一致,必须返回去再更新这个子组件。
补充:nextTick
的设计
nextTick
用于使某个函数在下次渲染的时候,才被调用。
nextTick
基本结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
nextTick = queueNextTick (cb, ctx) { // 队列处理 callbacks.push(func); if (!pending) { pending = true; timerFunc(nextTickHandler, 0); // nextTickHandler负责依次执行callbacks队列的函数 } } // 其中timerFunc函数的结构(如果浏览器不支持promise,会使用MutationObserver,或者setTimeout) p = Promise.resolve(); timerFunc = function () { p.then(nextTickHandler); } |
后话:观感
Vue
的代码初看……很简单嘛。但是细看却会一头雾水,流程也变得模模糊糊的,反思了一下,可能大部分的初始化(比如nextTick
、watcher
)是在初始化时内置好的,时间一长就会忘记。而且和环境本身绑定紧密(大量使用defineproperty
、闭包作用域等概念),需要时不时的想一想“这里闭包里都有啥呢”这样的问题。
这么一想,React
可能一开始就是奔着到处运行(比如React-native
)去的吗?
技术学习了 有用
有没有人直接跳到评论的。