浏览器自动化填表的三类「假成功」:受控组件的 value、藏在 class 里的勾选态、以及会错位的坐标点击
一、起因:脚本说「填好了」,页面说「没填」
用自动化填表单,最消耗时间的从来不是「怎么找到元素」,而是「明明填进去了,提交却没生效」。这类问题有个共同特点:脚本不报错,日志一路绿灯,甚至截图看上去都对了,但服务端收到的请求里字段是空的,或者校验一直卡在某个红字提示上。
最近在给几个不同技术栈的站点写自动登录和自动提交,连续撞上三种性质完全不同的坑。它们表面症状很像,成因却分别落在框架的事件机制、组件的状态表达方式、以及 DOM 坐标与视口的关系上。分开看每个都不复杂,混在一起就会让人在错误的方向上排查很久。
二、陷阱一:受控组件改 value 无效
先用最直觉的写法:
const input = document.querySelector('input[placeholder*="名称"]');
input.value = 'my-token-name'; // 看上去成功了
页面上输入框确实显示出了文字,但点提交后,服务端收到的是空值。原因在于 React / Vue 这类框架的受控组件:组件的状态是唯一数据源,DOM 上的 value 只是它的投影。直接赋值只改了投影,没有触发框架的变更回调,所以内部 state 仍然是空字符串。
正确做法是绕过框架对 value 属性的劫持,用原生 setter 写值,再手动派发事件:
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(input, 'my-token-name');
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
关键在于为什么要拿 HTMLInputElement.prototype 上的原始 setter。React 会在实例上重定义 value 的 getter/setter,用来做「值是否变化」的判断和受控回写。直接 input.value = x 走的是被劫持的 setter,它可能把变化吞掉;而从 prototype 上取到的原始 setter 能真正改掉底层的值,框架随后在 input 事件里读到的就是新值。
一个小验证技巧:改完之后立刻读一次 input.value,隔 100ms 再读一次。如果第二次读到的值被改回去了,说明框架在用它自己的 state 覆盖,十有八九就是受控组件没被正确通知。
三、陷阱二:状态藏在 class 里,checked 会骗人
第二个坑更隐蔽。某个基于组件库的复选框,勾选逻辑是这样的:
<div class="devui-checkbox unchecked">
<label>
<input type="checkbox">
<span class="devui-checkbox__material">...</span>
</label>
</div>
脚本点击之后,打印 input.checked 得到 false,于是判断「没勾上」,又点了一次——结果把刚勾上的取消了。来回折腾几轮,最后发现真实状态在外层 div 的 class 上:unchecked / active。那个 input 只是个语义占位,它的 checked 属性从来没有被同步过。
这类设计的成因通常是:组件库想自己绘制勾选样式(发光、动画、圆角),真实的 input 被视觉隐藏,状态由外层容器的 class 驱动样式。于是 input.checked 变成了一个「看起来应该有、实际上没人维护」的属性。
判断真实状态的写法:
const isChecked = (el) => (el.className || '').includes('active');
const box = document.querySelector('.devui-checkbox');
console.log(isChecked(box)); // 这个才是真的
通用一点的启发是:不要假设「语义 HTML 属性」等于「组件真实状态」。先用肉眼点一次,同时对比点击前后 DOM 里哪些东西变了(class、aria-checked、自定义属性),找到真正承载状态的那个字段,再决定用哪个判断。
还有一个连带教训:这种组件常常一次有多个。当时页面底部有两个协议复选框,一个是「用户协议 / 隐私政策」,一个是「数据共享」。只勾一个的话,点登录按钮毫无反应,页面只有一行红字提示,而红字本身不在按钮附近,脚本很容易误判成「登录接口失败」。
四、陷阱三:坐标点击在滚动和重排后会错位
拿到 getBoundingClientRect() 算中心点,再 mouse.click(x, y),这是很常见的写法。但它有两个前提前提失效就会点偏:
getBoundingClientRect()返回的是相对当前视口的坐标,页面一旦滚动,同一元素的位置就变了;- 计算坐标和真正点击之间如果发生了布局变化(比如勾选协议后红字提示消失,下面的内容整体上移),坐标就对不上了。
实测就踩了这个:先算出登录按钮在 y=356,点完之后页面因为提示文案消失重排,按钮跑到了 y=269,于是那一次点击落到了别的元素上,页面毫无反应。
更稳的做法是交给框架自己处理滚动和命中判定,用元素句柄点击而不是手算坐标:
const btn = await page.$('button.devui-button');
await btn.click(); // 自动滚动到元素、计算位置、派发真实鼠标事件
如果确实需要坐标(比如要模拟拖拽),至少要保证「取坐标」和「点击」之间页面没有变化,并在点击后立刻验证结果,而不是假设成功。
五、怎么系统排查这三类问题
这三类坑的症状都是「操作了但没生效」,可以按下面的顺序定位,成本从低到高:
- 先看网络请求。如果请求发出去了但字段是空的,问题在填值(陷阱一);如果请求根本没发出去,问题在提交动作(陷阱二、三)。
- 对比点击前后 DOM 变化。把目标元素及其祖先节点的
class、关键属性打印两次,直接看出状态字段存在哪。 - 用真实鼠标事件复核。同一次操作分别用「脚本派发 click」和「
mouse.click指定坐标」各做一遍,如果结果不同,基本就是坐标或事件层次的问题。 - 检查可见性。元素
getBoundingClientRect().width === 0说明它被隐藏了,这种情况下真实用户也点不到,必须先让它可见。
六、结论
- 受控组件的输入框,必须用原型上的原生 setter 写值 + 派发
input事件,直接赋值不可靠; - 组件库的 checkbox 等控件,真实状态常常在容器的 class 或
aria-*上,input.checked可能从不更新; - 凡是有多个同类复选框(协议、条款、授权),默认假设「全都要勾」,漏一个的表现往往是「按钮点了没反应」;
- 坐标点击要警惕滚动和布局重排,优先用元素句柄点击;
- 排查顺序从网络请求倒推,比盯着元素属性猜要快得多。
写完回头看,这些坑的共同点是:DOM 表面呈现的状态,和框架内部维护的状态,可能完全是两回事。自动化脚本如果只读 DOM 表象,就会得到「看起来对了」的假信号。交叉验证——请求体、class 变化、真实点击——才是可靠的确认方式。
- 点赞
- 收藏
- 关注作者
评论(0)