要往一个不是你写的 React 表单里填值,四件事,每一件都有一个看起来天经地义、实际上不工作的写法:

你想做的想当然的写法结果
写输入框el.value = x + 派 input 事件React 收不到
开下拉框selector.click()面板不开
找字段document.querySelector('#customer_id')下次发版就变了
等异步await delay(500)时快时慢,两头不讨好

这四条都可以亲手点一遍:我做了个靶页,左边是一个真的 React 受控表单,右边的按钮分别用错的和对的写法去操控它,同时显示「DOM 上的值」和「组件真实持有的 state」。

下面是每一条的原理。

一、写输入框:绕过值追踪器

React 给每个受控输入挂了一个值追踪器,记着上一次的值。它的用途是去重——避免为没有真正变化的输入重复触发 onChange。

直接 el.value = x 会把 DOM 的 value 和追踪器一起改掉。于是你随后派的 input 事件,在 React 看来是「当前值 === 追踪的值」,整个事件被当成空操作丢弃。

解法是从原型上取原生 setter 来调用。它改的只是 DOM 的 value,追踪器还停在旧值,React 于是认为「变了」,正常走 onChange:

src/driver/react-dom-driver.ts
export function setNativeValue(el: Element | null, value: string): void {
if (!el) return;
// ⚠ 原型要按元素类型取:textarea 和 input 是两个不同的原型,
// 拿错了调用时会抛 TypeError(illegal invocation)
const proto =
el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: el instanceof HTMLInputElement
? HTMLInputElement.prototype
: Object.getPrototypeOf(el);
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
if (setter) setter.call(el, value);
else (el as HTMLInputElement).value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}

一条实测出来的副作用:追踪器会被污染

写这篇文章配靶页的时候,我碰到一个意料之外的现象,值得单独说。

靶页最初的设计是两个按钮写同一个值——「想当然」和「正确」,好让读者看出区别只在 setter。结果按顺序点下来:第一个按钮如期失败,第二个按钮也失败了。

原因是:第一个按钮的直接赋值,把 DOM 值和追踪器一起改成了 示例客户 A。第二个按钮用原生 setter 写的还是 示例客户 A——追踪器里已经是这个值了,React 照样判定「没变」,把正确写法的事件也丢了。

换一个不同的值,立刻就生效。

这个现象的实际杀伤力在于调试顺序:「先试试直接赋值,不行再换成 setter」是非常自然的排查路径,而它会让你得出「setter 也没用」的错误结论,然后跑去怀疑别的地方。

所以:换个值再试,或者先写空再写目标值。 靶页上有个按钮专门演示这一幕。

二、开下拉:click() 不行

HTMLElement.click() 只派 click 事件。而多数组件库的 select 监听的是 mousedown——面板在你按下去的那一刻就该开,而不是等你松手。

export function openSelect(el: Element | null): void {
if (!el) return;
el.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }),
);
}

bubbles: true 不能省:组件库常在祖先节点上做事件委托,不冒泡就收不到。

开了面板之后还有两步,一步都不能少:

// 1) 等选项真的渲染出来 —— 它们常常是异步的(远程搜索、虚拟列表)
const appeared = await waitFor(() => findOption() !== null, 2000);
if (!appeared) return bail('no-option');
// 2) 点中之后回读确认 —— 不要相信「我派了事件所以它一定生效了」
findOption()!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const applied = await waitFor(() => currentText() === target, 600);

失败的时候要收拾现场

这一条是真吃过亏的。

「点击式」填下拉的完整动作是:聚焦搜索框 → 打关键词 → mousedown 开浮层 → 等选项 → 点中。中间任何一步失败,如果直接 return,你打的关键词和展开的浮层就留在界面上了。

诡异之处在于:字段的真实值其实没被改动(受控值只在选中选项时才提交),纯粹是 UI 残留。但用户分不出来——他看到搜索框里有字,以为填上了;而那个浮层还挡住了下面半张表单。

所以每个失败出口都走同一个 bail():

const bail = (reason: FillResult['reason']): FillResult => {
if (search) setNativeValue(search, ''); // 清掉打进去的关键词
closeSelect(f.element); // 关掉浮层
return { ok: false, reason }; // ★ 说清楚卡在哪一步,别只回 false
};

reason 那个字段后来救过我很多次。{ ok: false } 只告诉你没成,{ ok: false, reason: 'no-option' } 告诉你下拉开了但没这个选项——那是数据问题,不是交互问题。

能直填就别点

如果能拿到组件库的表单实例(通过 fiber),直接 setFieldsValue 比这一整套点击动作稳得多,而且不弹浮层。

