视口高度为 0:一次浏览器自动化调试的完整复盘

27 阅读3分钟

起点:一次普通的自动化任务

任务本身没有技术含量:打开社区页面,点击按钮,提交表单。真正消耗时间的是第一步,点击工具直接报错:

Click coordinates (85, 31) are outside the visible viewport

坐标是正数,为什么会超出可视区域?

第一层:视口尺寸是 0x0

打印几个尺寸就能看出异常:

window.innerWidth;                     // 0
window.innerHeight;                    // 0
document.documentElement.clientWidth;  // 0

视口为 0 时 CSS 布局照常完成,但 getBoundingClientRect() 的返回值整体落在负半轴:

el.getBoundingClientRect();
// top: -480, left: -12, width: 84, height: 28

自动化工具习惯把 rect 的中心点当成真实鼠标坐标,负坐标被判定为视口外,点击直接被拒绝。结论很朴素:按钮不是找不到,是点不到。

第二层:绕开坐标,直接派发事件

在同一元素上合成事件序列可以绕过坐标层。组件库常常依赖 pointerdown 与 mousedown 切换内部状态,只派发 click 会静默失效,所以整套事件要补齐:

function fire(el, x, y) {
  ['pointerover', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(function (tp) {
    var E = tp.indexOf('pointer') === 0 ? PointerEvent : MouseEvent;
    el.dispatchEvent(new E(tp, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
  });
}

列表里按钮在视口外的情况,配合 scrollIntoView 一并解决:先把元素滚到中间,再取 rect 派发事件。

第三层:contenteditable 只认快捷键

评论框是一个 contenteditable 的 div。写入内容需要触发框架自己的输入监听:

var el = document.querySelector('.rich-input');
el.focus();
el.textContent = 内容;
el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true }));

内容进了 DOM,字数统计也更新了,但点发送没有任何请求发出。这类富文本框的提交挂在键盘事件上,真正有效的是快捷键 Ctrl+Enter,换成真实按键后一次通过。

写入走 input 事件,提交走键盘事件,两步分开是这个坑的核心。

第四层:隐藏模板被误读成风控

页面上能读到「当前操作失败」的字样,第一反应是触发风控。检查可见性之后才发现那是常驻隐藏节点:

var m = document.querySelector('.risk-modal');
var r = m.getBoundingClientRect();
// width 为 0 且 height 为 0:模板一直存在,从未弹出

innerText 与 textContent 都会包含隐藏节点。判断弹窗是否真的出现,必须用 getBoundingClientRect().width 大于 0 来断言,否则每一轮都会误判中止。

第五层:两步动作被当成一步

收藏按钮点了没反应,因为它只负责打开收藏集弹窗,真正的写入发生在第二步:

  1. 点收藏,弹出 .collection-list-modal;
  2. 勾选条目内的 label.checkbox-icon(点外层容器无效),再校验 input.checked 为 true;
  3. 点 .confirm-btn,此时才发出 POST /interact_api/v2/collectionset/add_article。

同类问题还有窄视口下的媒体查询:悬浮栏被 display none 隐藏,取到的 rect 全是 0,需要 style.setProperty 加 important 强制显形后再取坐标。

结论

这次调试没有一处卡在模型能力上,全部卡在运行环境与前端框架的契约上。可复用的做法是三条:

  • 先断言可见再动作:把 getBoundingClientRect().width 大于 0 作为所有点击前的守卫,隐藏元素直接跳过。
  • 一次动作一次校验:写完内容读字数,点完按钮读 class 或 URL,让失败稳定复现,而不是靠重试掩盖。
  • 以网络请求为终态证据:DOM 变化可能只是本地乐观更新,接口有没有发出才代表任务真正完成。

视口为 0 是极端环境,但这三条对生产环境的自动化脚本同样成立。点击失败往往不是元素不存在,而是脚本对「可见」和「成功」这两个判断过于乐观。