接手一个第三方视频播放器组件时,我遇到过一个很典型的卡点:组件内部用的是attachShadow({ mode: 'closed' }),我的自动化脚本想读取它内部某个按钮的文本,结果控制台里执行document.querySelector('my-player').shadowRoot,直接返回了null。当时第一反应是"完了,这组件是不是没法测了",后来一步步试下来,才发现closed shadow root并不是无解的,只是大多数开发者没找对路子。
这篇文章就是把"读取closed shadow root内部内容"这件事彻底讲明白:它到底锁住了什么、哪些东西是真的拿不到、哪些东西其实随便拿,以及在不同场景下最值得用的几套方案。无论你是要写自动化测试、临时调试线上问题,还是想把组件库的封装设计得更合理,这篇都能给你一套可落地的方法。
1. 先搞懂mode参数到底锁住了什么
1.1 open和closed不是"能访问"和"不能访问"的区别
很多人对Shadow DOM的mode参数有个误解,觉得open就是"外部能访问内部",closed就是"外部完全碰不到内部"。这个理解不够准确。
先看一段最基础的示例代码,一个自定义播放器组件:
class MyPlayer extends HTMLElement { constructor() { super(); this._root = this.attachShadow({ mode: 'closed' }); this._root.innerHTML = ` <div class="player"> <video controls></video> <button class="play-btn">播放</button> <span class="status">未播放</span> </div> `; } } customElements.define('my-player', MyPlayer);页面加载后,你在控制台跑一下:
const player = document.querySelector('my-player'); console.log(player.shadowRoot); // null console.log(player._root); // 如果组件内部把root存到了this._root上,这里反而能拿到第二个结果很反直觉:shadowRoot属性返回了null,但_root这个普通属性却能把shadow root拿出来。
这说明什么?closed模式真正切断的,只是从元素到shadow root的"官方引用通道"(也就是shadowRoot这个getter),它并没有加密、也没有销毁内部结构。组件内部如果用普通属性保存了root引用,外部照样能摸到;如果不保存,那外部确实拿不到这个引用。
但要注意,无论open还是closed,ShadowRoot对象本身的API是完全一样的,它照样支持querySelector、getElementById、innerHTML、事件监听这些能力。区别只在于你怎么拿到这个对象。
1.2 closed并不是安全边界
这个结论我要放在前面说清楚:把mode设为closed不能用来做权限隔离,更不能拿来藏敏感信息。它顶多算一个"防君子不防小人"的封装提示。
为什么这么说?只要你在attachShadow被调用之前,重写一下Element.prototype.attachShadow,就能在组件毫无察觉的情况下把closed的root截获出来:
const originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function (init) { const shadowRoot = originalAttachShadow.call(this, init); if (init && init.mode === 'closed') { window.__closedRoots = window.__closedRoots || []; window.__closedRoots.push({ host: this, shadowRoot: shadowRoot }); } return shadowRoot; };这段代码不依赖任何浏览器漏洞,就是正统的JavaScript。只要在页面加载组件之前执行,之后所有组件创建的closed shadow root都会被打进window.__closedRoots这个全局注册表里。你想拦都拦不住。
了解了这层逻辑,后面所有方案都能归到三个思路上:要么提前埋伏拿引用,要么借助浏览器特权接口,要么利用事件传播路径绕过去。
1.3 读取内部内容的关键前提
要读取某个closed shadow root里的内容,本质上必须满足下面任一条:
- 手上有shadow root的对象引用,比如组件自己存了
this._root,或者被monkey patch截获了; - 能拿到shadow tree内部的某个节点,然后通过
node.getRootNode()追溯到根节点; - 借助浏览器调试协议那一类有特权的工具。
这三个前提就是贯穿全文的线索。下面每种方案,你都可以想一想它对应哪一条。
2. 调试阶段最快的路:Elements面板加$0.getRootNode()
如果你只是想自己看一眼closed shadow root里有什么,或者临时改一下内部元素的样式,根本不需要写脚本,DevTools自己就能办到,而且步骤非常短。
2.1 Chrome的Elements面板能看到closed shadow tree
很多人不知道,Chrome DevTools的Elements面板里,closed shadow root的内容是照常显示的。开发者工具是浏览器调试器,它运行在浏览器特权层,不受页面JavaScript里mode: 'closed'这个标志的限制。
你可以直接打开DevTools,在Elements面板里找到<my-player>这个自定义元素节点,展开它,会发现内部结构完整地列在下面,哪怕它是closed。你不需要在页面的JS上下文里拿到shadow root,因为DOM树视图本身已经把它渲染出来了。
2.2 用$0.getRootNode()把root变成全局变量
这里有一个很关键的操作技巧。在Elements面板里选中shadow tree内部的某个元素(比如那个"播放"按钮),然后切到Console面板,此时$0指向的就是你刚才选中的内部元素。
接下来执行:
const root = $0.getRootNode(); window.__playerRoot = root; console.log(root);你会发现这个root就是一个ShadowRoot对象,而且它是活的,不是快照。之后你想怎么查就怎么查:
window.__playerRoot.querySelector('.status').textContent; window.__playerRoot.querySelector('video').currentTime;为什么这里要用$0.getRootNode(),而不是$0.shadowRoot?因为$0现在是内部元素,不是shadow host,内部元素身上没有shadowRoot这个属性。而getRootNode()是Node.prototype上的方法,它的作用就是返回当前节点所在的根节点:普通DOM树里的人返回document,shadow tree里的人返回对应的ShadowRoot,无论它是open还是closed。
这个点很多人不知道,它也是本文最推荐的调试手法,整个过程不超过三十秒。
2.3 Firefox需要开一个隐藏设置
Firefox的Elements面板默认不展示closed shadow tree,需要在about:config里手动打开一个开关:
devtools.inspector.showAllAnonymousContent = true设置完成后重启DevTools,Firefox的审查器里就能像Chrome一样看到closed shadow DOM内容了。之后的$0.getRootNode()操作流程和Chrome完全一致。
我自己的习惯是:遇到closed组件,先开DevTools用这个办法快速确认内部结构,再决定接下来用哪种自动化方案。如果连结构都没搞清就写脚本,很容易白忙活。
2.4 只想拿HTML内容的话,试试getInnerHTML
Chrome 122开始,Element上多了一个实验性方法getInnerHTML(),它的选项里藏着一个专门对付这种场景的参数:
const html = document.querySelector('my-player') .getInnerHTML({ includeShadowRoots: true }); console.log(html);includeShadowRoots为true时,序列化出来的HTML会包含shadow tree的内容,closed的也一样。你会拿到一段带shadowrootmode="closed"标记的完整HTML字符串。
这个方法最适合"我只要看内容、不想操作DOM"的场景,比如写快照测试、导出组件结构、对比渲染结果。但要注意,它返回的是字符串,不能直接用来调用内部元素的click()方法。如果你要操作真实DOM节点,还是得用$0.getRootNode()或者后面要说的脚本方案。
3. 脚本与自动化场景:三个不用改源码的招
在自动化测试里,你是没法手动在Elements面板里点来点去的,必须用代码在页面上下文里把内容取出来。这一节列三个我实测可行的路子。
3.1 事件穿透法:用composedPath绕开引用限制
先说一个很多人忽略的事实:通过composedPath()拿到的事件路径,是可以看到closed shadow tree内部节点的。
原理是,事件的传播路径(composed path)在浏览器内部是不受mode限制的。比如用户点了播放按钮,click事件的目标就是这个按钮本身,哪怕它深埋在closed shadow tree里。外部监听这个事件时,event.composedPath()数组的第0项就是它。
所以,如果你能在页面里触发一个真实的用户事件,就能拿到内部节点,然后继续用getRootNode()逆推出shadow root:
document.addEventListener('click', (event) => { const inner = event.composedPath()[0]; if (!inner) return; const root = inner.getRootNode(); if (root instanceof ShadowRoot) { window.__lastRoot = root; // 到这里,closed shadow root已经到手了 console.log('closed root captured:', root); } });这段代码不挑open还是closed,对所有shadow root都生效。放在自动化测试里,你可以先让脚本去点击播放器(通过坐标、通过外围可见元素定位等方式),点击事件的监听器里就能顺手把root存进全局变量,后续断言随便写。
如果不想依赖真实点击,document.elementsFromPoint(x, y)在不少场景下也能直接返回shadow tree内部的元素。比如你在页面上能确定某个内部元素在视口中大概的位置,就能通过这个API拿到它,再走getRootNode()的流程。这个办法没有事件依赖,但在元素被遮挡、或者在动画中时会有一定的误判风险。
3.2 早期monkey patch:在attachShadow之前设伏
如果你的自动化脚本需要主动、批量地拿到多个closed shadow root,而不是等用户去触发事件,那最靠谱的办法还是第一节里提到的monkey patch。
在Playwright里,用page.addInitScript在页面加载前注入:
await page.addInitScript(() => { if (window.__closedRootRegistry) return; window.__closedRootRegistry = new Map(); const originalAttachShadow = Element.prototype.attachShadow; Element.prototype.attachShadow = function (init) { const shadowRoot = originalAttachShadow.call(this, init); if (init && init.mode === 'closed') { window.__closedRootRegistry.set(this, shadowRoot); } return shadowRoot; }; });Puppeteer对应的是page.evaluateOnNewDocument,写法基本一样。之后在测试代码里:
const roots = await page.evaluate(() => { const result = []; for (const [host, root] of window.__closedRootRegistry) { result.push({ tag: host.tagName, text: root.querySelector('.play-btn')?.textContent, }); } return result; });这个方案的优点是覆盖面全,页面里所有closed组件都会自动进注册表,不用等交互事件;缺点也很明显,必须赶在组件创建之前注入。如果页面已经加载完了,组件都已经初始化了,这时再注入就晚了,因为attachShadow早就执行过,没有重来一次的机会。
我在实际项目里的经验是:这种注入脚本尽量做成测试工具的一个固定步骤,放在测试框架的全局setup里,而不是等到某个用例需要时才临时加。这样既不会漏,也不会让单个用例变得臃肿。
还有一个小坑:monkey patch时一定要记得保存并调用originalAttachShadow.call(this, init),直接调用this.attachShadow(init)会导致无限递归。另外,如果页面自身也重写过attachShadow,两个版本会互相覆盖,注入逻辑需要做好幂等判断。
3.3 调试协议路线:CDP的pierce能力
如果你的测试框架可以访问Chrome DevTools Protocol(CDP),那还有一条更"硬核"的路:CDP的DOM域本身就支持穿透closed shadow root。
在Puppeteer里可以通过createCDPSession来用:
const client = await page.createCDPSession(); await client.send('DOM.enable'); const { root } = await client.send('DOM.getDocument'); const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'my-player', pierce: true, }); const { node } = await client.send('DOM.describeNode', { nodeId, depth: -1, pierce: true, }); console.log(JSON.stringify(node, null, 2));CDP是DevTools本身在用的协议,它眼中的DOM树是完整的,closed shadow root和普通节点没有区别。pierce参数就是告诉协议栈:查询和描述节点时,穿透shadow boundary找下去。
这套方案最适合那种"页面里藏得非常深、普通选择器完全搞不定、又不想改动页面代码"的自动化场景。它最大的优势是不污染页面环境、不依赖事件、也不需要在组件创建前注入;代价是你得对CDP的接口有一定熟悉度,否则排查起来比DOM操作麻烦。
对了,Selenium 4虽然提供了ShadowRoot类,但element.shadowRoot拿到的只有open模式。遇到closed组件,Selenium基本还是要通过JavaScript执行器去跑前面说的方案,这一点在选型时要提前想好。
4. 治本方案:让closed按你的意图开口
以上都是在"不能改代码"的前提下想办法。如果这个组件是你自己团队维护的,或者你有机会推动组件库改造,那与其让测试和外部调用方费劲破解,不如直接在组件设计上把门开好。
4.1 自己的组件:优先考虑开放接口而不是closed
我见过不少开发者用closed,理由都是"防止外部乱改内部结构"。但实际项目里,closed带来的问题远比它解决的问题多:样式定制困难、自动化测试受阻、线上问题排查成本高。
如果你真的担心外部随意操作内部DOM,更好的做法不是把所有通道都堵死,而是:
- 把
mode改成open,在文档里明确"内部结构属于实现细节,外部不应直接修改"; - 或者保留
closed,但组件主动暴露几个只读/受控的接口,比如:
class MyPlayer extends HTMLElement { constructor() { super(); this._root = this.attachShadow({ mode: 'closed' }); this._root.innerHTML = `...`; } getPlayerRoot() { return this._root; } getVideoElement() { return this._root.querySelector('video'); } }第二种方案相当于"门锁着,但钥匙放在门口的花盆底下"。测试脚本和外部代码可以通过约定好的方法拿到root,组件内部结构依然不会暴露太多给随意修改。
4.2 只要样式控制权,用::part()和CSS自定义属性
很多时候外部想"读取"内部,真正的需求是想改样式。对于closed shadow root,直接的CSS选择器进不去,但有两个官方认可的通道可以用。
一个是::part()。组件内部在需要暴露的元素上加上part属性:
<button class="play-btn" part="play-btn">播放</button>外部CSS就可以这么写:
my-player::part(play-btn) { background: #ff0000; }这个能力是渲染引擎层面的,和shadow root的mode无关,closed也不影响。
另一个是CSS自定义属性,它可以跨过shadow边界。组件内部凡是需要外部主题化的地方,都通过var(--player-main-color)来引用:
:host { --player-main-color: #333; } .play-btn { background: var(--player-main-color); }外部只要在my-player上设置这个变量,里面就会跟着变:
my-player { --player-main-color: #ff6600; }这两个手段搭配起来,90%的样式定制需求都能在不碰内部DOM的前提下解决,也是社区里比较推崇的组件可配置性方案。
4.3 团队规范:为"可测试性"留一条后路
如果你在维护一个组件库,非常建议在文档和评审规范里约定:除了极少数确实需要隐藏实现细节的场景,默认使用mode: 'open'。同时约定,所有可交互的关键内部元素尽量加上>