本文目录
你在商场入口刷卡,门口的保安先听见,闸机确认身份,最后楼层店员才收到“顾客已经进场”的消息。离场时,消息又可能沿相反方向传回去。网页里的一个点击也有类似路径:事件从外层走向目标,再从目标走回外层。
但类比只能帮你建立方向感。浏览器不是把事件当作一封信简单投递一次;它会按照 DOM 树决定传播路径,允许监听器阻止默认动作、阻止继续传播,甚至在同一个节点上改变后续处理。
先猜输出:点击按钮到底先通知谁
先不要运行,猜下面的日志顺序:
<div id="outer">
<button id="save">保存</button>
</div>
<script>
outer.addEventListener('click', () => console.log('外层冒泡'))
outer.addEventListener('click', () => console.log('外层捕获'), true)
save.addEventListener('click', () => console.log('按钮目标'))
</script>点击按钮后通常是“外层捕获、按钮目标、外层冒泡”。同一个 click 事件不是只在按钮节点执行一次,而是沿着从 document 到按钮的路径先下行,再上行。第三个参数为 true 的监听器参加捕获阶段;默认值 false 参加冒泡阶段。
三个阶段不是三个队列
同一个事件沿着同一条祖先路径往返,阶段只是到达监听器的顺序。
DOM 事件传播通常可以拆成三段:capture phase(捕获阶段)从祖先往目标走,target phase(目标阶段)在实际点击的元素上处理,bubble phase(冒泡阶段)再从目标往祖先走。
for (const phase of ['capture', 'target', 'bubble']) {
console.log(phase)
}这段代码当然没有真的触发事件,它只是提醒我们不要把三个阶段想成三个独立的异步队列。它们发生在同一次事件分发过程中,监听器按传播路径和注册规则获得执行机会。一个监听器里做很重的同步工作,后面的监听器和页面响应仍要等它结束。
事件对象里的 eventPhase 能告诉你当前处在哪个阶段,但真实排查更常用的是同时打印 target 和 currentTarget:
outer.addEventListener('click', (event) => {
console.log(event.target.id, event.currentTarget.id)
})如果点击的是按钮,target 是真正被点击的按钮;currentTarget 是当前这条监听器挂在哪个节点上。外层监听器可能看到 target === save,但 currentTarget === outer。把它们混为一谈,就会误以为“事件怎么跑到外层了”。
冒泡不是多余的绕路
冒泡让事件委托成为可能。假设列表里有一百个删除按钮:
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-delete]')
if (!button || !list.contains(button)) return
removeItem(button.dataset.delete)
})监听器只注册在列表容器上,按钮点击冒泡上来后,再通过 target 找到真正的操作对象。以后动态插入的新按钮也自动适用,不必每次插入都重新绑定监听器。这就是事件委托:把多个相同操作的监听集中到稳定的祖先节点。
委托不是“祖先节点永远能处理所有点击”。要检查 closest 找到的元素是否真的属于当前容器;嵌套列表、Shadow DOM 和按钮内部图标都可能改变匹配结果。event.target 也可能是 svg 或 span,所以只比较 target.tagName === 'BUTTON' 往往不稳。
preventDefault 不等于 stopPropagation
点击链接时,浏览器默认会导航;提交表单时,默认可能会发起提交。preventDefault() 只取消这个默认动作:
form.addEventListener('submit', (event) => {
event.preventDefault()
validateAndSave()
})它不阻止事件继续传到祖先。若你在表单里阻止默认提交,外层的点击或提交监听器仍可能收到事件。stopPropagation() 则阻止事件继续走向其他节点,但不取消链接导航、表单提交等默认行为。
还有一个更容易混淆的 API:stopImmediatePropagation()。同一节点上已经注册的后续监听器可能仍想执行;调用它会连同这些同节点监听器一起停止。它的影响更强,通常不应该作为组件之间互相“抢事件”的常规方案。
监听器选项其实是在写生命周期
现代 addEventListener 的第三个参数可以写成对象:
const controller = new AbortController()
window.addEventListener('resize', updateLayout, {
passive: true,
signal: controller.signal,
})
button.addEventListener('click', saveOnce, { once: true })once 在第一次调用后自动移除监听器;passive 向浏览器声明监听器不会调用 preventDefault,浏览器在滚动场景中更容易提前安排处理;signal 把监听器和 AbortController 的生命周期绑在一起,组件卸载时可以统一 controller.abort()。
passive: true 不是性能装饰。如果在 passive 监听器里调用 preventDefault(),浏览器会忽略或警告,因为你已经承诺不取消默认行为。参数应该反映真实意图,而不是看到别人加了就复制。
动态内容为什么适合委托
const stop = new AbortController()
list.addEventListener('click', onListClick, { signal: stop.signal })
function disposeList() {
stop.abort()
}列表内容会不断刷新时,容器通常比子节点活得久。监听容器可以减少绑定次数,signal 又能在页面离开时清理。Vue 中也可以在 onMounted 里绑定、在 onUnmounted 里移除;浏览器原生的 signal 是另一种明确表达“这组监听器到此结束”的方式。
不要把事件委托当成性能万能药。低频、少量、结构稳定的按钮直接绑定更容易读;委托树太高会让选择器判断复杂,还可能让不相关的点击都经过同一个处理器。能说清容器边界和清理责任,比少写几行 addEventListener 更重要。
Shadow DOM 的边界
Web Component 可以拥有自己的 Shadow DOM。事件从内部节点传播到宿主时,event.target 在外部观察者那里可能被重定向为宿主元素,这叫事件重定向。某些事件不穿过 Shadow DOM 边界,某些事件的 composed 属性决定能否跨边界。
这解释了一个常见现象:你在组件外面监听到了点击,却拿不到内部真实按钮;这不是冒泡失效,而是封装边界改变了外部可见路径。组件若需要暴露语义,应发出稳定的自定义事件或公开方法,不要要求外部依赖内部 DOM 结构。
容易踩的坑
点击按钮时,祖先节点也能收到冒泡事件——除非传播被停住,或事件本身不冒泡。target 是事件起点,currentTarget 才是当前监听器所属节点;事件委托正是利用两者不同。stopPropagation 只管传播路径,挡不住导航或表单提交,那些要靠 preventDefault。委托也不是一定更快:监听器少了,判断逻辑却多了,规模和生命周期都要实测。