React 受控组件为何挡住了自动化脚本:六组各 10 轮的点选实验

举报
yd_263929376 发表于 2026/10/28 16:03:16 2026/10/28
【摘要】 React 受控组件为何挡住了自动化脚本:六组各 10 轮的点选实验 一、现象:正文能写进去,勾选却弹回来活儿很具体:给一个技术社区的发布页做半自动流程。编辑区是 iframe 里的 CodeMirror,把 Markdown 塞进去,字数统计立刻跳变,说明写入链路是通的。但底部两件事怎么都过不去——勾「免责声明」,肉眼可见地勾上又弹回;点「发表」,页面毫无反应,控制台干净,网络面板连一个...

React 受控组件为何挡住了自动化脚本:六组各 10 轮的点选实验

一、现象:正文能写进去,勾选却弹回来

活儿很具体:给一个技术社区的发布页做半自动流程。编辑区是 iframe 里的 CodeMirror,把 Markdown 塞进去,字数统计立刻跳变,说明写入链路是通的。但底部两件事怎么都过不去——勾「免责声明」,肉眼可见地勾上又弹回;点「发表」,页面毫无反应,控制台干净,网络面板连一个请求都没有。

脚本没有报错,这一步最迷惑人。它报告"点击成功",而页面状态是假的。

为了把归因钉死,我在本地搭了最小复现:React 18.3.1 + esbuild 打包(bundle 143,839 B),Playwright 1.64.1 复用系统 Chrome(HeadlessChrome/156),一张门控受控复选框,六种写法各跑 10 轮。

二、还原发生在 click() 返回之前

先看最直接的一组观测:

const el = document.querySelector('#cb-gated');
const before = el.checked;   // false
el.click();
const afterSync = el.checked; // false —— 中间态从未出现

10 轮结果完全一致:onChange 进入 10/10,状态改变 0/10,afterSync 已经是 false。单次 click() 本身的耗时是 2.8–3.6 ms。

这条数据排掉了一整类排查方向:不是"点太快",不是"等渲染",sleep(1000) 不会有用。还原是同步完成的,就在事件派发的同一个任务里。任何"点完再看一眼 checked"的补救,都看不到那个瞬间为真的值。

三、机制:DOM 只是 props 的一次投影

受控组件的输入框有 owner:checked={gated} 意味着每次渲染都用 state 覆盖 DOM 属性。React 在离散事件(click 属于此类)派发结束时,会执行一次恢复受控状态的逻辑,用 props 把被临时改动的 DOM 属性改回去。所以 DOM 和 state 是两个副本,浏览器层面写 DOM 只是改了投影,投影会在事件末尾被重画。

const [gated, setGated] = useState(false);
<input id="cb-gated" type="checkbox" checked={gated}
       onChange={() => {
         if (!unlocked) return;   // 门控:条件不满足就不 setState
         setGated(!gated);
       }} />

处理函数进去了、判断了、返回了,state 一次没变,DOM 自然被覆盖回 false。这就是"React 状态机回退脚本点击"的完整因果——不是 React 拒绝脚本,是脚本改的位置不对。

四、六组各 10 轮的对照

写法 进入 onChange 状态改变 DOM 结果
el.click(),门控未解除 10/10 0/10 200 ms 后 10/10 为 false
el.click(),门控已解除 10/10 10/10 true
dispatchEvent(new MouseEvent('click')) 10/10 10/10 true(isTrusted 为 false 10/10)
el.click() + 校验 isTrusted 10/10(全部被拦) 0/10 false
page.click() 真实鼠标 + 校验 isTrusted 10/10 10/10 true
el.checked = true(不派发事件) —(未派发) 0/10 立即为 true 10/10,一次无关点击后 0/10

第三行值得单独说:合成事件能过 React 的合成事件系统,却过不了 isTrusted 检查。而 page.click() 走的是浏览器真实输入管线,isTrusted 为 true。这两者的差异,决定了很多风控型页面的行为——它们不是"检测自动化",只是检测事件来源。

