事件处理
约 2044 字大约 7 分钟
2026-05-15
事件处理
事件属性的识别
事件本质上还是一种
props,只不过它不像id、class这类普通属性那样直接设置到元素上,而是需要通过addEventListener来进行绑定识别一个属性是否是事件,只需要判断:凡是以
on开头的属性,就认为它是事件const vnode = { type: 'button', props: { onClick: () => { alert('click'); }, }, children: 'click me', };实现思路
通过正则表达式
/^on/.test(key)就可以很方便地识别出事件属性了const onRE = /^on[^a-z]/ const isOn = (key) => onRE.test(key) function patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); // 这个 nextValue 就是事件对应的 callback el.addEventListener(eventName, nextValue); } // ... }事件更新
与
patch不同type的vnode类似,更新事件也是先需要将旧事件移除,再把新事件绑定上去function patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); // 先移除旧事件,再绑定新事件 prevValue && el.removeEventListener(eventName, prevValue); el.addEventListener(eventName, nextValue); } // ... }但在实际的应用中,事件更新的频率可能会非常高,如果每次都要进行一次
removeEventListener和addEventListener的操作,那么性能就会受到很大的影响,因此需要对事件更新进行优化。所以 Vue 采用了一种基于
invoker的事件更新优化。把真正绑定到 DOM 上的函数理解成一层 "壳函数",DOM 永远绑定这个壳,而真正需要执行的事件函数则挂在invoker.value上这样一来,事件更新时就不需要重新绑定 DOM 事件了,只需要修改
invoker.value即可function patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); // 创建一个壳函数 // vei 是 vue event invoker 的缩写 let invoker = el._vei; if (nextValue) { // 首次绑定事件时,创建一个固定的 invoker 壳函数并缓存到 el._vei 上 // 等到下次更新时直接取出来进行使用 if (!invoker) { // 此时将 invoker 设置为一个包装函数 // 当事件触发时,真正执行的不是 invoker 本身的逻辑, // 而是执行的 invoker.value 指向的事件处理函数 invoker = el._vei = (e) => { invoker.value(e); }; // 赋予真实的事件处理函数 invoker.value = nextValue; // 绑定到 DOM 上的是 invoker 壳函数,而不是 nextValue 这个事件函数 el.addEventListener(eventName, invoker); } else { // 如果已经有 invoker 了,则直接修改 invoker.value 的值即可 invoker.value = nextValue; } } // 新值不存在,则说明是卸载事件了,此时直接把 invoker 从 DOM 上移除,并把 el._vei 置空 else if (invoker) { el.removeEventListener(eventName, invoker); el._vei = null; } } else if (key === 'class') { // 省略...... } else if (shouldSetAsProps(el, key, nextValue)) { // 省略...... } else { // 省略...... } }元素节点的更新
在
patch函数的实现逻辑中,现在只实现了元素节点的挂载,还没有实现元素节点的更新。也就是当更新的vnode的type是同一个时,应该触发的更新逻辑在这种情况下,不需要卸载旧节点再重新挂载,而是应该复用原有的 DOM 元素,并在此基础上更新它的属性和子节点。这个过程就是
patchElement要负责的事情实现思路
- 先把新旧
vnode中的 DOM 元素复用起来,也就是把旧vnode的el赋值给新vnode的el - 更新这个节点的属性和子节点
function patchElement(oldVNode, newVNode) { // 复用真实 DOM const el = newVNode.el = oldVNode.el; // 获取新旧 vnode 中的 props,后续更新时需要对比它们的差异 const oldProps = oldVNode.props; const newProps = newVNode.props; for (const key in newProps) { // 如果新的虚拟节点和旧的虚拟节点中属性不一样,进行替换 if (newProps[key] !== oldProps[key]) { patchProps(el, key, oldProps[key], newProps[key]); } } for (const key in oldProps) { // 如果旧节点中的属性,新节点中没有,将属性值设置为 null if (!(key in newProps)) { patchProps(el, key, oldProps[key], null); } } }在
patch中,调用patchElement来完成更新操作function patch(oldVNode, newVNode, container) { if (typeof type === 'string') { if (!oldVNode) { mountElement(newVNode, container); } else { patchElement(oldVNode, newVNode); } } }- 先把新旧
单元素绑定多个事件
把
el._vei设计成一个函数来做事件的缓存,虽然能够实现事件更新的优化,但它也存在一个问题:一个元素可能会绑定多个事件,如果el._vei是一个函数,那么后一次事件绑定就会把前一次覆盖掉了实现思路
把
el._vei设计成一个对象,用事件名作为 key 进行缓存,这样每个事件都会有自己独立的invoker,互不影响patchProps.tsfunction patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); const invokers = el._vei || (el._vei = {}); let invoker = invokers[key]; if (nextValue) { if (!invoker) { // 用当前的 key 来作为缓存的标识 invoker = el._vei[key] = (e) => { invoker.value(e); }; invoker.value = nextValue; el.addEventListener(eventName, invoker); } else { invoker.value = nextValue; } } else if (invoker) { el.removeEventListener(eventName, invoker); invokers[key] = null; } } else if (key === 'class') { // 省略...... } else if (shouldSetAsProps(el, key, nextValue)) { // 省略...... } else { // 省略...... } }vnode.tsconst vnode1 = { type: 'button', props: { onClick: () => { alert('click1'); }, onMouseover: () => { alert('mouseover1'); }, }, children: 'click me', };单事件对应多个处理函数
除了 "一个元素对应多个事件" 外,还有一种情况是 "同一个事件对应多个处理函数"。例如一个按钮既有
onClick1事件,又有onClick2事件,这时候它们的处理函数就需要分别进行绑定实现思路
这个时候
invoker.value就不再一定是一个函数了,而有可能是一个数组,如果是一个数组的话,那么就遍历数组,一次执行即可patchProps.tsfunction patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); const invokers = el._vei || (el._vei = {}); let invoker = invokers[key]; if (nextValue) { if (!invoker) { invoker = el._vei[key] = (e) => { if (Array.isArray(invoker.value)) { invoker.value.forEach(fn => fn(e)); } else { invoker.value(e); } }; invoker.value = nextValue; el.addEventListener(eventName, invoker); } else { invoker.value = nextValue; } } else if (invoker) { el.removeEventListener(eventName, invoker); invokers[key] = null; } } }vnode.tsconst vnode1 = { type: 'button', props: { onClick: [ () => { alert('click1'); }, () => { alert('click2'); }, ], onMouseover: () => { alert('mouseover1'); }, }, children: 'click me', };事件冒泡与更新
还有一个更隐蔽的问题,出现在 "事件触发" 和 "响应式更新" 交错的时候
这是一个非常普通的冒泡场景,点击
p元素时,先触发子元素事件,再冒泡到父元素,没有任何问题const vnode = { type: 'div', props: { onClick: () => { alert('父元素 div click'); }, }, children: [ { type: 'p', props: { onClick: () => { alert('子元素 p click'); }, }, children: 'text', }, ], };但如果把他放进响应式更新中,就会出现问题
const { effect, ref } = VueReactivity; const flag = ref(false); effect(() => { const vnode = { type: 'div', props: flag.value ? { onClick: () => { alert('父元素 div click'); }, } : {}, children: [ { type: 'p', props: { onClick: () => { flag.value = true; alert('子元素 p click'); }, }, children: 'text', }, ], }; const renderer = createRenderer(options); renderer.render(vnode, document.getElementById('app')); });这里父元素一开始其实没有点击事件。只有当点击
p元素时,才会把flag.value改成true,从而触发副作用函数重新执行,并为父元素div绑定点击事件表面看上去,父元素是在 "点击之后" 才绑定事件的,因此直觉上它不应该接到这一次冒泡。但实际如果不做额外处理,这次冒泡还是会命中新绑定的父元素事件
原因在于:父元素事件虽然是点击之后才绑定的,但它的绑定时机仍然落在同一次事件冒泡结束之前

