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

本文代码托管在 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: 可选的去抖回调

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

项目结构

text 复制代码
src/
├── components/
│   ├── IMEInput.tsx
│   └── IMENumberInput.tsx
├── hooks/
│   └── useIMEComposition.ts
├── App.tsx
└── main.tsx

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

抽取共享的合成 Hook

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

tsx 复制代码
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

tsx 复制代码
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 的变化;结束时再统一提交。

tsx 复制代码
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

tsx 复制代码
<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 的属性:

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

运行示例

bash 复制代码
npm install
npm run dev

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

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

API

IMEInput

InputProps 外,额外提供:

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

IMENumberInput

除全部 InputNumberProps 外,额外提供:

属性 类型 说明
debounce number onDebouncedChange 的延迟时间,单位毫秒。
onDebouncedChange `(value: string number
相关推荐
Super 含2 小时前
Android 启动优化(二):TTID、TTFD 与 Macrobenchmark 启动性能测量
前端
Python私教2 小时前
别急着加 llms.txt:企业官网面向 AI 搜索的工程清单
前端·人工智能·seo
东方小月2 小时前
从零开发一个 Coding Agent(十三):实现安全的 read 文件读取工具
前端·人工智能·全栈
东方小月3 小时前
从零开发一个 Coding Agent(十二):实现版本化 JSONL 与真实 CLI 入口
前端·设计模式·前端框架
FL16238631294 小时前
室内易燃物识别易燃评估室内易燃程度识别分割数据集labelme格式1015张85类别
java·服务器·前端
用户059540174464 小时前
把 AI 长期记忆去重测试从 20 分钟压到 40 秒,重复率从 18% 干到 1.5%
前端·css
kyriewen4 小时前
我把 AI 写的并发请求控制器手写了一遍——3 个语义我当时根本讲不清
前端·javascript·面试
_codemonster5 小时前
Vue中的ref和reactive到底在干嘛
前端·javascript·vue.js
IT_陈寒5 小时前
为什么我的JavaScript闭包总是漏掉那个变量?
前端·人工智能·后端