最后一行是最危险的一种:不派发事件、直接写属性,DOM 会一直显示勾选(实测 50 ms 后仍为 true),state 始终是 false。页面看起来"勾上了",提交时后端却说你没同意。我在复现里加了一次无关按钮点击,DOM 在 75 ms 内变回 false——也就是说,这个假象只维持到页面上任何一个离散事件为止。

五、陈旧闭包:另一种"回退"

还有一类现象长得像回退,其实是根本没变过:

const [stale, setStale] = useState(false);
const onStale = useCallback(() => setStale(!stale), []); // 依赖数组为空

stale 被闭包固定成初始的 false,每次点击都执行 setStale(true)。连续点 5 次,状态序列是 true, true, true, true, true——勾得上,取消不掉。对照组(依赖写全的正常受控)是 true, false, true, false, true。

这类代码在真实项目里不少见,多出现在为"优化"随手补 [] 的地方。它的症状和门控很像:点了,没反应。区别是它连第一次反向操作都做不到。

六、判断一个控件是不是受控:两次探查

改脚本之前,先确认目标到底是"没点中"还是"点了不算"。两个探查在控制台里就能跑完,不需要源码:

const el = document.querySelector('#cb-gated');
// 1. 落点:点击坐标上是不是它
const r = el.getBoundingClientRect();
document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2) === el;

// 2. 属性来源:production 包上同样挂着 __reactProps
const props = el[Object.keys(el).find(k => k.startsWith('__reactProps'))];
Object.keys(props);   // ['id', 'type', 'checked', 'onChange']

在 18.3.1 的生产构建上实测,节点上挂着 __reactFiber、__reactProps、__reactEvents 三个键,props.checked 为 false,onChange 是函数。看到 checked 出现在 props 里,就说明它是受控的,写 DOM 必然被覆盖;elementFromPoint 返回的不是它本身,那才是遮挡层问题。这两条把"点不中"和"点了不算"分开,能省掉大量瞎改。

七、disabled 不是属性,是派生状态

发布页的「发表」按钮是同一类结构,我在夹具里把文案写成「提交」以便区分:

const canSubmit = agreed && contentOk && captchaOk;
<button id="btn-submit" disabled={!canSubmit} onClick={submit}>提交</button>

脚本点击它,会得到一段很具体的日志:

page.click: Timeout 3000ms exceeded.
  - locator resolved to <button disabled id="btn-submit">提交</button>

超时 3005 ms,日志里明写 disabled。注意此时 DOM 上的那个复选框可能仍是 checked = true(因为脚本直写过属性),canSubmit 却是 false。DOM 说可以,状态说不行。

三个条件都真实满足后,同一行 page.click() 立刻成功,提交计数从 0 变成 1,无异常。所以问题从来不是选择器。

八、把最后一公里留给人

几条可操作的判断,按代价从低到高:

优先用 page.click() 而不是 el.click(),用 page.check() 而不是写 checked。check() 失败会抛得很清楚:Clicking the checkbox did not change its state,102 ms 就返回,比静默通过好得多。

断言要读派生条件,不要读 DOM:

await page.waitForFunction(() => {
  const btn = document.querySelector('#btn-submit');
  return btn && !btn.disabled;
}, null, { timeout: 5000 });

如果脚本连"条件是否满足"都推不出来——比如勾选前必须先滚动到底、必须先读完全文、必须过一次人机校验——那这一段就不是技术问题而是流程问题。发布这类不可逆动作,正确的做法是脚本负责到"内容就位、页面停在待提交状态",最后两次点击交给人。

结论

自动化脚本在受控组件上失败,几乎总能归结为一句话:它改的是 DOM,而页面认的是 state;两者之间只有一条桥,就是用户真实产生的那次事件,绕过这条桥的任何写法,都会在某次无关重渲染时被悄悄抹平。

文中六组数据由同一夹具、同一浏览器版本、各 10 轮得出,脚本存放在本地 .tmp-react/(app.jsx、bench.js、bench3.js),可直接复跑核对。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。