上一篇讲的是用 DOM 事件去驱动一个 React 受控表单:原生 setter 写值、mousedown 开下拉、等选项、点中、回读确认。

这套能用,但它慢、会弹浮层、而且在弱一点的机器上肉眼可见地一个一个点。

如果组件库用的是那种带表单实例的 Form(antd 系的 form.setFieldsValue),有一条更直接的路:从 DOM 节点爬到 fiber,再从 fiber 上把 Form 实例拿下来,直接赋值。

三行拿到表单实例

src/driver/form-api.ts
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 状态干扰;后者本质上是在模拟一个人,而人的操作是有时序、会被打断、会被浮层挡住的。

能改数据源的时候就别去模拟人——这条结论在自动化里大概是通用的。