要往一个不是你写的 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:
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 —— 上面每一条结论都能自己点出来。