事件冒泡与更新时间 解决思路
vnode 新增
attached属性,用来记录事件处理函数的绑定时间,如果事件触发时间早于绑定时间,那么就不执行这个处理函数function patchProps(el, key, prevValue, nextValue) { if (isOn(key)) { const eventName = key.slice(2).toLowerCase(); const invokers = el._vei || (el._vei = {}); let invoker = invokers[key]; if (nextValue) { if (!invoker) { invoker = el._vei[key] = (e) => { if (e.timeStamp < invoker.attached) { return; } if (Array.isArray(invoker.value)) { invoker.value.forEach(fn => fn(e)); } else { invoker.value(e); } }; invoker.value = nextValue; // 记录事件处理函数的绑定时间 invoker.attached = performance.now(); el.addEventListener(eventName, invoker); } else { invoker.value = nextValue; } } else if (invoker) { el.removeEventListener(eventName, invoker); invokers[key] = null; } } else if (key === 'class') { // 省略...... } else if (shouldSetAsProps(el, key, nextValue)) { // 省略...... } else { // 省略...... } }注
performance.now()是浏览器提供的原生 Web API,用来获取一个高精度时间戳。它返回的是从当前页面上下文开始计时后的毫秒数,精度通常比Date.now()更高e.timeStamp也是原生事件对象上的属性,表示事件触发的时间戳
