一句话总结:Radix UI 的 DropdownMenu 通过 portal 渲染到 body 层级,依赖真实 pointer 事件——普通 element.click() 无效,必须用 CDP 的 Input.dispatchMouseEvent 打开菜单,再用 JS 选择选项,最后别忘了用 Escape 关闭。


目录

  1. 问题场景
  2. 为什么 JS click 不行
  3. 正确的三步骤
  4. 下拉菜单的识别方法
  5. 完整封装
  6. 教训总结

问题场景

在某AI平台的作者后台,有一个”720P·10s”的设置按钮,点击后弹出分辨率参数选择菜单。我需要自动化选择 “9:16” 选项。

页面结构大致如下:

1
2
3
4
5
6
7
8
9
10
<button class="trigger-btn">720P·10s</button>

<!-- 菜单通过 portal 渲染到 body 下 -->
<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 事件链:

  • pointerdownpointerupmousedownmouseupclick

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
# 1. CDP 鼠标点击打开菜单
btn_x, btn_y = get_element_center(ws, '.trigger-btn')
cdp_click(ws, btn_x, btn_y)
time.sleep(1)

# 2. 选择选项
result = js(ws, """...""") # 找到并点击目标选项

# 3. 关闭菜单
press_escape(ws)

下拉菜单的识别方法

不同组件库的下拉菜单有不同的特征,以下是几种常见的识别方式:

1
2
3
4
5
6
7
8
9
10
11
// 方法1: Radix UI portal content(最可靠)
document.querySelector('[data-radix-popper-content]');

// 方法2: 标准 role 属性
document.querySelector('[role="menu"], [role="listbox"]');

// 方法3: body 下 id 含 radix 的元素
document.querySelector('body > [id*="radix"]');

// 方法4: Arco Design / Ant Design 的 dropdown
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
})

教训总结

  1. 不要只用 JS click:现代 UI 组件库(Radix UI、React Aria、MUI)依赖真实 pointer 事件,合成事件不触发状态变更
  2. CDP 鼠标点击 + JS click 混合使用:打开菜单用 CDP 鼠标,选择选项用 JS click——各取所长
  3. Escape 关闭是必要步骤:portal 菜单不会自动消失,不关闭会遮挡后续操作
  4. 先检查再操作:用 document.querySelector('[data-radix-popper-content]') 确认菜单已渲染,不要盲目等待

总结:现代前端组件库的 portal 渲染给自动化带来了新挑战。理解底层事件机制(pointer 事件 vs 合成事件),结合 CDP 的真实事件注入能力,才能可靠地操控这些组件。关键原则——触发器用真实事件,菜单项用 JS click,完事后 Escape 收尾。