Vue 里的事件主要有两种,第一种是绑定再原生 DOM 上的事件,第二种是绑定在组件上的自定义事件。文章会详细对两者的相同点和不同点展开讲解。
基本使用
在 template 中使用 v-on 或者其语法糖 `` 可以快速的在节点上添加事件。
同时可以添加修饰符来对事件触发方式和其副作用进行快速的设定。
<div id="app"><button v-on:click="console.log('DOM Listener')">button</button><EventComponentExample @click="ListenerComponentEvent" /></div>
原理
AST
我们先查看 template 对应生成 AST 是怎么样的。
由于 AST 阶段无法判断节点是原生 DOM 还是组件,所以在这个阶段节点事件的编译是没有区分的。
[{"type": 1,"tag": "button","attrsList": [{"name": "v-on:click","value": "console.log('DOM Listener')"}],"attrsMap": {"v-on:click": "console.log('DOM Listener')"},"children": [{"type": 3,"text": "button","static": true}],"hasBindings": true,"events": {"click": {"value": "console.log('DOM Listener')","dynamic": false}}},{"type": 1,"tag": "EventComponentExample","attrsList": [{"name": "@click","value": "ListenerComponentEvent"}],"attrsMap": {"@click": "ListenerComponentEvent"},"hasBindings": true,"events": {"click": {"value": "ListenerComponentEvent","dynamic": false}}}]
我们可以发现,对于其他普通的节点来说,主要有两个 AST 属性发生了变化,分别是 hasBinding(是否是有数值或者事件绑定), events(具体绑定的事件的信息,以 key-value 的形式存储着),我们来研究一下其编译过程。
- 遇到节点头标签(
- 遇到节点闭合标签()
- parseEndTag:节点序列化
- option.end:操作节点堆栈
- closeElement:闭合节点操作
- processElement:分析节点内容
- …解析 key/ref/slots/component 等信息
- processAttrs:分析节点属性
- processElement:分析节点内容
- closeElement:闭合节点操作
// 只有 Vue 的指令或者属性才能进入if (dirRE.test(name)) {// 确定绑定了内容el.hasBindings = true;// 匹配可能存在的修饰符modifiers = parseModifiers(name.replace(dirRE, ""));// 还原真实 key 值name = name.replace(modifierRE, "");// 是否是事件绑定的指令 v-on/@if (onRE.test(name)) { // v-on// 除去指令前缀name = name.replace(onRE, '')// 判断是否为动态指令isDynamic = dynamicArgRE.test(name)// 如果是动态,去除两边的方括号,得到真实 nameif (isDynamic) {name = name.slice(1, -1)}// 在节点 events 上添加事件信息addHandler(el, name, value, modifiers, false, warn, list[i], isDynamic)}}
CodeGen
AST 后面紧接着就是生成 render 函数,直接看结果
with (this) {return _c("div",{ attrs: { id: "app" } },[_c("button",{on: {click: function ($event) {return console.log("DOM Listener");},},},[_v("button")]),_c("EventComponentExample", { on: { click: ListenerComponentEvent } }),],1);}
很明显,这里原生 DOM 事件和组件事件还没有区分,render 函数与其他 render 函数的区别也只体现在属性上,多了一个 on 属性,但是 内联处理器 却生成了一个生成的函数,让我们看看源码,Vue 是如何做到的。
genElement:生成节点代码
- genData:生成属性
- genHandlers:遍历 AST 中的 events 属性生成具体代码
- genHandler:生成具体绑定的代码
```javascript
function genHandler (handler: ASTElementHandler | Array
): string { // 如果入参为空,直接返回空函数 if (!handler) {return ‘function(){}’}
- genHandler:生成具体绑定的代码
```javascript
function genHandler (handler: ASTElementHandler | Array
- genHandlers:遍历 AST 中的 events 属性生成具体代码
// 如果是数组则递归生成对应事件集合 if (Array.isArray(handler)) { return
[${handler.map(handler => genHandler(handler)).join(',')}]}// 仅仅是函数变量名 const isMethodPath = simplePathRE.test(handler.value) // 是否是在行内定义函数 const isFunctionExpression = fnExpRE.test(handler.value) // 是否是在行内直接调用函数 const isFunctionInvocation = simplePathRE.test(handler.value.replace(fnInvokeRE, ‘’))
// 是否有修饰符 if (!handler.modifiers) { // 如果是变量名或者直接在和行内定义的函数,则直接返回 if (isMethodPath || isFunctionExpression) {
return handler.value
} // 如果是行内立即调用则封装一个函数直接返回,如果是内联处理器,则在新的而函数内直接执行 return `function($event){${
isFunctionInvocation ? `return ${handler.value}` : handler.value
}}` // inline statement } else { // 如果有修饰符,则根据具体修饰返回具体的变形代码 } } ```
- genData:生成属性
VNode
在生成 VNode 的时候,原生 DOM 事件和自定义组件事件变换发生区别,原生 DOM 的事件依旧在 VNode.data.on 上面,而自定义组件的事件,则会转移到 VNode.componentOptions.listeners 上。
我们可以看一下自定义组件的事件是怎么转移的
// _createElementfunction _createElement(context, tag, data, children, normalizationType){//...} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {// 创建组件的 VNodevnode = createComponent(Ctor, data, context, children, tag)} else {//...}// createComponentexport function createComponent (Ctor, data, context, children, tag): VNode | Array<VNode> | void {//...const listeners = data.ondata.on = data.nativeOn//...const vnode = new VNode(`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,data, undefined, undefined, undefined, context,{ Ctor, propsData, listeners, tag, children },asyncFactory)}
原生 DOM 事件绑定过程
createEle
function createElm (vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {if (isDef(vnode.elm) && isDef(ownerArray)) {// 防止引用污染问题,导致判断出错,克隆一边 vnode 进行操作vnode = ownerArray[index] = cloneVNode(vnode)}vnode.isRootInsert = !nested // for transition enter check// 如果是组件则会在 createComponent 创建成功,并结束if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {return}// 非组件的节点会走到此处// 节点属性const data = vnode.data// 节点的子节点const children = vnode.children// tag 名const tag = vnode.tagif (isDef(tag)) {// 创建真实 DOM,有命名空间的节点会特殊处理vnode.elm = vnode.ns? nodeOps.createElementNS(vnode.ns, tag): nodeOps.createElement(tag, vnode)// 设置 style scopesetScope(vnode)// 创建子节点createChildren(vnode, children, insertedVnodeQueue)// 如果节点有属性,则调用一系列创建函数,来更新节点属性if (isDef(data)) {invokeCreateHooks(vnode, insertedVnodeQueue)}// 插入父节点insert(parentElm, vnode.elm, refElm)} else if (isTrue(vnode.isComment)) {// 创建注释节点并插入} else {// 创建文本节点并插入}}
createComponent & initComponent
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {let i = vnode.dataif (isDef(i)) {// 是否需是重新激活的const isReactivated = isDef(vnode.componentInstance) && i.keepAlive// 调用 init 函数创建组件实例if (isDef(i = i.hook) && isDef(i = i.init)) {i(vnode, false /* hydrating */)}// 如果创建成功if (isDef(vnode.componentInstance)) {// 初始化节点的属性!!!initComponent(vnode, insertedVnodeQueue)// 插入真实 DOMinsert(parentElm, vnode.elm, refElm)if (isTrue(isReactivated)) {// 重新激活reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)}return true}}}function initComponent (vnode, insertedVnodeQueue) {if (isDef(vnode.data.pendingInsert)) {insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)vnode.data.pendingInsert = null}vnode.elm = vnode.componentInstance.$elif (isPatchable(vnode)) {// 这里与真实 DOM 创建的收尾流程一样,所以关键就在 invokeCreataHooks 这个函数里面invokeCreateHooks(vnode, insertedVnodeQueue)setScope(vnode)} else {// empty component root.// skip all element-related modules except for ref (#3455)registerRef(vnode)// make sure to invoke the insert hookinsertedVnodeQueue.push(vnode)}}
invokeCreateHooks & updateDOMListeners
function invokeCreateHooks (vnode, insertedVnodeQueue) {// 调用创建相关函数,其中有 "updateAttrs", "updateClass", "updateDOMListeners", "updateDOMProps", "updateStyle, ....// 其中跟事件有关的核心函数就 updateDOMListenersfor (let i = 0; i < cbs.create.length; ++i) {cbs.create[i](emptyNode, vnode)}i = vnode.data.hook // Reuse variableif (isDef(i)) {if (isDef(i.create)) i.create(emptyNode, vnode)if (isDef(i.insert)) insertedVnodeQueue.push(vnode)}}function updateDOMListeners (oldVnode: VNodeWithData, vnode: VNodeWithData) {// 如果没有新旧 VNode 都没有绑定事件则跳过if (isUndef(oldVnode.data.on) && isUndef(vnode.data.on)) {return}const on = vnode.data.on || {}const oldOn = oldVnode.data.on || {}// 真实 DOMtarget = vnode.elm// 格式化事件normalizeEvents(on)// 通过 add, remove 更新事件updateListeners(on, oldOn, add, remove, createOnceHandler, vnode.context)target = undefined}
add
function add (name: string,handler: Function,capture: boolean,passive: boolean) {// 边缘情况处理if (useMicrotaskFix) {const attachedTimestamp = currentFlushTimestampconst original = handlerhandler = original._wrapper = function (e) {if (e.target === e.currentTarget ||e.timeStamp >= attachedTimestamp ||e.timeStamp <= 0 ||e.target.ownerDocument !== document) {return original.apply(this, arguments)}}}// 在节点上添加事件target.addEventListener(name,handler,supportsPassive? { capture, passive }: capture)}
自定义组件事件绑定及触发过程
前文已经提到,所有绑定在组件上的事件会绑定到 VNode.componentOptions.listeners 上。
在初始化中,在合并选项的时候,在 initInternalComponent 里将值赋值到 options._parentsListeners 上
并在 initEvent 中调用 updateComponentListeners 使用先前在原生 DOM 事件绑定中的提到的 updateListeners ,只不过有区别的是 add 函数不再是 addEventListener 而是 $on,remove 是 $off。initRender 的时候 this.$listeners 的代理会指向 options._parentsListeners
$on
$on 负责将监听事件们 push 到 _events 上
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {const vm: Component = thisif (Array.isArray(event)) {for (let i = 0, l = event.length; i < l; i++) {vm.$on(event[i], fn)}} else {(vm._events[event] || (vm._events[event] = [])).push(fn)// optimize hook:event cost by using a boolean flag marked at registration// instead of a hash lookupif (hookRE.test(event)) {vm._hasHookEvent = true}}return vm}
$emit
$emit 则是负责从 _events 中取出对应的事件,并调用触发
Vue.prototype.$emit = function (event: string): Component {const vm: Component = thislet cbs = vm._events[event]if (cbs) {cbs = cbs.length > 1 ? toArray(cbs) : cbsconst args = toArray(arguments, 1)const info = `event handler for "${event}"`for (let i = 0, l = cbs.length; i < l; i++) {invokeWithErrorHandling(cbs[i], vm, args, vm, info)}}return vm}
总结
原生 DOM 事件和自定义组件的事件在生成 虚拟DOM 之前没有什么分别,但再生成虚拟 DOM 后,前者依旧再 VNode.data.on 上,而后者则直接到 VNode.componentOptions.listeners。前者会通过 addEventListener 添加到 DOM 上,后者会在组件初始化的时候通过中通过 $on 以键值对的形式存放到 _events 中,并可以通过 $emit 触发指定的监听事件。
