All projects

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

Install Copy-paste · zero deps · single file views

React DOM 驱动器

往一个不是你写的 React 受控表单里填值。四件事,每件都有一个想当然的写法会失败:

你想做的想当然的写法结果
写输入框el.value = x + 派 inputReact 收不到(值追踪器判定「没变」)
开下拉框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);

三条要记住的

  1. 写完要回读确认。 不要相信「我派了事件所以它一定生效了」——这份代码里每个写入动作后面都有一次 waitFor 回读。
  2. 失败时收拾现场。 打进搜索框的关键词、展开的浮层,失败分支必须清掉(bail()),否则用户会以为填上了,浮层还挡着下面的表单。
  3. 给自己注入的节点加统一标记(默认是 [data-injected])。否则你往 label 行里挂一个徽标,textContent 就从 客户 变成 客户 查档案,所有精确匹配当场失配——而你改的是 UI,坏的是查找逻辑,完全不会往这想。

验证

过 tsc --strict;四条结论全部在真实浏览器里的 React 靶页上跑通过(直接赋值 → state 不变;原生 setter → state 更新;click() → 浮层不开;mousedown + waitFor + 回读 → 选中生效;注入徽标 → 裸 textContent 匹配失配;labelTextOf 排除后恢复)。

代码

src/driver/react-dom-driver.ts
/**
* 操控一个没有源码的 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

ESC