「点击成功了,但什么都没发生」:Playwright 点击被拦截的三类原因与排错法
今天早上跑一个定时任务,脚本第一行就失败了,报错长这样:
- locator resolved to <div class="btnBox" id="task-loading">…
- <td data-seq="3" data-colid="col_3" class="tiny-grid-body__column">…</td>
from <div class="tiny-modal tiny-modal__wrapper type__alert status__ is__animat
lock__view is__mask is__visible active point-info-modal">…</div>
subtree intercepts pointer events
翻译成人话:元素在那儿、可见、可点,但有个弹窗盖在它上面,所以点不到。 这不是脚本写错了,是页面上真有一层东西挡着。这类问题在自动化里出现频率极高,而且表现形式五花八门,值得单独说清楚。
一、先读懂 Playwright 的 actionability 检查
Playwright 在执行 click() 前会做一串检查,任何一项不过就重试,超时才报错:
- 可见(visible):有非空的 bounding box,且
visibility不是 hidden。 - 稳定(stable):连续两帧的 bounding box 不变。CSS 动画、懒加载出来的位移都会卡在这。
- 可接收事件(receives events):这是最容易被忽略的一项——它在目标点做一次 hit-test,看
document.elementFromPoint(x, y)落在哪里。如果落在别的元素上,就报intercepts pointer events,并把那个元素的祖先链打印出来。
上面那段报错就是第 3 项失败。报错里那行 <div class="tiny-modal ... is__visible active point-info-modal"> 是决定性信息:一个小弹窗还开着。我的脚本先打开「积分明细」读数字,然后想去点「展开全部任务」,中间按了一下 Escape 以为弹窗关了,实际这个组件不响应 Esc。于是后面每一次点击都在和一层 is__mask 的遮罩打架。
所以看到 intercepts 报错,第一件事不是加 force,而是去读它打印出来的那个元素。 它已经告诉你是谁挡的路了。
二、三类真实原因,处理方式完全不同
1. 残留的遮罩层
最典型。弹窗组件(Element、Tiny、Ant 这些 UI 库都一样)关闭动画没走完,或者关闭逻辑压根没触发,is__mask 的 div 还在,opacity: 0 但 pointer-events 依然生效。视觉上"什么都没有",自动化视角里是"一堵墙"。
正确做法是显式关闭并确认关闭成功,而不是按 Esc 赌它响应:
page.locator(".point-info-modal .tiny-modal__close").first.click()
page.wait_for_selector(".point-info-modal.is__visible", state="detached", timeout=5000)
如果组件没有关闭按钮,就直接判断遮罩是否还在,再决定要不要刷新页面重来:
if page.evaluate("!!document.querySelector('.point-info-modal.is__visible')"):
page.reload(wait_until="domcontentloaded")
刷新是重代价,但它比"在遮罩底下瞎点二十秒然后超时"便宜得多。
2. 固定定位的头部导航
第二种我遇到的次数仅次于遮罩:position: fixed 的顶栏。Playwright 滚动到元素时会把它滚到视口中间,但有些页面(尤其是长列表 + 吸顶头部)滚动补偿算不准,目标正好落在头部下面。报错里会出现 from <div class="logined" data-mod-id="header"> 这类祖先链。
处理方式是把元素滚到一个"安全区",而不是简单 scroll_into_view_if_needed():
el.evaluate("e => e.scrollIntoView({block: 'center', behavior: 'instant'})")
page.wait_for_timeout(300) # 等吸顶头部的进场动画
bb = el.bounding_box() # 滚动之后重新取坐标
page.mouse.click(bb["x"] + bb["width"] / 2, bb["y"] + bb["height"] / 2)
注意最后两行:坐标必须在滚动之后重新取。用滚动前的 bounding_box() 去 mouse.click,点的是旧位置,表现是"点击成功了但什么都没发生"——这比报错更恶心,因为日志里一切正常。
3. 动画未稳定
stable 检查连续两帧不动。Vue/React 的进场动画通常 200~400ms,Playwright 默认会等,但用 mouse.click(x, y) 这种底层 API 时它不做 actionability 检查,动画没完你算出来的坐标就是错的。这就是为什么用 mouse.click 的脚本容易"偶发失败"——它偶发,因为动画时长依赖主线程繁忙程度。
三、force=True 和 JS .click() 的边界
这两个是网上最常见的"解法",也最容易埋雷。
force=True 只跳过 actionability 检查(包括那层遮罩的 hit-test),事件照样发到坐标点上。如果遮罩真的在,浏览器会把点击派发给遮罩,而不是你的按钮。结果是:click() 返回成功,业务逻辑没跑。你对着一份"全部成功"的日志排查半小时。
JS 的 el.click() 则是另一种性质:它绕过整条输入管线,直接触发元素上的 click 处理器,完全不看有没有东西挡着、元素在不在视口内。所以它常常能"解决问题",但它解决的是"绕过 UI",不是"模拟用户"。用它的地方要非常清楚自己在干什么:
- 适用:确认元素本身可点、只是想绕开一层莫名的遮挡,且后续有独立的成功判据(比如页面文本变了、接口返回了)。
- 不适用:需要走完整 UI 状态机的地方。我踩过一个典型坑——一个标签选择组件,我用 JS 手工加了个
options-visibleclass 让下拉"显示"出来,然后点选项,DOM 里确实出现了标签,但组件的内部状态没更新。最后点提交是静默失败:零网络请求、零报错、URL 不变,连校验红字都不出现。因为组件根本不认为你选了东西。
判据在这里救了我:真正的提交一定伴随一个 POST。我挂了 page.on("request") 收集请求,看到 submit reqs: [],才确定"点击成功"是假的。
四、一套可复用的排错顺序
踩够之后我形成了固定顺序,基本五分钟内能定位:
-
读报错里的祖先链,别读报错的第一行。挡住你的元素类名往往直接说明问题。
-
elementFromPoint自查。在目标坐标上问浏览器"这里最上层是谁",比在脑子里推测快得多:page.evaluate("""([x,y]) => { const e = document.elementFromPoint(x, y); return e ? e.className + ' | ' + (e.innerText||'').slice(0,40) : 'null'; }""", [cx, cy]) -
确认没有残留遮罩:
document.querySelectorAll('[class*=mask],[class*=modal]')里过滤offsetParent !== null的。 -
重新取坐标,任何滚动/动画之后都必须重取。
-
加独立成功判据。这是我最后补上、也最重要的一条:不要拿"click 没抛异常"当成功。用 URL 变化、某个接口的响应、或页面上一段特定文本作为判据。
第 5 条的价值在这个项目里体现得特别明显。有一次发布表单,我点了"发表",页面没跳转,我以为失败了准备重发;实际上提交已经成功,只是前端没有做路由跳转。如果拿 URL 当唯一判据,就会发出两篇一模一样的文章。而真正的信号反而是 page.evaluate 抛出的 Execution context was destroyed, most likely because of a navigation——这个报错本身就是成功的证据,因为它说明页面正在导航。
结语
自动化脚本的失败大多不是"代码错了",而是你对页面的假设错了:以为弹窗关了、以为坐标还准、以为点了就等于生效了。Playwright 的 actionability 日志其实把大部分假设都替你验证了一遍,问题在于我们习惯第一时间去搜"怎么跳过这个检查",而不是读它到底在说什么。
慢一点读日志,通常会快很多。
- 点赞
- 收藏
- 关注作者
评论(0)