Udesk Series · Sample Code
React DOM Driver
Drive a third-party React controlled form — native setter, mousedown to open selects, find fields by label, wait on conditions
React DOM 驱动器
往一个不是你写的 React 受控表单里填值。四件事,每件都有一个想当然的写法会失败:
| 你想做的 | 想当然的写法 | 结果 |
|---|---|---|
| 写输入框 | el.value = x + 派 input | React 收不到(值追踪器判定「没变」) |
| 开下拉框 | selector.click() | 面板不开(组件监听的是 mousedown) |
| 找字段 | 按自动生成的 id | 下次发版就变了 |
| 等异步 | await delay(500) | 设短了必失败,设长了白等 |
单文件、零依赖、TypeScript。选择器抽成了配置(组件库各不相同),附一份 antd 风格的默认值。
📖 原理、那条「追踪器会被污染」的实测副作用、以及注入 UI 污染 label 匹配的坑 → 让 React 受控组件看见你写的值
🖱 亲手点一遍 → React 受控表单靶页
怎么用
import { setNativeValue, openSelect, byLabel, listLabels, waitFor, fillInputByLabel, selectByLabel, ANTD_LIKE,} from './driver/react-dom-driver';
// 接一张陌生表单,第一件事:看它有哪些字段listLabels(); // → ['客户', '分类', '描述', ...]
// 写输入框 / 文本域fillInputByLabel('客户', '示例客户 A');
// 选下拉(内部:开浮层 → 等选项 → 点中 → 回读确认;失败会收拾现场)const r = await selectByLabel('分类', '账号权限');if (!r.ok) console.warn('没填上,卡在:', r.reason); // 'no-field' | 'no-option' | 'not-applied'
// 换一套组件库:只改配置,不改逻辑const MY_UI = { ...ANTD_LIKE, row: '.my-form-row', label: '.my-form-label' };byLabel('客户', MY_UI);三条要记住的
- 写完要回读确认。 不要相信「我派了事件所以它一定生效了」——这份代码里每个写入动作后面都有一次
waitFor回读。 - 失败时收拾现场。 打进搜索框的关键词、展开的浮层,失败分支必须清掉(
bail()),否则用户会以为填上了,浮层还挡着下面的表单。 - 给自己注入的节点加统一标记(默认是
[data-injected])。否则你往 label 行里挂一个徽标,textContent就从客户变成客户 查档案,所有精确匹配当场失配——而你改的是 UI,坏的是查找逻辑,完全不会往这想。
验证
过 tsc --strict;四条结论全部在真实浏览器里的 React 靶页上跑通过(直接赋值 → state 不变;原生 setter → state 更新;click() → 浮层不开;mousedown + waitFor + 回读 → 选中生效;注入徽标 → 裸 textContent 匹配失配;labelTextOf 排除后恢复)。
代码
/** * 操控一个没有源码的 React 受控表单。 * * 四件事,每一件都有一个「想当然的写法」会失败: * 1. 写输入框 —— 直接赋值 React 不认,要用原型上的原生 setter * 2. 开下拉框 —— click() 打不开,多数组件库在 mousedown 上开面板 * 3. 找字段 —— 按 id 找会失效,要按可见的 label 文本找 * 4. 等待 —— 固定 sleep 不可靠,要等条件 * * 选择器是组件库相关的,抽成配置。下面给了 antd 风格的默认值。 */
export interface DriverConfig { /** 表单行容器:label 与控件在同一个行容器里 */ row: string; /** 行内的 label 元素 */ label: string; /** 行内的 select 触发器 */ selectTrigger: string; /** 下拉浮层里的选项(浮层通常挂在 body 上,不在行里) */ option: string; /** select 当前选中项的显示文本 */ selectValue: string; /** select 内部的搜索输入框(支持搜索的 select 才有) */ selectSearch: string; /** * ★ 你自己注入进页面的节点。取 label 文本时必须排除它们,见 labelTextOf。 * 给自己的注入 UI 统一加一个标记类 / data 属性,这里才好排除。 */ injected?: string;}
export const ANTD_LIKE: DriverConfig = { row: '.ant-form-item', label: '.ant-form-item-label', selectTrigger: '.ant-select-selector', option: '.ant-select-item-option', selectValue: '.ant-select-selection-item', selectSearch: 'input.ant-select-selection-search-input', injected: '[data-injected]',};
/* ================================================================== * * 1. 写值:绕过 React 的值追踪器 * ================================================================== */
/** * 往 input / textarea 写值,并让 React 真的认这个值。 * * **为什么不能直接 `el.value = v`**:React 给每个受控输入挂了一个「值追踪器」, * 它记着上一次的值。直接赋值会把 DOM 的 value 和追踪器一起改掉,于是你随后派的 * `input` 事件在 React 看来是「值没变」,**整个事件被当成空操作丢弃**。 * * 从原型上取原生 setter 调用,改的只是 DOM 的 value,**追踪器还停在旧值**, * 于是 React 认为「变了」,正常走 onChange。 * * ⚠ 原型要按元素类型取:textarea 和 input 是两个不同的原型, * 拿错了调用时会抛 TypeError(illegal invocation)。 * * ⚠⚠ **追踪器是有状态的,而且会被污染**(实测):如果你先试了一次直接赋值, * DOM 值和追踪器会一起变成 X;此时再用本函数写**同一个 X**,React 依然判定 * 「值没变」而丢弃。于是「先试直接赋值,不行再换 setter」这个很自然的调试顺序, * 会让你误判连正确写法也失败。换个不同的值,或先写空再写目标值。 */export function setNativeValue(el: Element | null, value: string): void { if (!el) return;
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 }));}
/* ================================================================== * * 2. 开下拉:click() 不行 * ================================================================== */
/** * 打开一个 select 的下拉浮层。 * * **为什么 `el.click()` 没用**:多数组件库的 select 监听的是 **mousedown**, * 不是 click。`HTMLElement.click()` 只派 click 事件,面板根本不会开。 * * 派事件要带 `bubbles` 和 `cancelable`——组件库常在祖先节点上做事件委托, * 不冒泡就收不到。 */export function openSelect(el: Element | null): void { if (!el) return; el.dispatchEvent( new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }), );}
/** 关掉下拉浮层:把焦点挪走通常比再点一次可靠。 */export function closeSelect(el: Element | null): void { (el as HTMLElement | null)?.blur?.(); (document.activeElement as HTMLElement | null)?.blur?.();}
/* ================================================================== * * 3. 找字段:按可见 label,不按 id * ================================================================== */
export type FieldKind = 'select' | 'input' | 'textarea';
export interface FoundField { kind: FieldKind; row: HTMLElement; element: HTMLElement;}
/** 归一化 label:去掉空白、冒号、必填星号 —— 它们在不同状态下会变。 */export function normalizeLabel(s: string): string { return s.replace(/\s+/g, '').replace(/[::**]/g, '').trim();}
/** * 取某元素的「纯原生 label 文本」。 * * ★ 必须排除**你自己注入的节点**。 * 往 label 行里挂了几个徽标之后,`textContent` 会从「客户」变成 * 「客户 A B C」,**所有精确匹配当场全线失配**——而你完全不会往这想, * 因为你改的是 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 ?? '';}
/** 按可见 label 文本找字段。id 是自动生成的、会变,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;}
/** 列出当前表单上所有 label —— 调试时先跑这个,比猜选择器快得多。 */export function listLabels(cfg: DriverConfig = ANTD_LIKE, root: ParentNode = document): string[] { const out: string[] = []; root.querySelectorAll<HTMLElement>(cfg.row).forEach((row) => { const t = normalizeLabel(labelTextOf(row.querySelector(cfg.label), cfg)); if (t) out.push(t); }); return out;}
/* ================================================================== * * 4. 等待:等条件,不等时间 * ================================================================== */
export const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
/** * 轮询等一个条件成立。 * * 比 `await delay(500)` 好在两头:条件早成立就早返回(快), * 网络慢的时候也不会提前放弃(稳)。 */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); })(); });}
/* ================================================================== * * 组合动作 * ================================================================== */
export interface FillResult { ok: boolean; /** 没成功时说清楚卡在哪一步,别只回 false */ reason?: 'no-field' | 'no-option' | 'not-applied';}
/** 按 label 往输入框 / 文本域写值。 */export function fillInputByLabel( label: string, value: string, cfg: DriverConfig = ANTD_LIKE, root: ParentNode = document,): FillResult { const f = byLabel(label, cfg, root); if (!f || f.kind === 'select') return { ok: false, reason: 'no-field' }; setNativeValue(f.element, value); return { ok: true };}
/** * 按 label 选中一个下拉选项(「点击式」路径)。 * * 完整动作:聚焦搜索框 → 打关键词 → mousedown 开浮层 → 等选项出现 → 点中目标。 * * ★ 每一个失败出口都必须 bail(),见下。 */export async function selectByLabel( label: string, optionText: string, cfg: DriverConfig = ANTD_LIKE, root: ParentNode = document, timeoutMs = 2000,): Promise<FillResult> { const f = byLabel(label, cfg, root); if (!f || f.kind !== 'select') return { ok: false, reason: 'no-field' };
const search = f.row.querySelector<HTMLInputElement>(cfg.selectSearch);
/** * ★ 放弃时一定要收拾现场。 * * 你往搜索框打了关键词、开了浮层,如果失败分支直接 return, * 关键词和浮层就**留在界面上**。字段的真实值其实没被改动(受控值只在选中 * 选项时才提交),纯粹是 UI 残留——但用户分不出来,而且浮层会挡住下面的表单。 */ const bail = (reason: FillResult['reason']): FillResult => { if (search) setNativeValue(search, ''); closeSelect(f.element); return { ok: false, reason }; };
if (search) { search.focus(); setNativeValue(search, optionText); } openSelect(f.element);
const target = normalizeLabel(optionText); const findOption = (): HTMLElement | null => { // 浮层通常挂在 body 上,不在表单行里 —— 所以从 document 找 for (const o of document.querySelectorAll<HTMLElement>(cfg.option)) { if (normalizeLabel(o.textContent ?? '') === target) return o; } return null; };
const appeared = await waitFor(() => findOption() !== null, timeoutMs); if (!appeared) return bail('no-option');
findOption()!.dispatchEvent( new MouseEvent('click', { bubbles: true, cancelable: true, view: window }), );
// ★ 写完要回读确认,不要相信「我派了事件所以它一定生效了」 const applied = await waitFor(() => { const cur = f.row.querySelector(cfg.selectValue); return normalizeLabel(cur?.textContent ?? '') === target; }, 600);
return applied ? { ok: true } : bail('not-applied');}原理与踩坑 → 让 React 受控组件「看见」你写的值 · 靶页 → /tools/react-driver-playground.html