8.8 事件冒泡与更新时机问题
一次点击里既触发了更新、又在冒泡过程中继续派发事件,就可能执行到「这次事件里本该还不存在」的事件处理函数。
一、复现问题的测试
const { effect, ref } = VueReactivity
const bol = ref(false)
effect(() => {
const vnode = {
type: 'div',
// bol 为 false 时,div 上根本没有事件
props: bol.value ? {
onClick: () => { alert('父元素 clicked') }
} : {},
children: [
{
type: 'p',
props: {
onClick: () => {
alert('子元素 clicked')
bol.value = true // 点击子节点时把 bol 改成 true
}
},
children: 'text'
}
]
}
renderer.render(vnode, document.querySelector('#app'))
})
预期:首次渲染时 bol.value 为 false,渲染器不会为 div 绑定点击事件,点击 p 只应弹出「子元素 clicked」。
实际:父元素的处理函数也执行了,弹出「父元素 clicked」。
二、为什么会出现这种「奇怪」现象
把一次点击拆开看:
- 点击
p元素,绑定在它身上的click处理函数执行,bol.value被改为true; bol是响应式数据,值变化会触发副作用函数重新执行;- 此时
bol.value已是true,于是在更新阶段,渲染器为父级div绑定了click处理函数; - 更新完成之后,点击事件才从
p元素冒泡到父级div元素; div上此时已有处理函数,于是它被执行了。
要害在于:事件触发的时间早于事件处理函数被绑定的时间。也就是说,事件触发时目标元素上还没有绑定相关的事件处理函数。 本节示例中副作用函数是同步执行的,更新在事件冒泡过程中就完成了;若更新被推迟到微任务(此时事件派发早已结束)中执行,现象不会出现 —— 但框架实现不能依赖「更新一定晚于事件派发」这一前提,所以需要主动屏蔽。
三、解决方案:时间戳屏蔽「迟到」的处理函数
既然规律是「触发时间早于绑定时间」,那就屏蔽所有绑定时间晚于事件触发时间的处理函数:
invoker = el._vei[key] = (e) => {
// e.timeStamp 是事件发生的时间
// 事件发生时间早于事件处理函数绑定的时间 → 不执行
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
// 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now()
为什么可以直接比较:
performance.now()与e.timeStamp使用同一时间基准。 但不同浏览器里e.timeStamp的含义不一致 —— 它既可能是高精时间,也可能是非高精时间。只有 Chrome 49、Firefox 54、Opera 36 及之后的版本,e.timeStamp才是高精时间;更早的浏览器里参考基准不同,不能这样直接比较。