React + Ant Design 中的 IME (输入法合成)安全输入组件

6 阅读4分钟

本文代码托管在 github.com/cbtpro/reac…

这个示例演示如何在 React 与 Ant Design Form 中处理中文、日文、韩文等输入法的合成过程,并提供两个可复用组件:

  • IMEInput:基于 Ant Design Input 的文本输入组件。
  • IMENumberInput:基于 Ant Design InputNumber 的数字输入组件。

组件的设计原则是:合成中的内容只用于输入框显示;合成完成后的最终值才同步到表单和业务逻辑。

为什么需要区分合成状态

输入法输入并不是一次普通的键盘输入。以拼音输入“张三”为例,用户会先输入拼音、看到候选词、再确认文字。浏览器在这个过程中会触发合成事件。

  1. compositionstart:开始输入法合成。
  2. change:候选词或临时文本变化。
  3. compositionend:用户确认选词,得到最终内容。
sequenceDiagram
  participant U as 用户 / 输入法
  participant C as IME 组件
  participant F as Ant Design Form
  participant B as 业务回调

  U->>C: compositionstart
  Note over C: 标记为合成中
  U->>C: change(临时文本变化)
  Note over C: 仅更新输入框显示值
  U->>C: compositionend(确认文字)
  C->>F: 立即同步最终值
  C-->>B: 可选的去抖回调

在合成期间如果立即执行格式化、远程搜索或将旧的受控值回写到输入框,就会干扰候选词。组件需要将“临时显示值”和“最终业务值”分开处理。

项目结构

src/
├── components/
│   ├── IMEInput.tsx
│   └── IMENumberInput.tsx
├── hooks/
│   └── useIMEComposition.ts
├── App.tsx
└── main.tsx

useIMEComposition 管理两个组件共享的合成状态与去抖逻辑;两个组件只负责适配各自的 Ant Design 控件。

抽取共享的合成 Hook

useIMEComposition 接收去抖配置,返回合成状态引用和三个操作:开始合成、结束合成、提交最终值。

type UseIMECompositionOptions<T> = {
  debounce?: number
  onDebouncedChange?: (value: T) => void
}

export function useIMEComposition<T>({
  debounce = 0,
  onDebouncedChange,
}: UseIMECompositionOptions<T>) {
  const composingRef = useRef(false)
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  const clearDebounce = () => {
    if (timerRef.current) clearTimeout(timerRef.current)
    timerRef.current = null
  }

  const startComposition = () => {
    composingRef.current = true
    clearDebounce()
  }

  const endComposition = () => {
    composingRef.current = false
  }

  const emitChange = (value: T, onChange?: (value: T) => void) => {
    onChange?.(value)
    clearDebounce()

    if (!onDebouncedChange) return
    if (!debounce) {
      onDebouncedChange(value)
      return
    }
    timerRef.current = setTimeout(() => onDebouncedChange(value), debounce)
  }

  useEffect(() => clearDebounce, [])

  return { composingRef, startComposition, endComposition, emitChange }
}

这里用 useRef 保存 composingRef,而不是 useState。合成状态只用于事件处理,不需要展示在 UI 上;使用 ref 可以避免额外渲染,并能在连续事件中立即读取到最新标记。

编写文本输入组件

IMEInput 维护本地的 inputValue。它保证合成过程中的文本能正常显示,同时只在合成结束后将最终事件交给 Form.Item 注入的 onChange

const { composingRef, startComposition, endComposition, emitChange } =
  useIMEComposition<React.ChangeEvent<HTMLInputElement>>({
    debounce,
    onDebouncedChange: onDebouncedChange
      ? (event) => onDebouncedChange(event.target.value)
      : undefined,
  })

<Input
  {...props}
  value={inputValue}
  onCompositionStart={(event) => {
    startComposition()
    onCompositionStart?.(event)
  }}
  onCompositionEnd={(event) => {
    endComposition()
    setInputValue(event.currentTarget.value)
    emitChange(event as unknown as React.ChangeEvent<HTMLInputElement>, onChange)
    onCompositionEnd?.(event)
  }}
  onChange={(event) => {
    const isComposing = (event.nativeEvent as InputEvent).isComposing
    setInputValue(event.target.value)

    if (!composingRef.current && !isComposing) {
      emitChange(event, onChange)
    }
  }}
