上一篇讲的是用 DOM 事件去驱动一个 React 受控表单:原生 setter 写值、mousedown 开下拉、等选项、点中、回读确认。
这套能用,但它慢、会弹浮层、而且在弱一点的机器上肉眼可见地一个一个点。
如果组件库用的是那种带表单实例的 Form(antd 系的 form.setFieldsValue),有一条更直接的路:从 DOM 节点爬到 fiber,再从 fiber 上把 Form 实例拿下来,直接赋值。
三行拿到表单实例
const REACT_KEYS = ['__reactFiber$', '__reactInternalInstance$'];
function getReactKey(node: Element): string | null { for (const k of Object.getOwnPropertyNames(node)) { if (REACT_KEYS.some((p) => k.startsWith(p))) return k; } return null;}
/** 从 <form> 元素爬到它所属组件的 props,把 form 实例取下来 */export function getFormApi(form: Element | null): FormApi | null { if (!form) return null; const k = getReactKey(form); if (!k) return null; const fiber = (form as any)[k]; // ★ .return 是父 fiber —— form 实例通常挂在渲染出 <form> 的那个组件的 props 上, // 而不是 <form> 这个宿主节点自己的 fiber 上 return fiber?.return?.memoizedProps?.form ?? null;}拿到之后:
getFormApi(form)?.setFieldsValue({ customer: { value: 123, label: '示例客户' } });一步到位。不开浮层、不等选项、不需要回读重试——因为你改的就是数据源本身,渲染是它自己的事。
级联选择器这类联动字段尤其受益:点击式要一级一级点、每级都等,而 setFieldsValue 一次触发全部联动。
两个必须先讲清楚的前提
前提一:这件事只能在页面主世界做
__reactFiber$xxx 是页面 JS 在 DOM 节点上设的普通属性。扩展的内容脚本跑在隔离世界,读不到——getFormApi 在那里恒返回 null。
而你在 DevTools Console 里测会成功,因为 Console 默认在主世界。这是「实验通过、上线无效」的经典陷阱,细节见内容脚本的三个世界。
所以实际形态是:主世界放一个桥,隔离世界发请求过去。
// 隔离世界侧const r = await callPage('fillSelect', { label: '分类', optionTitle: '账号权限' });主世界那侧收到请求,getFormApi(...) → setFieldsValue(...) → 校验选中文本 → 回传结果。
前提二:.return 那一跳不是固定的
fiber.return.memoizedProps.form 这个路径依赖组件库的实现细节——它可能变。
所以真实代码里更稳的做法是往上爬几层找,而不是写死跳一层:
let cur = fiber;for (let d = 0; cur && d < 10; d++, cur = cur.return) { const f = cur.memoizedProps?.form; if (f && typeof f.setFieldsValue === 'function') return f;}return null;并且必须有兜底:拿不到实例就退回点击式路径。把整个功能压在一个未公开的内部结构上,等于把自己绑在对方的版本号上。
真正的难点:哪一张才是「那张表单」
拿实例本身是三行代码。难的是找到正确的 <form>。
内容脚本注入每一个 frame,而 document.querySelector('form') 在每个 frame 里都能返回点什么。实测的情况是:
| frame | 里面的 form |
|---|---|
| 顶层帧 | 真表单,21 个表单项,挂着 fiber |
| 某个同源 iframe | 影子表单,表单项数为 0,长得很像但什么都没有 |
| 富文本编辑器的 iframe | 另一个无关的 form |
| 跨源 iframe | 访问就抛 SecurityError |
在影子表单上调什么都「成功」——它有 <form> 标签,querySelector 找得到,只是里面什么都没有。 于是你会得到「返回成功但字段没填上」这种最难查的结果。
所以要先跨 frame 收集所有同源 document,再按优先级挑:
/** 本 frame + 所有同源祖先 + top 往下 BFS 的所有同源 frame 的 document */function collectSameOriginDocuments(): Document[] { const docs: Document[] = [document]; const seen = new Set<Document>([document]);
// 向上爬 parent 链 try { let w: Window | null = window.parent; while (w && w !== window) { const d = w.document; if (d && !seen.has(d)) { docs.push(d); seen.add(d); } if (w === w.parent) break; w = w.parent; } } catch { /* 跨源:忽略 */ }
// 从 top 往下 BFS try { const queue: Window[] = [window.top as Window]; while (queue.length) { const w = queue.shift()!; try { const d = w.document; if (d && !seen.has(d)) { docs.push(d); seen.add(d); } for (let i = 0; i < w.frames.length; i++) queue.push(w.frames[i]); } catch { /* 跨源子 frame:跳过 */ } } } catch { /* ignore */ }
return docs;}每一步都要 try/catch:跨源 frame 上访问 .document 会抛 SecurityError,一次没接住整条链路就断了。
按「有没有料」挑,不按顺序挑
export function getActiveForm(): HTMLElement | null { const allForms = collectSameOriginDocuments() .flatMap((d) => Array.from(d.querySelectorAll<HTMLElement>('form')));
// ① 含目标字段 + 可见(影子表单通常两条都不满足) const candidates = allForms.filter( (f) => !!f.querySelector('#subject') && f.offsetParent !== null, );
// ② 首选挂着 fiber 的那个(只有主世界判得出来) const withFiber = candidates.find((f) => !!getFormApi(f)); if (withFiber) return withFiber;
// ③ 退而求其次:含特定几个业务字段的那个 const withFields = candidates.find(hasExpectedLabels); if (withFields) return withFields;
// ④ 再退:任意一个可见的候选 if (candidates[0]) return candidates[0];
// ⑤ 最后兜底:当前 frame 的第一个 form(保留旧行为) return allForms[0] ?? null;}这里有个反直觉的点:第 ② 步的判据在隔离世界里永远为假。
早期版本把 && getFormApi(f) 直接写进了第 ① 步的 filter 里。在主世界没问题,在隔离世界筛完永远是空——于是一路掉到第 ⑤ 步,拿到影子表单。
所以判据要分层:主世界用得上的条件放在优先项,隔离世界也判得出来的条件必须能独立兜住。 同一段代码要在两个世界里都给出合理结果。
找字段也要用同一个 scope
这条是配套的,漏了会出现极具迷惑性的现象:
const textarea = document.querySelector('textarea');const scope = getActiveForm()?.ownerDocument ?? document;const textarea = scope.querySelector('textarea');我们曾经下拉框走的是跨 frame 查找,而文本域用的是裸 document。结果:在影子 frame 里触发时,下拉填上了,文本域报「找不到」——而那个字段在屏幕上肉眼可见。
排查信号:某个字段报「找不到」,但它明明在页面上 —— 你找错 frame 了。
什么时候用哪条路
两条路都留着,但要分清主次:
| 直填(fiber → form 实例) | 点击式 | |
|---|---|---|
| 速度 | 一次调用 | 每个字段好几步 |
| 会弹浮层吗 | 不弹 | 弹 |
| 可靠性 | 依赖组件库内部结构 | 依赖 DOM 结构 |
| 能在隔离世界跑吗 | ❌ 必须过桥 | ✅ |
我们的规矩是:前面所有环节一律走直填,点击式收敛成整条链路最后唯一一处兜底。
有一阵子点击式散落在三个地方当兜底,结果一次操作能弹出好几回下拉框,界面一直在闪。更糟的是其中一次是注定失败的——那个场景下目标选项本来就不存在,白白 focus、打字、等两秒半超时。
收敛之后:只要前面任何一步成功,就根本走不到点击式。
这一套的价值不只是快。直填改的是数据源,点击式改的是界面。
前者天然幂等、天然触发联动、天然不会被别的 UI 状态干扰;后者本质上是在模拟一个人,而人的操作是有时序、会被打断、会被浮层挡住的。
能改数据源的时候就别去模拟人——这条结论在自动化里大概是通用的。