一个再说下去要挨骂的问题:Shadow DOM 的事件穿透到底怎么算穿透?
做 Web Components 组件库这几年,我踩过不少 Shadow DOM 的坑,其中最让团队大眼瞪小眼的,永远是"事件穿透"。也就是今天标题里那个词。
第一次遇到它是在给一个自研的下拉选择组件做全局点击关闭。组件内部是一个 Shadow Root,里面包着按钮、列表项、搜索框。我当时在document上挂了click监听,想判断"点击是否发生在组件内部"。结果诡异的事情来了:明明点的是组件内部那个搜索框,event.target拿到的却是组件最外层的宿主元素,也就是host。当时的第一反应是"Shadow DOM 把真实目标藏起来了",第二反应是"这日子没法过了"。
后来翻规范、扒引擎派发逻辑、写各种验证 demo,才算把这件事彻底弄明白。这篇就围绕 Shadow DOM 与事件穿透,从源码实现的角度拆开讲清楚三件事:事件凭什么能跨过 Shadow 边界、浏览器为什么要把target改写成另外一个样子、以及怎么用composedPath()把被藏起来的真实路径捞回来。适合正在写 Custom Element、封装设计系统组件、或者被事件代理坑到怀疑人生的前端开发者。
1. 一个反直觉的真实现场:点的是内部按钮,出来的却是根节点
1.1 先把这个现象复现出来
先把问题固定到可复现的最小例子上。一个宿主元素,内部挂一个 Shadow Root,里面丢一个按钮:
<div id="host"></div> <script> const host = document.getElementById('host'); const root = host.attachShadow({ mode: 'open' }); root.innerHTML = ` <style> button { padding: 10px 20px; } </style> <button id="innerBtn">内部按钮</button> `; document.addEventListener('click', (e) => { console.log('event.target:', e.target); console.log('event.composedPath():', e.composedPath()); }); </script>在 Chrome 里点击那个按钮,控制台输出是:
event.target: <div id="host"></div> event.composedPath(): [button#innerBtn, div, #document-fragment, div#host, body, html, document, Window]注意看,e.target是host,不是button。但e.composedPath()的第一个元素才是真正的button。
这个现象就是事件重定向(Event Retargeting),外面传着传着,target就被"掉包"了。
很多人管这种情况叫"事件穿透",其实描述的是同一个过程的两个侧面:事件从 Shadow Tree 内部穿透到外部树时,内部真实目标被替换成了外部视角可见的那个节点。
1.2 先对齐三个术语:Shadow Tree、边界、宿主
在进入原理之前,得把三个词统一一下:
- Shadow Tree:挂在 Shadow Root 下面的一棵独立 DOM 树,里面可以有任意节点。
- Host(宿主):挂了 Shadow Root 的那个普通元素,在上面的例子里就是
div#host。 - Tree Boundary(树边界):Shadow Root 与 Host 之间的交界处,是整个事件穿透机制真正做事的地方。
浏览器对待 Shadow Tree 的核心思想是"封装":外部脚本默认访问不到内部节点、ID 和样式互不污染、DOM API 的查询结果被隔离。但事件系统有一个特殊待遇——它被设计成"半穿透"的:交互事件要能跨过边界传出去,因为不能因为封装就把全局监听完全掐断;但传出去的同时,内部细节又不能裸露给外部代码看,于是就有了target重定向。
简单来说,Shadow DOM 做出的取舍是:你能感知组件发生了交互,但默认不告诉你交互发生在内部哪个具体节点上。
2. 边界放行规则:composed 决定哪些事件能钻出 ShadowRoot
2.1 bubbles 和 composed 是两码事
问很多同学,他们以为只要事件设置了bubbles: true,就能从 Shadow 内部透出来。这是最常见的一个误区。
bubbles控制的是事件沿祖先链"向上冒泡"的意愿,它只对同一棵树内的祖先节点有意义。而composed控制的是事件能不能跨过一个ShadowRoot边界。两个标志位是正交的:
bubbles: true, composed: true:既能冒泡,也能穿边界。这是常规交互事件的标准配置。bubbles: true, composed: false:在 Shadow 内部正常冒泡,但冒到 Shadow Root 这里就被拦下,外部树上的非捕获监听器看不到它。bubbles: false, composed: true:不冒泡,但在捕获阶段可以跨边界被外层监听器看到。focus和blur就是这个类型。
还有一个细节容易被忽略:即使bubbles: false,外部在捕获阶段仍然能收到事件。因为捕获阶段是沿着路径从上往下走的,路径经过 Host 时,Host 上的 capture 监听器一定会被触发。很多"外部收不到事件"的问题,用 capture 阶段监听就能救回来,我后面会专门讲。
2.2 常见事件的穿透能力速查
我整理了一张表,按"外部普通监听能不能轻易收到"分类。这里要注意,不同浏览器的实现虽然大体一致,但个别边缘事件可能有差异,所以这表只作为参考,实战里以composed的实际委托为准。
| 事件 | bubbles | composed | 外部普通监听(冒泡) |
|---|---|---|---|
| click / dblclick | true | true | 能收到 |
| mousedown / mouseup / mousemove | true | true | 能收到 |
| pointerdown / pointerup / pointermove | true | true | 能收到 |
| keydown / keyup | true | true | 能收到 |
| input | true | true | 能收到 |
| focusin / focusout | true | true | 能收到 |
| focus / blur | false | true | 收不到(除非捕获) |
| change | true | 视引擎而定 | 建议手动验证 |
| scroll | false | false | 收不到 |
| 自定义事件 | 由构造参数决定 | 由构造参数决定 | 看你怎么声明 |
举个实证:你在 Shadow 内部放一个input,在里面敲字,document.addEventListener('input', ...)是可以收到input事件的。但如果你在 Shadow 内部监听原生scroll,外层document想捕捉滚动时机,普通冒泡监听是没戏的。
2.3 自定义事件必须显式声明 composed
自定义事件是重灾区。组件库内部经常要做"校验失败""展开完成"这类通知,如果直接这么干:
// 错误示范 this.dispatchEvent(new CustomEvent('component:error', { detail: { message: 'xxx' }, bubbles: true, }));在普通 DOM 里,上面这段没问题。但如果你是在 Shadow Root内部通过this.dispatchEvent派发的,而this是 Shadow 内部的元素,那么事件冒泡到 Shadow Root 边界就被吞掉了,外面监听component:error的代码永远等不到消息。
正确做法是:
// 正确示范 this.dispatchEvent(new CustomEvent('component:error', { detail: { message: 'xxx' }, bubbles: true, composed: true, }));多一个composed: true,事件才能从 Shadow 内部"钻"到外部去。我们团队后来立了一条规矩:所有对外派发的自定义事件,一律{ bubbles: true, composed: true },不准有例外。宁可后面发现bubbles用不上,也不能漏掉composed。
3. Retargeting 算法:浏览器在每个边界上重算 target
3.1 为什么必须重定向而不是原样抛出
假设浏览器不做重定向,事件原封不动把button#innerBtn作为target传给外部监听器。外部代码拿到这个节点后能干嘛?
target.className、target.id、target.tagName全部泄露出内部结构;- 顺着
target.parentNode一路往上摸,再调getRootNode(),Shadow 内部结构几乎等于裸奔; - 配合
querySelector之类的手段,封装的边界形同虚设。
也就是说,事件系统如果不做重定向,Shadow DOM 的封装就塌了一半。所以规范必须对target进行"对外改写"。
Retargeting 的设计目标很简单:站在哪棵树上看事件,事件目标就显示为那个视角下离真实目标最近的可见节点。站在外部树,你只能看到 Host,那target就是 Host;站在 Shadow Root 内部的监听,你看到的就是内部真实元素。
3.2 重定向发生的时机:以监听器所在树为基准
算法上可以这么理解(这不是某家浏览器源码的逐行翻译,而是对 DOM 规范中相关处理逻辑的等价描述):
- 浏览器在派发事件前,先算出完整事件路径:从
Window一路走到document -> html -> body -> ... -> host -> shadow root -> ... -> 真实 target。 - 派发时,每碰到一个监听器,根据监听器绑定在哪棵树上的位置来决定它对
target可见性。 - 如果监听器在真实
target同一棵树(或更深的树)上,target原样呈现。 - 如果监听器在某个 ShadowRoot 之外的树上,
target会被替换成"跨过这个边界后第一个可见的节点",也就是 Host。 - 事件每穿过一层 Shadow 边界,
target就按层重定向一次。
因此同一个事件在不同的监听器里,看到的target可以是不一样的东西。这不是 bug,是刻意设计。
3.3 多层 Shadow DOM 嵌套:target 一路被“换皮”
组件嵌套组件是设计系统里的常态。一个内部弹层组件,里面又挂了一个子组件,形成两层 Shadow:
<outer-host> #shadow-root <inner-host> #shadow-root <button id="deepBtn">最深层按钮</button>现在点deepBtn,不同位置的监听器看到的target分别是:
deepBtn自己树内部的监听:button#deepBtninner-host的 Shadow Root 内部监听:button#deepBtnouter-host的 Shadow Root 内部监听:inner-host- 外部
document监听:outer-host
一层边界换一层皮,层层往外扒,每扒掉一层 Shadow,target就变成那一层的宿主。只要理解了这个"逐层换皮"的过程,嵌套再深也不会乱。
值得特别提一句:在捕获阶段,监听器同样遵守重定向规则。你绑在document上的 capture 监听,拿到的target也是外层 Host,不是内部节点。重定向不是"冒泡时才做的变形",而是"对你这个监听器可见性不变的常量"。
3.4 用一个表格总结不同位置的可见性
我把上面嵌套场景整理成表,方便以后查:
| 监听位置 | 看到的 target | 原因 |
|---|---|---|
| deepBtn 内部同树元素 | button#deepBtn | 在同一 Shadow Tree 内,无需重定向 |
| inner-host 的 shadow root 内 | button#deepBtn | 在真实目标同树内 |
| outer-host 的 shadow root 内 | inner-host | 跨过 inner-host 的边界,重定向为 inner-host |
| document / 外部普通元素 | outer-host | 再次跨过 outer-host 的边界,重定向为 outer-host |
把这张表记牢,基本就能应付绝大多数"这个事件 target 怎么是他"的排查场景了。
4. composedPath():唯一能拿到完整链路的口子
4.1 它与老式 e.path 的差异
事件重定向保护了封装,但也给业务埋了一个大麻烦:外部想判断"用户是不是点了内部某个输入框"的时候,e.target === host永远成立,根本区分不开。
这时候就要用composedPath()。
e.composedPath()返回一个数组,按"从 Window 到真实目标"的顺序排列,里面包含跨过的所有 Shadow Root:
[button#innerBtn, div, #document-fragment, div#host, body, html, document, Window]这里的#document-fragment其实是一个ShadowRoot节点,打印时显示为#document-fragment。
早年 Chrome 有非标准属性e.path,也能拿到差不多的数组,但e.path没有被写进规范,在部分引擎和跨 iframe 场景下行为不一致。composedPath()是标准方法,所有现代浏览器都实现了。不要再用e.path,我在老项目上见过因为e.path在不同浏览器输出不一致导致的线上事故。
4.2 典型用法:在外层做精确命中判断
回到开头那个全局点击关闭的场景。组件内部有搜索框和选项列表,需求是:点击组件外部任意位置关闭下拉,点击组件内部不关闭。
正确写法:
document.addEventListener('click', (e) => { const path = e.composedPath(); // 检查这个点击是否经过我们的组件宿主 if (path.includes(host)) { // 点击发生在组件内部(包括 shadow 内部),不关闭 return; } // 点击完全在组件外部,关闭下拉 closeDropdown(); });这里面有个很多人第一次看会觉得绕的点:path.includes(host)判断的是"事件路径上是否经过宿主"。因为点击 Shadow 内部的按钮,路径必然经过button -> ... -> shadow root -> host -> ...,所以host一定在 path 里。而点击组件旁边的空白,path 里就没有host。这一招比判断e.target可靠得多,也把 Shadow 内外统一成了一个逻辑。
如果还要更精细地判断"是否点中了内部某个具体按钮",可以进一步看path[0]:
const realTarget = path[0]; if (realTarget.id === 'innerBtn') { // 用户精准点击到了内部按钮 }path[0]永远是真实目标,不经历重定向。这就是前面说的"要用 composedPath 看真相"。
4.3 事件代理场景里的过滤技巧
大型应用里经常用事件代理统一处理一类组件。比如产品要给页面上所有自研x-switch组件加埋点,一个监听器搞定:
document.addEventListener('click', (e) => { const path = e.composedPath(); // 找到第一个匹配 x-switch 的节点 const switchNode = path.find((el) => el instanceof HTMLElement && el.tagName === 'X-SWITCH'); if (switchNode) { track('x-switch-click', switchNode.dataset.name); } });注意这里我用的是path.find而不是e.target.closest('x-switch')。因为如果点击发生在这个x-switch的 Shadow 内部,e.target是外层的某个宿主,closest根本找不到x-switch。而composedPath()里完整保留了从真实节点一路到 Window 的祖先链,在这条链上找父级组件是最稳的。
这个思路在跑真实项目时非常省事,相当于用一条路径同时打通了"Shadow 内部真实节点"和"外部祖先组件"两边的信息。
4.4 两个容易翻车的注意点
第一,composedPath()在事件派发过程中是实时计算的,不要在异步回调里保存它并隔帧使用。实例如下,如果你在setTimeout里再取e.composedPath(),有概率拿不到完整路径,因为事件派发已经结束。
document.addEventListener('click', (e) => { const path = e.composedPath(); // 正确做法:先存下来 setTimeout(() => { console.log(path); }, 0); });第二,手动dispatchEvent时,如果目标节点没有挂到 DOM 树上,composedPath()的行为不保证。写单测时要注意先让节点上树再派发,否则数组长度和内容都可能和你预期不一致。这类模拟交互的测试,最好直接挂在document.body下面跑。
5. 引擎视角:事件分发决策与 Shadow 边界的交会
5.1 事件路径的生成
翻过 Chromium 的派发逻辑,会发现对事件系统而言,"事件从哪来、到哪去"这件事在派发前就已经算好了。真正重要的是这条 path 的构造规则。
以点击事件为例,派发前浏览器会沿 DOM 树往上收集节点,但在遇到 Shadow Root 时并不会停,而是把 Shadow Root、Host、Host 的祖先依次都放进 path 里。这就是为什么你的composedPath()数组里能同时看到内部节点、ShadowRoot 和外部祖先。
可以把它理解成:事件系统在构造路径时,把"树"和"树"之间通过 ShadowRoot + Host 拼成了一条连续的链。这条链是后续分发、重定向、命中判断的共同基础。
5.2 捕获、命中、冒泡在边界上的差异
整个事件分发分三个阶段,Shadow 边界在三个阶段里的行为各不相同。
捕获阶段从 Window 向下走,路径上每个节点的 capture 监听器都会触发。如果你在外部树绑了 capture 监听器,你会在 Shadow 内部监听器之前收到事件,因为事件先经过host再钻进 Shadow 内部。这个顺序是"外部早、内部晚"。
命中测试阶段解决的是"坐标点对应哪个元素"的问题。Shadow 树对渲染层是开放的,因为最终页面上的像素并不是"一棵树",浏览器内部是按渲染帧来命中的。坐标落在 Shadow 内部按钮上,命中的就是那个按钮。但要小心,document.elementFromPoint这类 API 在 Open 和 Closed 模式下能拿到的结果不一样,Closed 模式下外部视角会拿到 Host 而不是真实内部元素。所以调试穿透问题时,不要混淆"命中测试的直接结果"和"事件派发后的 target",后者经历过多重处理。
冒泡阶段就回到了本文反复讲的主题:从真实目标一路往上,每碰到一个 Shadow 边界,监听器看到的目标就被换皮一次。
我用一个实验脚本验证过完整的监听顺序,核心代码长这样:
<div id="host"></div> <script> const host = document.getElementById('host'); const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `<button id="deep">deep</button>`; const log = (name) => (e) => console.log(name, 'target:', e.target.tagName, 'pathLength:', e.composedPath().length); host.addEventListener('click', log('host-capture'), true); root.addEventListener('click', log('shadow-root-capture'), true); document.addEventListener('click', log('document-capture'), true); </script>点击后输出顺序是:
document-capture target: DIV pathLength: 6 host-capture target: DIV pathLength: 6 shadow-root-capture target: BUTTON pathLength: 6注意document-capture和host-capture拿到的target是DIV,而shadow-root-capture拿到的是BUTTON。同一个事件,只因为监听器绑在不同的树上,target就不一样。这是 Retargeting 最直观的现场验证。
5.3 引擎派发逻辑的等价伪代码
从源码精神上,引擎做的事情可以等价成下面这段逻辑:
function dispatchEvent(event, path) { // 捕获阶段:从 Window 往下 for (let i = path.length - 1; i >= 0; i--) { const node = path[i]; const visibleTarget = getVisibleTarget(node, event.target); for (const listener of node.captureListeners) { listener({ ...event, target: visibleTarget }); } if (event.propagationStopped) return; } // 命中目标阶段 const targetNode = path[0]; event.target = targetNode; // 冒泡阶段:从 target 往上 for (let i = 0; i < path.length; i++) { const node = path[i]; const visibleTarget = getVisibleTarget(node, targetNode); for (const listener of node.bubbleListeners) { listener({ ...event, target: visibleTarget }); } if (event.propagationStopped) return; } } function getVisibleTarget(listenerNode, realTarget) { // 如果监听器节点和真实目标在同一棵可见树内,直接返回真实目标 // 否则向上找到最近一个 Shadow Root 的 host 作为可见目标 let current = realTarget; while (current && !isVisibleFrom(listenerNode, current)) { current = current.getRootNode()?.host || current; } return current; }真实引擎的实现比这复杂得多(要考虑 iframe、事件重定向缓存、内联监听器等),但核心决策逻辑就是这个:事件沿路径跑,监听器在哪个树层,target就投喂哪个层级的可见节点。
理解到这,再回去看"事件穿透"四个字,其实是两股力量在拔河:一股是封装,想让内部细节不被看到;一股是交互,想让事件继续向外传播。最终规范选择了"路径穿透、目标隐藏"的方案。
6. 组件库与设计系统里的事件穿透规矩(避坑手册)
6.1 focus/blur 不冒泡是个大坑
focus和blur事件设置了composed: true,但是bubbles: false。这就导致外部树如果用普通冒泡监听,完全收不到内部元素聚焦的消息。很多自定义下拉框的外部关闭逻辑,全都栽在这里。
正确做法是改用focusin/focusout,它们既冒泡又能穿透:
document.addEventListener('focusin', (e) => { if (e.composedPath().includes(host)) { // 焦点进入了组件内部 } }); document.addEventListener('focusout', (e) => { if (e.composedPath().includes(host)) { // 焦点离开了组件整体 } });尤其在做"点击外部关闭"时,强烈建议用focusout结合composedPath()代替大部分click判断。它比click更贴近真实意图,而且能避免很多点击事件的边缘情况(比如拖拽选中、文本选区点击等)。
6.2 表单类事件不能全指望原生穿透
Shadow 内部有表单控件时,外层想统一收集表单数据,别直接依赖原生事件链。我实测过一些引擎上的change、submit事件,穿透表现并不统一。靠谱的做法是组件内部显式派发自定义事件来通知外层,或者干脆由容器在 Shadow 内部接管表单逻辑,把校验结果用composed: true的自定义事件抛出来。
举个例子,一个自研输入组件在内部包装了一个原生input来做校验和格式化,当用户输入非法内容时:
const input = shadowRoot.querySelector('input'); input.addEventListener('input', (e) => { const valid = validate(e.target.value); if (!valid) { this.dispatchEvent(new CustomEvent('component:invalid', { bubbles: true, composed: true, detail: { value: e.target.value }, })); } });外层只要统一监听component:invalid就能接管所有子组件的校验提醒,完全绕开原生表单事件的兼容差异。
6.3 内部状态与外部联动的三个原则
做组件封装时,关于事件我最后沉淀下来三条原则,照着做基本不出大问题:
- 对外一律发自定义事件,并显式声明
{ bubbles: true, composed: true },不依赖内部对外暴露节点。 - 对外不做任何"返回内部节点"的 API,事件如果需要传数据,用
detail字段传纯数据或拷贝,而不是把 Shadow 内部元素直接塞给外部。 - 外部接收组件事件时,判断"是不是关于这个组件的"用
target或composedPath().includes(host),判断"内部具体是哪个元素触发的"再用composedPath()[0],两个层次别混用。
三条原则落到实处后,组件内部的自由度就很高了,未来想换内部结构、换渲染方案,外部几乎不用跟着改。
6.4 open 和 closed 模式的实际选择
最后再说一个很多人误解的点:closed 模式并没有比 open 模式"更事件安全"。
事件重定向的规则对 open 和 closed 一视同仁,该穿透的事件照样穿透,该重定向的照样重定向。mode: 'closed'锁住的只是外部通过element.shadowRoot拿根节点、继而直接操作内部树的能力,它并不会让事件系统更保守。
实际项目中我几乎不用 closed。原因是它带不来收益,却会平白增加调试成本:DevTools 里看不到内部结构、第三方脚本想辅助做无障碍或扩展时也寸步难行。如果团队担心有人乱动内部结构,与其用 closed 模式,不如靠代码评审和组件目录权限控制。我见过太多次因为 closed 模式导致线上问题无法排查的案例,后来内部规范直接要求一律 open,不准用 closed 标榜"安全"。
回头看这个知识点,Shadow DOM 的事件穿透根本不是玄学,它是浏览器在"封装"和"可交互"之间画好的一条线:路径放行、目标隐藏、细节靠composedPath()拿。把这条线记熟了,之后调试任何自研组件的事件问题,基本都能在半小时内收工。最后分享一个小小的调试习惯:碰到穿透相关的事件异常,永远先打一遍console.log(e.composedPath()),把完整链路打印出来再谈结论,十有八九问题当场就现形了。