一句话总结:Radix UI 的 DropdownMenu 通过 portal 渲染到 body 层级,依赖真实 pointer 事件——普通 element.click() 无效,必须用 CDP 的 Input.dispatchMouseEvent 打开菜单,再用 JS 选择选项,最后别忘了用 Escape 关闭。
目录
- 问题场景
- 为什么 JS click 不行
- 正确的三步骤
- 下拉菜单的识别方法
- 完整封装
- 教训总结
问题场景
在某AI平台的作者后台,有一个”720P·10s”的设置按钮,点击后弹出分辨率参数选择菜单。我需要自动化选择 “9:16” 选项。
页面结构大致如下:
1 2 3 4 5 6 7 8 9 10
| <button class="trigger-btn">720P·10s</button>
<div data-radix-popper-content> <div role="menu"> <div role="menuitem">9:16</div> <div role="menuitem">3:4</div> <div role="menuitem">1:1</div> </div> </div>
|
起初我尝试最直接的方式:
1
| js(ws, "document.querySelector('.trigger-btn').click()")
|
按钮没有反应。试了试其它常见选择器,也不行。
为什么 JS click 不行
Radix UI 使用 portal 将下拉菜单内容渲染到 document.body 下(而非在按钮的DOM树中)。它的交互机制依赖真实 pointer 事件链:
pointerdown → pointerup → mousedown → mouseup → click
而 element.click() 是合成事件,只会触发 click,不会触发前面的一串 pointer 事件。Radix UI 的内部状态机监听的是 pointer 事件,合成事件无法触发它的状态变更。
| 交互方式 |
触发 pointer 事件 |
对 Radix UI 有效? |
element.click() |
❌ |
❌ |
CDP dispatchMouseEvent |
✅ |
✅ |
| 真实用户点击 |
✅ |
✅ |
这就是问题的根源。
正确的三步骤
经过调试,我发现操作 Radix UI 下拉菜单需要三步:
步骤 1:CDP 鼠标点击打开菜单
按钮本身在正常 DOM 中,但需要用 CDP 的真实鼠标事件来点击:
1 2 3 4 5 6 7 8 9 10
| def cdp_click(ws, x, y): """CDP 真实鼠标点击""" cmd(ws, 'Input.dispatchMouseEvent', { 'type': 'mousePressed', 'x': x, 'y': y, 'button': 'left', 'clickCount': 1 }) cmd(ws, 'Input.dispatchMouseEvent', { 'type': 'mouseReleased', 'x': x, 'y': y, 'button': 'left', 'clickCount': 1 })
|
通过 getBoundingClientRect() 获取按钮坐标后调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| def get_element_center(ws, css_selector): """获取页面元素的中心坐标""" pos = js(ws, f""" (() => {{ const el = document.querySelector('{css_selector}'); if (!el) return null; const r = el.getBoundingClientRect(); return {{x: r.left + r.width / 2, y: r.top + r.height / 2}}; }})() """) return pos['x'], pos['y']
btn_x, btn_y = get_element_center(ws, '.trigger-btn') cdp_click(ws, btn_x, btn_y) time.sleep(1)
|
步骤 2:JS click 选择菜单项
有意思的是——菜单项虽然通过 portal 渲染,但它在 DOM 中已经存在且正常渲染,JS click 对它有效:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| clicked = js(ws, """ (() => { const dd = document.querySelector('[data-radix-popper-content]'); if (!dd) return 'NO_MENU'; const items = dd.querySelectorAll('[role="menuitem"]'); for (const item of items) { if (item.innerText.trim() === '9:16') { item.click(); return 'OK'; } } return 'NOT_FOUND'; })() """)
|
步骤 3:Escape 关闭菜单
关键发现:Radix UI 下拉菜单选择选项后不会自动关闭。菜单仍然显示在页面上,会遮挡后续操作。必须手动发送 Escape 关闭:
1 2 3 4 5 6 7 8
| def press_escape(ws): """发送 Escape 关闭下拉菜单""" cmd(ws, 'Input.dispatchKeyEvent', { 'type': 'rawKeyDown', 'windowsVirtualKeyCode': 27, 'key': 'Escape' }) cmd(ws, 'Input.dispatchKeyEvent', { 'type': 'keyUp', 'windowsVirtualKeyCode': 27, 'key': 'Escape' })
|
完整流程
1 2 3 4 5 6 7 8 9 10
| btn_x, btn_y = get_element_center(ws, '.trigger-btn') cdp_click(ws, btn_x, btn_y) time.sleep(1)
result = js(ws, """...""")
press_escape(ws)
|
下拉菜单的识别方法
不同组件库的下拉菜单有不同的特征,以下是几种常见的识别方式:
1 2 3 4 5 6 7 8 9 10 11
| document.querySelector('[data-radix-popper-content]');
document.querySelector('[role="menu"], [role="listbox"]');
document.querySelector('body > [id*="radix"]');
document.querySelector('.arco-dropdown, .ant-dropdown');
|
对于非 Radix UI 的组件库(如 Arco Design、Ant Design),虽然 portal 实现不同,但交互规律类似——触发器需要真实事件,菜单内容 JS click 通常有效。
完整封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| class RadixDropdownHandler: """处理 Radix UI 下拉菜单的自动化交互""" def __init__(self, ws): self.ws = ws def open_menu(self, trigger_selector): """用 CDP 鼠标点击打开菜单""" x, y = self._get_center(trigger_selector) self._cdp_click(x, y) time.sleep(1) def select_item(self, item_text): """选择菜单项""" return js(self.ws, f""" (() => {{ const dd = document.querySelector( '[data-radix-popper-content], [role="menu"]' ); if (!dd) return 'NO_MENU'; const items = dd.querySelectorAll('[role="menuitem"], [role="option"]'); for (const item of items) {{ if (item.innerText.trim() === '{item_text}') {{ item.click(); return 'OK'; }} }} return 'NOT_FOUND'; }})() """) def close_menu(self): """Escape 关闭菜单""" press_escape(self.ws) def select_and_close(self, trigger_selector, item_text): """完整的打开→选择→关闭流程""" self.open_menu(trigger_selector) result = self.select_item(item_text) self.close_menu() return result def _get_center(self, css_selector): pos = js(self.ws, f""" (() => {{ const el = document.querySelector('{css_selector}'); if (!el) return null; const r = el.getBoundingClientRect(); return {{x: r.left + r.width/2, y: r.top + r.height/2}}; }})() """) return pos['x'], pos['y'] def _cdp_click(self, x, y): cmd(self.ws, 'Input.dispatchMouseEvent', { 'type': 'mousePressed', 'x': x, 'y': y, 'button': 'left', 'clickCount': 1 }) cmd(self.ws, 'Input.dispatchMouseEvent', { 'type': 'mouseReleased', 'x': x, 'y': y, 'button': 'left', 'clickCount': 1 })
|
教训总结
- 不要只用 JS click:现代 UI 组件库(Radix UI、React Aria、MUI)依赖真实 pointer 事件,合成事件不触发状态变更
- CDP 鼠标点击 + JS click 混合使用:打开菜单用 CDP 鼠标,选择选项用 JS click——各取所长
- Escape 关闭是必要步骤:portal 菜单不会自动消失,不关闭会遮挡后续操作
- 先检查再操作:用
document.querySelector('[data-radix-popper-content]') 确认菜单已渲染,不要盲目等待
总结:现代前端组件库的 portal 渲染给自动化带来了新挑战。理解底层事件机制(pointer 事件 vs 合成事件),结合 CDP 的真实事件注入能力,才能可靠地操控这些组件。关键原则——触发器用真实事件,菜单项用 JS click,完事后 Escape 收尾。