批量点击为什么把页面卡死:一次 60 秒执行超时的排查
一段只有十几行的脚本,点了六次菜单,两次执行都以超时告终。更奇怪的是,把同样的循环拆开、一次点一个,每个都点得动。这篇记录这个反差背后的原因,以及 SPA 里做批量操作时能直接用的一套写法。
现象
目标页面是一个中后台应用,左侧有六个一级菜单。脚本这样写:
for (const name of names) {
const el = [...document.querySelectorAll("button")]
.find(e => e.textContent.trim() === name);
el.click();
await sleep(1200);
}
六次点击,加上等待,理论上不到 10 秒。实际执行器报的是 60 秒超时,page URL 没变,readyState 已经是 complete。
先排除的两种猜测
第一种猜测是元素选不中。把循环改成只点第一个,立刻返回,说明选择器和点击都没问题。
第二种猜测是等待时间太短。把 1200 毫秒加到 3000 毫秒,超时照旧。这就说明瓶颈不在等待时长。
真正的原因:主线程被重渲染占住
每次菜单点击,触发的是一整条链路:路由切换、页面组件卸载与重建、列表接口请求、图表初始化。这套动作全部跑在主线程上。循环体里的 sleep 只是让出执行权,并不保证这些渲染任务跑完;等下一次迭代回到脚本时,上一轮的渲染还在队列里排着。
六轮叠加下来,渲染队列越堆越长,脚本和渲染争抢同一个线程。执行器的超时计时按墙上时间算,页面实际已卡到无法完成一次完整的任务轮转,于是报超时。
改法:小批次、每批重查 DOM
把六次点击拆成一次一个,或者最多两个一批,每批结束重新查询一次节点再继续。关键不是次数,而是让「查询节点」这个动作发生在渲染完成之后:
async function clickMenu(name) {
const el = [...document.querySelectorAll("button")]
.find(e => e.textContent.trim() === name);
if (!el) return "missing";
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await sleep(1200);
return document.body.innerText.slice(150, 400);
}
一次调用只处理一个菜单,返回后主线程有机会把上一轮的渲染跑完,下一次查询拿到的是新渲染出来的节点,而不是旧的引用。
怎么确认点到了
点击成功不等于页面切换成功。最省事的确认方式是读一段页面正文,比对是否出现在目标页面的特征文案:
const snapshot = document.body.innerText.slice(150, 400);
比如点「教学总览」之后,这段文字里应该出现「生成教学诊断报告」;点「作业中心」应出现「作业模板列表」。文案对得上,说明路由和渲染都完成了,可以继续下一个。
还被踩到的两个细节
一是坐标点击可能失败。执行器在指定坐标上找到的元素不一定是可点击节点,点在图标或 SVG 上会抛 「el.click is not a function」。此时改用选择器点到按钮本身,或者用元素派发事件更稳。
二是中途超时不一定代表没生效。超时返回后先读一次页面状态,很可能前面的点击已经成功,只需要补剩下的。重新从头跑一遍,反而会因为重复点击把状态搞乱。
小结
在单页应用里做批量交互,三条规则够用:每次只推进一小步;每步之后重新查 DOM 而不是复用旧引用;每步之后读一次页面特征文案确认到位。把这三条固化进脚本,超时基本就不再出现了。
- 点赞
- 收藏
- 关注作者
评论(0)