
一个再说下去要挨骂的问题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 idhost/div script const host document.getElementById(host); const root host.attachShadow({ mode: open }); root.innerHTML style button { padding: 10px 20px; } /style button idinnerBtn内部按钮/button ; document.addEventListener(click, (e) { console.log(event.target:, e.target); console.log(event.composedPath():, e.composedPath()); }); /script在 Chrome 里点击那个按钮控制台输出是event.target: div idhost/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 决定哪些事件能钻出 ShadowRoot2.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的实际委托为准。事件bubblescomposed外部普通监听冒泡click / dblclicktruetrue能收到mousedown / mouseup / mousemovetruetrue能收到pointerdown / pointerup / pointermovetruetrue能收到keydown / keyuptruetrue能收到inputtruetrue能收到focusin / focusouttruetrue能收到focus / blurfalsetrue收不到除非捕获changetrue视引擎而定建议手动验证scrollfalsefalse收不到自定义事件由构造参数决定由构造参数决定看你怎么声明举个实证你在 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 算法浏览器在每个边界上重算 target3.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 一路被“换皮”组件嵌套组件是设计系统里的常态。一个内部弹层组件里面又挂了一个子组件形成两层 Shadowouter-host #shadow-root inner-host #shadow-root button iddeepBtn最深层按钮/button现在点deepBtn不同位置的监听器看到的target分别是deepBtn自己树内部的监听button#deepBtninner-host的 Shadow Root 内部监听button#deepBtnouter-host的 Shadow Root 内部监听inner-host外部document监听outer-host一层边界换一层皮层层往外扒每扒掉一层 Shadowtarget就变成那一层的宿主。只要理解了这个逐层换皮的过程嵌套再深也不会乱。值得特别提一句在捕获阶段监听器同样遵守重定向规则。你绑在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-hostdocument / 外部普通元素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 idhost/div script const host document.getElementById(host); const root host.attachShadow({ mode: open }); root.innerHTML button iddeepdeep/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())把完整链路打印出来再谈结论十有八九问题当场就现形了。