Shadow DOM 里的元素定位:Playwright 哪些选择器能穿透
现象长什么样
页面用了自定义元素,page.locator('#inner-details') 一直点得好好的。某天有人做重构,把它换成一条从根节点数下来的路径写法,用例立刻超时,报「找不到元素」。打开浏览器看,元素明明在那儿。
或者是反过来的版本:一个自定义元素在页面上看得见、也点得动,但 getByRole 就是匹配不到它,改用 CSS 又能找到。
这两种都可能是 shadow root 边界上的问题,但成因完全不同,处置也不同。先别急着改选择器,走一遍判定。
第一步:确认元素到底在不在 open shadow root 里
三个可执行的动作,从便宜的开始。
动作一:同一个目标写两遍,一遍 CSS 一遍 XPath,比数量。 如果 CSS 那条数得到、XPath 那条数不到,基本可以定到这条线上。计数用断言最省事。docs/src/locators.md 里 Python 同步绑定的计数断言原样是 expect(page.get_by_role("listitem")).to_have_count(3),css= / xpath= 这两个显式前缀写在 docs/src/other-locators.md 里;把两处拼起来就是下面这对(这是按仓库写法组合出来的示例,不是仓库里的原句):
expect(page.locator("css=#inner-details")).to_have_count(1)
expect(page.locator("xpath=//*[@id='inner-details']")).to_have_count(1)
动作二:打一份 aria snapshot 看目标节点在不在树里。 docs/src/api/class-locator.md 记录 Locator.ariaSnapshot 自 v1.49 起可用,Python 侧的调用写法文档里逐字给的是 page.get_by_role("link").aria_snapshot()。这个动作之所以能当判据,是因为 packages/injected/src/ariaSnapshot.ts 在遍历子节点时确实会走进 element.shadowRoot,把里面的子节点一并 visit。open 的 shadow 内容会出现在快照里。
动作三:把 Inspector 打开,直接看 Playwright 眼里的匹配结果。 JS 侧 docs/src/debug.md 给的命令是:
npx playwright test example.spec.ts:10 --debug
Python / Java / C# 侧走 PWDEBUG 环境变量,文档写明设了 PWDEBUG=1 之后浏览器会以 headed 方式启动、默认超时被设为 0。Linux/macOS 的 bash:
PWDEBUG=1 pytest -s
Windows 的 PowerShell 不能把赋值写在命令前面,文档单独给了一个 tab:
$env:PWDEBUG=1
pytest -s
仓库写明的穿透规则
docs/src/locators.md 的 Locate in Shadow DOM 一节,第一句就是:所有 locator 默认都对 Shadow DOM 里的元素生效。紧接着列了例外,只有两条——XPath 定位不穿透 shadow root;closed 模式的 shadow root 不支持。docs/src/other-locators.md 的 CSS locator 一节也重复了同一件事:CSS 选择器会穿透 open shadow DOM。
再往下一层,packages/injected/src/injectedScript.ts 里注册引擎的那段代码把这件事写得更直白:
| 引擎名 | 注册时的构造 | 是否穿透 |
|---|---|---|
css | _createCSSEngine() | 内部写死 pierceShadow: true |
text / internal:text | _createTextEngine(true, ...) | 第一个参数 shadow 传的 true |
internal:testid | _createTestIdEngine() | 内部写死 pierceShadow: true |
id / data-testid / data-test-id / data-test | _createAttributeEngine(attr, true) | 第二个参数 shadow 传的 true |
xpath | XPathEngine | 引擎里没有这个概念 |
真正干活的是 packages/injected/src/selectorEvaluator.ts 里的 _queryCSS:先在当前 root 上 querySelectorAll,然后一句 if (!context.pierceShadow) return;,否则对 root 自身的 shadowRoot、以及 root.querySelectorAll('*') 里每个带 shadowRoot 的元素递归下去。递归的唯一入口就是 element.shadowRoot 这个属性——把它和文档里那句「closed 模式不支持」放在一起看,两处是自洽的。
XPath 那边,packages/injected/src/xpathSelectorEngine.ts 整个文件只有一个 queryAll,核心是 document.evaluate(selector, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE) 加一个迭代器循环,没有任何一行碰 shadowRoot。other-locators.md 里那句「XPath locators are equivalent to calling Document.evaluate」和这段代码对得上。
还有一条最容易踩的转换规则,同样写在 other-locators.md:任何以 // 或 .. 开头的选择器字符串都会被当成 xpath 处理,文档举的例子是 '//html/body' 会被转成 'xpath=//html/body'。也就是说你没写 xpath= 前缀、只是随手写了一条路径,走的照样是不穿透的那个引擎。开头那个「重构之后就失效」的现象,多半就来自这里。
getByRole 在 shadow 边界上的两种表现
第一种是穿。 packages/injected/src/roleSelectorEngine.ts 自己写了一遍遍历:先把 root 的 shadowRoot 收进一个 shadows 数组,再遍历 root.querySelectorAll('*'),把每个带 shadowRoot 的元素也收进去,最后 shadows.forEach(query) 递归。所以 role 定位跟 CSS 一样进 open shadow root。
第二种反直觉:light DOM 里没被分配到 slot 的子元素,会被当成 hidden 跳过。 packages/injected/src/roleUtils.ts 的 belongsToDisplayNoneOrAriaHiddenOrNonSlotted 里有这么一段(节选自仓库,注释是原文):
// When parent has a shadow root, all light dom children must be assigned to a slot,
// otherwise they are not rendered and considered hidden for aria.
if (element.parentElement && element.parentElement.shadowRoot && !element.assignedSlot)
hidden = true;
而 docs/src/api/params.md 里 includeHidden 这个选项的说明写明:默认只匹配非 hidden 的元素,hidden 的定义按 ARIA 来。两处合起来就是:宿主元素挂了 shadow root、你的元素又没落进任何 <slot>,getByRole 默认看不见它,而同一个元素用 CSS 仍然找得到。这时候不是「穿不透」,是「被判成了 hidden」。
includeHidden 是 API 文档里的规范名,各语言绑定的实际参数拼写以对应绑定的文档为准——仓库的 params.md 只给了这一个名字。
处置
目标在 open shadow root 里:什么都不用做,按最终渲染结构直接写。 locators.md 用的例子是:
<x-details role=button aria-expanded=true aria-controls=inner-details>
<div>Title</div>
#shadow-root
<div id=inner-details>Details</div>
</x-details>
点 shadow root 里那个 <div>Details</div>,文档给的写法就是当 shadow root 不存在一样写:
page.get_by_text("Details").click()
反过来要点宿主元素,文档给的是带 has_text 的写法:
page.locator("x-details", has_text="Details").click()
这条能成立,是因为文本匹配也把 shadow 里的文本算进去了——packages/injected/src/selectorUtils.ts 的 elementText 在累加完子节点之后,还有一句把 (root as Element).shadowRoot 的文本追加进 value.full。
原来用 XPath 的地方改掉。 最常见的是拿 xpath=.. 取父元素。other-locators.md 对这个写法本身就有保留意见,说它不够可靠、DOM 结构一变就断,并给了替代写法:
child = page.get_by_text("Hello")
parent = page.get_by_role("listitem").filter(has=child)
closed 模式:没有绕法。 locators.md 只写了「不支持」,仓库里我们没有找到针对 closed 模式的替代方案说明。别把时间花在换写法上。
被判 hidden 的那种: 要么让组件把内容真的放进 <slot>(这是页面侧的事),要么按 params.md 的说明用 includeHidden 放开,要么换成不看 aria 可见性的定位方式,比如 CSS 或 test id。
如果你注册过自定义 selector engine,穿不穿是你自己的事。 docs/src/extensibility.md 讲自定义引擎时给的示例实现是这样的:
const createTagNameEngine = () => ({
// Returns the first element matching given selector in the root's subtree.
query(root, selector) {
return root.querySelector(selector);
},
// Returns all elements matching given selector in the root's subtree.
queryAll(root, selector) {
return Array.from(root.querySelectorAll(selector));
}
});
这段是仓库里的示例代码,原样抄下来的。注意它只调了一次 root.querySelectorAll,没有像内置引擎那样对 shadowRoot 递归——也就是说自定义引擎默认不会有内置引擎那种穿透行为,除非你自己写。同一份文档还写明:引擎必须在创建 page 之前注册;默认引擎跑在页面自己的 JS 上下文里,加 {contentScript: true} 可以把它隔离到 content script、免受页面篡改全局对象的影响,内置引擎全部以 content script 方式运行,但文档也注明了与其它自定义引擎一起使用时不保证这一点。
处置后怎么验证
先用前面那条计数断言确认一次,看匹配到的是一个元素而不是一堆,把角色和数量换成你自己的。文本断言可以用 locators.md 给的宿主级写法 expect(page.locator("x-details")).to_contain_text("Details"),它能顺带验证 shadow 里的文本确实被算进来了。最后把 aria snapshot 再打一次,看目标节点是否已经出现在树里。
不要用「点得动就算过」当验收标准——匹配到多个元素时报的是 strict 违反,那是另一类失败。
以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。
什么情况说明不是这个原因
元素其实在 iframe 里。 shadow 和 iframe 是两回事:前者默认穿,后者不穿。跨 iframe 找元素要用 Page.frameLocator;docs/src/api/class-page.md 另记录了 Page.pierceFrames 自 v1.63 起可用,它返回一个会同时在主 frame 和所有 iframe 里查找的 FrameLocator,文档同时写明所有匹配到的元素必须属于同一个 frame,否则会抛错。context 级别还有一个 pierceFrames 选项,params.md 写明默认是 false(这是仓库当前文档里的默认值,随版本可能变动)。
报的是 strict 违反而不是超时。 那说明选择器匹配到了多个元素。穿透本身会把 shadow 里的同类元素也算进结果,问题在于怎么收窄,不在于穿不穿——other-locators.md 的 :visible 一节就是这个场景。
用了布局伪类。 :right-of()、:near() 这一组在 other-locators.md 里已经标了 deprecated,文档写明它们将来可能被移除,并且布局差一个像素就可能匹配到别的元素。定位不稳先怀疑这个。
:has-text() 单独用了。 文档明说它要和别的 CSS 限定一起用,否则会匹配到包括 <body> 在内的一大片元素,这时候的报错也不是「找不到」而是匹配太多。
元素压根还没渲染出来。 判据是:换成 CSS 写法一样查不到,而且 aria snapshot 里连宿主元素都没有。这跟 shadow 边界无关。
这块规则本身不复杂,难受的地方在于「默认就穿」这件事不显眼——你在代码里看不到任何穿透语法,所以出问题时也想不到往这里查。记两条就够用:XPath 是唯一的例外,closed 是唯一的死路。该项目持续更新,文中涉及的文件路径与接口写法以仓库最新内容为准。
本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测,
因此不涉及运行速度、稳定性与实际表现的任何描述。
该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。
本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。