---
title: "React DOM 驱动器"
url: "https://turingblog.org/notes/patterns/react-dom-driver"
date: 2026-09-22
tags: ["React","TypeScript","Chrome扩展"]
description: "操控一个没有源码的 React 受控表单 —— 原生 setter / mousedown 开下拉 / 按 label 找字段 / 等条件不等时间"
insight: "操控一个没有源码的 React 受控表单 —— 原生 setter / mousedown 开下拉 / 按 label 找字段 / 等条件不等时间"
problem: "往一个没有源码的 React 受控表单里填值时，直觉写法（直接赋值、click、按自动 id 找、固定延时）全都会失败。"
---

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

| 你想做的 | 想当然的写法 | 结果 |
|---|---|---|
| 写输入框 | `el.value = x` + 派 `input` | React 收不到（值追踪器判定「没变」） |
| 开下拉框 | `selector.click()` | 面板不开（组件监听的是 `mousedown`） |
| 找字段 | 按自动生成的 id | 下次发版就变了 |
| 等异步 | `await delay(500)` | 设短了必失败，设长了白等 |

单文件、零依赖、TypeScript。**选择器抽成了配置**（组件库各不相同），附一份 antd 风格的默认值。

> 📖 原理、那条「追踪器会被污染」的实测副作用、以及注入 UI 污染 label 匹配的坑 → [让 React 受控组件看见你写的值](/blog/drive-react-without-source)

## 怎么用

```ts
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` 排除后恢复）。

## 代码

```ts
// 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 受控组件「看见」你写的值](/blog/drive-react-without-source)
