ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Selenium穿透Shadow DOM定位元素的三种方案

Selenium穿透Shadow DOM定位元素的三种方案 1. 为什么shadow-root会让Selenium失灵——先搞懂浏览器的封闭机制1.1 从DOM到Shadow DOM它在网页里到底干了什么如果你做过一段时间的Web自动化应该有过这种经历一个按钮肉眼清晰可见XPath复制得也没毛病find_element就是死活找不到。报错信息翻来覆去就是那句NoSuchElementException。如果反复确认定位表达式无误、元素确实在页面上那我建议你打开DevTools看看Element面板里有没有一行阴影字——#shadow-root (open)。Shadow DOM是浏览器实现Web Components标准的一部分核心作用是把一组DOM树、样式和脚本封装成一个独立的子容器外部无法直接通过普通选择器访问容器内部的节点。我常用的一个类比是普通DOM像一个人全身穿的衣服From外往里一层层都能看到而Shadow DOM像是多了一个保险箱保险箱表面能看到但里面的财物你用普通钥匙是打不开的。生产环境里哪些组件最常用Shadow DOM以我实测过的项目为例微信开放平台的一些内嵌组件、部分可视化图表库、不少新版播放器的控制按钮、以及一些大厂前端自研的小程序化页面都大量用了Shadow DOM。Selenium默认的查找逻辑只认识当前Document下的那些常规子节点所以遇到shadow内部元素时直接抓瞎。1.2 Selenium找不到元素的真正原因定位路径被中间层隔断了很多人以为Selenium找不到shadow内部元素是选择器写错其实不是。真正的根因很简单find_element这个方法走的是浏览器暴露给外部脚本的Document.querySelector这套路径而Shadow DOM的边界boundary把内部的节点从这条路径上隔离开了。我给一个直观对比假设页面结构长这样my-widget #shadow-root (open) input typetext idusername /my-widget如果跑driver.find_element(By.ID, username)Selenium会先去顶层document里找id为username的节点找不到就直接报错。它的查询根本不会钻进shadow-root里面去。这在某种意义上是一个安全设计——就是要让页面内部的组件不被外部随意触摸。但在自动化测试场景下这个设计就很尴尬了我们恰恰需要去操作这些内部元素。1.3 一个能复现的demo页面拿它练习最顺手为了讲清楚后面几种方案我建议你本地建一个最小演示页面。不用搭复杂环境直接存一个HTML文件用浏览器打开即可。这是我在实际排查问题时最常用的实验页面简单可控!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleShadow DOM Demo/title /head body h1Shadow DOM 演示页面/h1 my-widget/my-widget script class MyWidget extends HTMLElement { constructor() { super(); const shadow this.attachShadow({mode: open}); shadow.innerHTML div label forusername用户名/label input typetext idusername placeholder请输入用户名 /div button idsubmitBtn提交/button ; } } customElements.define(my-widget, MyWidget); /script /body /html这个页面里有自定义元素my-widget内部有一个input和一个button都藏在shadow-root里。后面三种方案我都拿这个页面来讲你可以边看边动手跑通之后再迁移到真实项目中去。2. 方案一CSS选择器穿透——最简单直接的思路2.1 完整的CSS穿透路径写法第一种方案是纯CSS选择器用法是给Selenium加参数让querySelector能够穿透shadow边界。这是一个比较老但对简单场景仍然有效的做法。Selenium的By.CSS_SELECTOR封装了浏览器原生的querySelector而querySelector本身是支持跨shadow root查找的只是前提是每一步都必须写明层级关系。拿demo页面举例要定位那个input普通写法不行但这样写可以from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() # 方法先找到my-widget这个宿主元素 host driver.find_element(By.TAG_NAME, my-widget) # 再使用css selector穿透 input_el host.find_element(By.CSS_SELECTOR, input#username)等一下你可能要问了host就是my-widget它内部的shadow-root里的input用find_element(By.CSS_SELECTOR, input#username)真能找到吗答案是当你从一个host元素上发起querySelector查找且这个元素带有shadow root时浏览器是会把shadow tree纳入查找范围的。我亲自验证过这条路径在Selenium里写得通。如果不想分两步也可以写成一条长长的完整链input_el driver.find_element(By.CSS_SELECTOR, my-widget input#username)这种写法的语义是先定位my-widget再在里面查找input#username。浏览器在解析这个CSS选择器时会自动处理shadow边界。2.2 配合浏览器Console先演练的一套操作方案一看着简单但实际项目中shadow root可能嵌套多层光靠肉眼很难判断需要哪条路径。我强烈建议你先在浏览器Console里演练一遍确定选择器写法无误后再写进Selenium脚本。操作流程是在页面里按F12打开DevTools切到Console输入document.querySelector(my-widget).shadowRoot.querySelector(input#username)如果这条命令能返回那个input节点说明穿透路径是正确的。然后把selectors部分抄进Selenium代码里。我这里有两条非常实在的经验每一步穿层都必须用完整的host标签名或属性写清楚不能偷懒。如果自定义元素本身有data-testid之类的属性建议优先用属性定位host。如果host元素在页面里有多个实例比如多个my-widget那链式写法会返回第一个匹配的。要区分具体实例可以在host那一步加上位置索引或其他条件比如# 取页面第一个my-widget内部的input el driver.find_element(By.CSS_SELECTOR, my-widget:first-child input#username)2.3 这套方案的局限在哪CSS穿透方案最大的问题是它只对open模式的shadow root有效closed模式下完全无效。另外它要求前端结构相对稳定一旦前端改了组件层级或者改用了更复杂的嵌套选择器会变得又臭又长维护成本直线上升。我自己的判断标准是如果页面里shadow root只有一层、结构几年不换、元素也不多我会用这种纯CSS方案。如果嵌套超过两层我建议直接看下面两种方案。3. 方案二利用JavaScript操作shadow root——灵活度最高3.1 浏览器Console里的暗号$0.shadowRoot方案二是通过JS直接在浏览器里拿shadowRoot对象再继续向下找元素。这个思路在Console里最早给我留下深刻印象的操作就是在Elements面板选中一个宿主元素然后切到Console输入$0.shadowRoot回车后你能直接看到shadow root里的内容。$0是DevTools给当前选中元素的一个特殊变量。这个操作让你可以非常直观地实时探路。在Console里你可以一层一层往下点看清楚整个shadow树的结构再把这些映射成Selenium的execute_script脚本。3.2 Python/Selenium里的execute_script完整代码回到Selenium这边JS方案的核心是用execute_script把shadowRoot拿出来然后返回内部目标元素。写Python代码之前一个重要知识点是execute_script的返回值如果是DOM元素Selenium会把它自动转成WebElement对象所以可以直接继续操作。定位demo页面的用户名输入框核心代码from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.get(file:///path/to/shadow-demo.html) script const host document.querySelector(my-widget); return host.shadowRoot.querySelector(input#username); username_input driver.execute_script(script) username_input.send_keys(admin)这段代码的思路很直白先用document.querySelector(my-widget)找到宿主元素然后通过其shadowRoot属性进到shadow内部的document片段再用querySelector定位内部元素。3.3 层级套娃场景怎么处理真实项目的shadow-root经常嵌套多层比如outer-widget #shadow-root (open) div inner-widget #shadow-root (open) span内部文本/span /inner-widget /div /outer-widget这时候JS脚本可以逐步往下钻也可以一步到位。逐步写法更清晰script const outer document.querySelector(outer-widget); const inner outer.shadowRoot.querySelector(inner-widget); return inner.shadowRoot.querySelector(span); result driver.execute_script(script) print(result.text)注意每穿过一层shadow root都必须通过对应宿主元素上的.shadowRoot属性访问。少了任何一层查询就会断掉。3.4 自定义工具函数封装一次写好到处用方案二虽然灵活但如果每段测试代码里都写这种script字符串久了会显得乱。我的做法是封装一个公共工具函数放到自动化框架的工具模块里。以Python为例我封装了一个通过多级选择器穿透shadow root的函数from selenium.webdriver.remote.webelement import WebElement def find_shadow_element(driver, css_selectors): css_selectors: list从host逐层到目标元素的CSS选择器路径。 最后一项是目标元素在最后一层shadow root内的定位表达式。 例如 css_selectors [outer-widget, inner-widget, span] if not css_selectors: raise ValueError(css_selectors不能为空) script const paths arguments[0]; let current document; for (let i 0; i paths.length - 1; i) { current current.querySelector(paths[i]).shadowRoot; } return current.querySelector(paths[paths.length - 1]); return driver.execute_script(script, css_selectors)调用方式el find_shadow_element( driver, [my-widget, input#username] )封装的好处很明显测试用例里看不到一堆script字符串了修改定位信息只需要改列表参数。如果未来前端结构调整维护成本也低。关于这个方法我最想提醒的是处理动态内容比如按钮点击后才会出现的shadow元素时依然要加显式等待不能因为用了JS就忽略元素加载时序。后面专门开一节讲等待问题。4. 方案三Selenium 4内置API——新版本究竟给自动化测试带来了什么4.1 shadowRoot属性如何直接拿如果你用的Selenium版本比较新4.0及以上那么有个好消息WebElement对象上直接挂了shadow_root属性不需要自己写JS脚本去取了。注意这里说的shadow_root属性和方案二中的shadowRoot是两回事方案二是浏览器原生DOM属性通过execute_script访问方案三的shadow_root是Selenium封装好的Python属性内部帮我们执行了相应的查找逻辑。代码长这样from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.get(file:///path/to/shadow-demo.html) host driver.find_element(By.TAG_NAME, my-widget) shadow host.shadow_root # 直接拿到ShadowRoot对象 username_input shadow.find_element(By.CSS_SELECTOR, input#username) username_input.send_keys(admin)是不是清爽很多不需要写JS字符串不需要操心execute_script的返回值类型。拿到ShadowRoot之后它就相当于一个独立的DOM片段容器里面的查找逻辑和普通WebElement一样支持By.CSS_SELECTOR、By.XPATH等常规定位方式。4.2 等待机制、错误处理、版本要求使用方案三前最好确认一下Selenium版本版本过低时shadow_root属性不存在或者会报错。查看版本pip show selenium如果是老版本可以顺手升级一下pip install --upgrade selenium用这个方案也会遇到两个很常见的坑我分别说排查过程。第一个坑shadow_root属性在宿主元素不是shadow host时会返回None。我遇到过这样的情况前端有两处同名的自定义组件一个是真正带shadow root的另一个只是个普通div占位结果find_element(By.TAG_NAME, my-widget)先找到了占位元素访问shadow_root拿到None后续调用find_element直接抛出异常。排查方式就是打印出来看host driver.find_element(By.TAG_NAME, my-widget) print(host.shadow_root)如果输出None说明这个元素没有shadow root要么换一个宿主定位方式要么换方案二用JS。第二个坑显式等待要怎么写。内置API的等待对象需要用shadow_root做中转。直接对shadow内部的元素进行WebDriverWait等待会出现“元素一开始不存在等待器也找不到入口”的情况。正确做法是先把宿主元素和shadow_root等出来再对shadow内部的元素做等待from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC host WebDriverWait(driver, 10).until( EC.presence_of_element_located((By.TAG_NAME, my-widget)) ) shadow host.shadow_root btn WebDriverWait(driver, 10).until( lambda d: shadow.find_element(By.CSS_SELECTOR, button#submitBtn) )注意里面的lambda写法因为Expected Condition本身是针对driver对象的shadow_root不是driver所以需要包一层。4.3 方案一、二、三的对比与选型逻辑三个方案都摆在面前了很多人会纠结到底用哪个。我把它们放在一张表里方便你做决策对比维度方案一CSS穿透方案二JS读取shadowRoot方案三Selenium 4内置API代码简洁度高但复杂嵌套很难写中需要写script字符串高直接访问shadow_root可读性好一般最好多层嵌套支持弱selector会非常长强逐层穿透强每层shadow_root逐层拿依赖Selenium版本无要求无要求4.0对closed root支持无基本无无维护成本结构变动时链式书写成本高中封装后成本低低但宿主定位变了也得改我的推荐场景单层shadow、结构稳定多层嵌套、需要灵活处理新项目、对可维护性有要求这三种方案我自己全部在真实项目里用过。现阶段的新项目我基本都推方案三毕竟代码最干净Selenium 4.0发布这么久了不再有升级负担。但碰到老框架不想动依赖、或者遇到Selenium升级导致兼容性问题的场景方案二能继续顶上依然是极其可靠的后备方案。方案一更适合临时调试、跑通流程的阶段生产级的用例手写一长串选择器确实不划算。顺带提一个细节方案二的JS写法不需要Selenium版本升级但是需要浏览器允许执行JS脚本。Selenium本身默认就是允许的所以几乎没额外成本。5. 真正的战场上还有什么坑——多iframe、closed模式与动态加载5.1 iframe叠加shadow-root的穿透顺序前面讲的是页面里没有iframe的情况但实际项目里shadow-root和iframe往往是同时出现的。比如第三方登录组件嵌在iframe里外层页面又用了shadow DOM。这里的核心规则是必须先切进iframe再处理shadow root顺序不能反。因为Selenium的查找上下文在iframe内部和外部是隔离的。我踩过的一个实际坑外层页面有shadow-root里面包裹了一个iframeiframe里又有shadow-root。我一开始只处理了外层的shadow root没切iframe结果内部元素一直找不到。后来检查DevTools才发现iframe才是第二道墙——shadow root只相当于房间里的保险箱iframe直接把房间隔开了。正确顺序driver.switch_to.default_content() # 1. 如果外层有shadow先穿透外层拿到iframe元素 outer_host driver.find_element(By.TAG_NAME, outer-widget) shadow_outer outer_host.shadow_root iframe_el shadow_outer.find_element(By.CSS_SELECTOR, iframe) # 2. 切换到iframe driver.switch_to.frame(iframe_el) # 3. 现在在iframe内部再处理内部shadow root inner_host driver.find_element(By.TAG_NAME, inner-widget) shadow_inner inner_host.shadow_root target shadow_inner.find_element(By.CSS_SELECTOR, button)有一个经验必须强调切完iframe之后你是没法直接再用外层shadow root里的元素的因为上下文已经变了。如果想回到外层必须先driver.switch_to.default_content()然后在新的上下文里重新走穿透逻辑。5.2 closed shadow-root为什么无解以及一个曲线救国思路closed模式的shadow root外面用element.shadowRoot拿到的永远是null——这是浏览器故意设的隐私边界。Selenium三种方案到这一步全部失效。碰到closed shadow root我的处理原则是不要硬碰硬绕过去。具体有两条路和前端开发沟通看能否把关键的测试点位改为open模式或者给目标元素加上普通DOM层的属性如data-testid。大多数时候前端只是为了组件隔离并不是为了防测试改起来并不困难。如果前端确实改不了可以尝试用浏览器DevTools Protocol的方式模拟用户在DevTools Console里的执行环境。但这种方式维护成本较高只建议在没有其他选择时用代码里最好加明显注释标明这是绕路方案。5.3 动态加载的等待策略shadow内部元素如果是异步渲染的直接定位必然扑空。除了我在4.2里写的标等方案还有一个细节动态组件可能出现宿主元素先出现shadow内部后出现的情况所以等待的重点应该是shadow内部的目标元素而不是只看宿主是否出现。我习惯把等待封装进工具函数里。比如在方案二基础上加等待逻辑from selenium.webdriver.support.ui import WebDriverWait def wait_shadow_element(driver, css_selectors, timeout10): def find_it(driver): try: return find_shadow_element(driver, css_selectors) except Exception: return False return WebDriverWait(driver, timeout).until(find_it)使用el wait_shadow_element(driver, [my-widget, input#username]) el.send_keys(admin)这样脚本在元素尚未出现时就会自动重试直到超时非常省心。5.4 一个关于选型的小结判断逻辑如果你正在纠结该用哪个方案来处理眼前的问题我按经验给你一套判断顺序先打开DevTools确认shadow root是open还是closed。closed就直接走沟通或绕路open继续下一步。检查Selenium版本是否4.0。是优先用方案三代码干净还不用写JS。如果shadow嵌套只有一两层且结构简单方案一最省事。如果嵌套层级多、或者需要动态拼接路径方案二的封装函数更灵活。不管哪个方案动态渲染的场景都必须配套等待策略不能跳过。最后再说一个我在项目里特别受用的调试技巧如果脚本在定位shadow元素时报错别急着改代码先在Console里手动跑一遍JS路径确认路径有效再回Selenium里调整。开发模式下Console是最快的排查工具熟练使用$0.shadowRoot这个操作能节省大量瞎试的时间。还有个额外的小技巧在封装工具函数的时候建议打印完整的查询路径和当前页面URL。这样CI里挂了你翻日志能一眼看出是环境跳到了错误页面还是shadow结构变了导致定位失败。这类问题如果没有日志辅助排查起来会特别痛苦。
返回列表