我们有两条路:静默直填(走 fiber)和点击式(上面这套)。有一阵子点击式散落在三个地方当兜底,结果一次操作能弹出好几回下拉框,界面一直在闪。

后来的规矩是:点击式收敛成整条链路最后唯一一处,前面所有环节一律走静默直填。只要前面任何一步成功,就根本走不到点击式。

(从 DOM 摸到 fiber 再摸到表单实例是另一篇的话题,而且那件事必须在页面主世界做——隔离世界读不到 fiber,见 内容脚本的三个世界。)

三、找字段:按 label,不按 id

组件库生成的 id 形如 rc_select_7、form_item_customer_id_3。它们会因为渲染顺序、组件版本、甚至同页面多开一个弹窗而改变。

label 是给人看的,所以它稳定。 按可见的 label 文本找到行容器,再在行里找控件:

export function byLabel(
label: string,
cfg: DriverConfig = ANTD_LIKE,
root: ParentNode = document,
): FoundField | null {
const target = normalizeLabel(label);
if (!target) return null;
for (const row of root.querySelectorAll<HTMLElement>(cfg.row)) {
const labelEl = row.querySelector(cfg.label);
if (!labelEl) continue;
if (normalizeLabel(labelTextOf(labelEl, cfg)) !== target) continue;
const select = row.querySelector<HTMLElement>(cfg.selectTrigger);
if (select) return { kind: 'select', row, element: select };
const textarea = row.querySelector<HTMLTextAreaElement>('textarea');
if (textarea) return { kind: 'textarea', row, element: textarea };
const input = row.querySelector<HTMLInputElement>('input[type="text"], input:not([type])');
if (input) return { kind: 'input', row, element: input };
}
return null;
}
/** 归一化:去空白、去冒号、去必填星号 —— 它们在不同状态下会变 */
export const normalizeLabel = (s: string): string =>
s.replace(/\s+/g, '').replace(/[::**]/g, '').trim();

⚠ 别让自己注入的 UI 污染 label 文本

这个坑我在两个不同的位置各踩了一遍,所以值得单独拎出来。

某个版本我往「客户」这一行的 label 里挂了三枚状态徽标。功能没问题,但从那以后:

labelEl.textContent
// 想要的:'客户'
// 实际的:'客户 查档案 历史记录 详情'

所有 === '客户' 的精确匹配当场全线失配。 找字段、写骨架、同步标题、判断有没有草稿——全挂。

最难受的是归因方向完全错了:你改的是 UI,坏的是查找逻辑。 没人会把「加了个徽标」和「填单功能失灵」联系起来。

解法很简单,但前提是你给自己注入的每个节点都加了统一标记:

export function labelTextOf(el: Element | null | undefined, cfg: DriverConfig): string {
if (!el) return '';
const clone = el.cloneNode(true) as HTMLElement;
// ★ 克隆一份,移除自己注入的节点,再取文本
if (cfg.injected) clone.querySelectorAll(cfg.injected).forEach((n) => n.remove());
return clone.textContent ?? '';
}

凡是往宿主 DOM 里注入东西,就要假设有人在读那块 DOM 的文本。 给注入节点统一加一个标记类或 data- 属性,是一次性的小成本;不加的话,这个坑会在你想不到的地方复发。

靶页上有个按钮可以现场注入一个徽标,看 label 匹配怎么当场坏掉,再看排除注入节点之后怎么恢复。

四、等待:等条件,不等时间

export function waitFor(cond: () => boolean, timeoutMs = 1500, stepMs = 50): Promise<boolean> {
return new Promise((resolve) => {
const t0 = Date.now();
(function loop() {
if (cond()) return resolve(true);
if (Date.now() - t0 >= timeoutMs) return resolve(false);
setTimeout(loop, stepMs);
})();
});
}

固定 sleep 两头都不讨好:设短了在慢网络下必然失败,设长了每次操作都要白等。waitFor 是条件早成立就早返回,慢的时候也不会提前放弃。

注意它返回布尔而不是抛异常。超时是一个预期内的结果,不是异常情况——调用方大多要据此走兜底路径,而不是往上抛。

先列 label,再写代码

最后一个实用建议:接一个陌生表单时,第一件事不是去 DevTools 里一层层点元素,而是跑这个:

listLabels()
// → ['客户', '分类', '描述', '优先级', ...]

你立刻知道这张表单有哪些字段、label 的确切文本是什么(包括那些看不见的空格和全角冒号)。后面所有代码都以这个列表为准。


完整代码:/showcase/react-dom-driver —— 单文件、零依赖,选择器抽成配置,附一份 antd 风格的默认值。

亲手试试:/tools/react-driver-playground.html —— 上面每一条结论都能自己点出来。