Axiu Blog
组件,除加额外的`:native`等属性,否则一众事件都使用`$on/$emit`的模式来调用,哪怕是`on:click`这些`DOM`事件。如果是普通`tag`,哪怕是自定义事件,也会以`addEventListener`添加。 比如分别在组件和普通`tag`上分别定义了`DOM`事件和自定义事件 相应的处理方法定义(在根`Vue`实例上) btnC
写`Vue`最常用的就是各种交互事件,各种`v-on:xxx`比如`DOM`事件`click、hover`,还有自定义的那些更加乱七八糟。 不同于`React`,`Vue`里的事件是否为自定义事件,是按照是否写在组件标签上来区分的。如果是组件,除加额外的`:native`等属性,否则一众事件都使用`$on/$emit`的模式来调用,哪怕是`on:click`这些`DOM`事件。如果是普通`tag
写`Vue`最常用的就是各种交互事件,各种`v-on:xxx`比如`DOM`事件`click、hover`,还有自定义的那些更加乱七八糟。 不同于`React`,`Vue`里的事件是否为自定义事件,是按照是否写在组件标签上来区分的。如果是组件,除加额外的`:native`等属性,否则一众事件都使用`$on/$emit`的模式来调用,哪怕是`on:click`这些`DOM`事件。如果是普通`tag
Vue-事件的注册和触发
Max

Vue最常用的就是各种交互事件,各种v-on:xxx比如DOM事件click、hover,还有自定义的那些更加乱七八糟。

不同于ReactVue里的事件是否为自定义事件,是按照是否写在组件标签上来区分的。如果是组件,除加额外的:native等属性,否则一众事件都使用$on/$emit的模式来调用,哪怕是on:click这些DOM事件。如果是普通tag,哪怕是自定义事件,也会以addEventListener添加。

比如分别在组件和普通tag上分别定义了DOM事件和自定义事件

相应的处理方法定义(在根Vue实例上)

btnClickHandler () { console.log('btnClickHandler'); }, counterHandler () { console.log('counterHandler'); }

之后,经过模板解析,上面的内容变为

_h(tagName, { // 'button-counter'或'div' on: { 'click': btnClickHandler, 'counter': counterHandler } })

一眼看上去,除了tagName,其他地方没什么区别,下面createElement的地方才是区别所在,这个在上一篇(Vue-组件的初始化和渲染过程)讲过。除了渲染的区别,事件的注册也有差别。

这里把事件的流程单独拿出来看一下,基本的渲染步骤是一样的。

事件注册

首先,模板解析之后,事件会被放入data,所有的节点都会这样处理,没差别

data: { on: { click: boundFn counter: boundFn } }

开始渲染

vm._render -createElm

如果是普通tag,会使用无componentOptions参数的方式初始化VNode

new VNode(...) // 无componentOptions参数

之后,就会创建普通DOM元素document.createElement(tag),然后调用addEventListeners注册事件。

如果是组件,会使用有componentOptions参数的方式初始化VNode

-new VNode(..., componentOptions)

// componentOptions的结构 componentOptions = { Ctor: Ctor, propsData: propsData, listeners: listeners, // data.on tag: tag, children: children }

接着,实例化组件

new VueComponent(options) // options结构中包含了组件标签上的事件(listeners) options = { ... _parentListeners: vnode.componentOptions.listeners ... }

之后,不同的地方来了。Vue把这部分时间通过vm.$on的方式,放进了实例的_events对象中。这个对象干啥用呢?_events存放的这些functions,会在调用$emit的时候,作为回调事件触发。

之后按照之前的流程,组件会在初始化所有子内容之后,把第一个子DOM元素放入vnode.elm。然后在这个元素上执行addEventListeners,这里对事件会有一些过滤。

-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 event init

vue事件注册流程

问题:为什么Vue的事件可以不用bind绑定作用域(像react一样)?

看模板解析后的结果:

(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存放

data = { nativeOn: function, on: function }

new VNode的时候,会分别处理:on里的放vm._eventsnativeOn通过addEventListener注册。

var listeners = data.on; data.on = data.nativeOn;

由于后面vnode.elm会直接取第一个子元素,所以这个操作实际是把事件透传给渲染出的第一个子元素。但是如果这个子元素不支持这个事件(比如给div添加了focus事件),那么就会无效(addEventListener不支持这个事件,静默失败)。

解决方法是另外一个“补丁”-$listeners。这里不讲(当前代码版本还未支持)。

问题:为什么普通tag不能用v-on:counter绑定自定义事件?

普通tag也是调用了addEventListener了的,但是对于DOM元素来说,DOM事件是固定类别的,所以会静默失败。

补充:initEvents的处理过程

initEvents使用了闭包处理,这里直接贴代码吧,因为代码太清楚了。

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。

-updateListeners //传入了两个函数on/off,都用闭包添加了vm实例作为作用域 -on(event, cur.invoker, capture) // cur.invoker又是一个闭包,cur处在其作用域里 -vm._events[event].push(cur.invoker)

这样,回调函数都使用闭包绑定了对应的环境变量。

补充:invokeCreateHooks做了哪些工作

invokeXXXHook(s)里定义了一系列的节点操作,这些函数会依次在节点上调用。类似的还有invokeDestroyHookinvokeInsertHook

这一步会依次执行预制的create处理函数(包含以下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

-boundFn(event) -clickHandler.call(ctx, event)

比如这里进行data的更改(set),那么就会触发使用defineProperty定义好的reactiveSetter

前面说过,一个data属性对应一个Observer,一个Observer里包含一个dep实例。dep.subs中存储了所有观察者(Watcher),属性变更,就会触发(notify)这些watcher。这里就是要走一遍这个流程。

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永远在renderwatcher之前执行(用户watcher以后再看);
3、如果在父组件watcher执行过程中,某个子组件销毁了,这个子组件不会影响进度(直接被跳过)。

如果没有排序,那么如果先更新子组件,向上到某一级,这个子组被销毁了,那么就导致状态不一致,必须返回去再更新这个子组件。

补充:nextTick的设计

nextTick用于使某个函数在下次渲染的时候,才被调用。

nextTick基本结构

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的代码初看……很简单嘛。但是细看却会一头雾水,流程也变得模模糊糊的,反思了一下,可能大部分的初始化(比如nextTickwatcher)是在初始化时内置好的,时间一长就会忘记。而且和环境本身绑定紧密(大量使用defineproperty、闭包作用域等概念),需要时不时的想一想“这里闭包里都有啥呢”这样的问题。

这么一想,React可能一开始就是奔着到处运行(比如React-native)去的吗?

Comments