/>

InputEvent.isComposingcompositionstart/end 的 ref 标记同时使用,可以更稳妥地处理不同浏览器的事件顺序。

组件还会监听来自 Formvalue 更新,以支持 resetFieldssetFieldsValue 等外部操作;合成期间不会用外部旧值覆盖正在输入的文本。

完整代码见 src/components/IMEInput.tsx

编写数字输入组件

IMENumberInput 直接包装 Ant Design InputNumber,不重复实现数字解析或格式化。InputNumber 的现有属性都会原样透传,包括:

  • precision
  • formatterparser
  • stringMode
  • minmaxstep
  • prefixsuffixcontrols
  • 其他 InputNumberProps

合成期间组件暂存最后一次 InputNumber 的变化;结束时再统一提交。

const pendingValueRef = useRef<T | null | undefined>(undefined)

<InputNumber<T>
  {...props}
  onCompositionStart={(event) => {
    startComposition()
    pendingValueRef.current = undefined
    onCompositionStart?.(event)
  }}
  onCompositionEnd={(event) => {
    endComposition()

    if (pendingValueRef.current !== undefined) {
      emitChange(pendingValueRef.current, onChange)
      pendingValueRef.current = undefined
    }
    onCompositionEnd?.(event)
  }}
  onChange={(nextValue) => {
    if (composingRef.current) {
      pendingValueRef.current = nextValue
      return
    }
    emitChange(nextValue, onChange)
  }}
/>

完整代码见 src/components/IMENumberInput.tsx

表单同步与去抖的边界

去抖不应该延迟表单值本身。否则用户输入后立刻提交,表单可能还没有收到最新内容。

组件采用以下分工:

graph LR
    A[用户确认输入] --> B[立即调用 Form onChange]
    B --> C[表单校验与提交使用最新值]
    A --> D{配置 onDebouncedChange}
    D -->|是| E[取消旧计时器]
    E --> F[延迟执行搜索或远程校验]
    D -->|否| G[结束]

onChange 用于同步 FormonDebouncedChange 用于成本较高的业务副作用。两者互不等待。

在 Form 中使用

文本字段支持 Ant Design Input 的属性,并额外支持 debounceonDebouncedChange

<Form.Item
  name="remark"
  label="备注"
  rules={[
    { required: true, message: '请输入备注' },
    { max: 20, message: '备注不能超过 20 个字符' },
  ]}
>
  <IMEInput
    showCount
    placeholder="请输入备注"
    debounce={500}
    onDebouncedChange={(value) => {
      // 用于搜索、远程校验或自动保存
      console.log(value)
    }}
  />
</Form.Item>

这里的 max 只做表单校验,不限制继续输入。如果希望在输入时直接阻止超长内容,可以额外传入 maxLength={20}

数字字段可直接使用 InputNumber 的属性:

<Form.Item name="amount" label="金额" rules={[{ required: true, message: '请输入金额' }]}>
  <IMENumberInput
    precision={2}
    prefix="¥"
    min={0}
    placeholder="请输入金额"
  />
</Form.Item>

运行示例

npm install
npm run dev

启动后可以验证以下行为:

  1. 在姓名或备注中连续使用拼音输入并多次选词。
  2. 输入超过 20 个字符的备注,确认仍可继续编辑,提交时显示长度错误。
  3. 在备注停止输入 500ms 后观察去抖结果,同时确认立即提交依然能得到最新值。
  4. 在金额字段中使用 InputNumber 的精度、范围或格式化能力。

API

IMEInput

InputProps 外,额外提供:

属性类型说明
debouncenumberonDebouncedChange 的延迟时间,单位毫秒。
onDebouncedChange(value: string) => void合成完成且防抖结束后触发的业务回调。

IMENumberInput

除全部 InputNumberProps 外,额外提供:

属性类型说明
debouncenumberonDebouncedChange 的延迟时间,单位毫秒。
onDebouncedChange(value: string | number | null) => void合成完成且防抖结束后触发的业务回调。