本文代码托管在 github.com/cbtpro/reac...
这个示例演示如何在 React 与 Ant Design Form 中处理中文、日文、韩文等输入法的合成过程,并提供两个可复用组件:
IMEInput:基于 Ant DesignInput的文本输入组件。IMENumberInput:基于 Ant DesignInputNumber的数字输入组件。
组件的设计原则是:合成中的内容只用于输入框显示;合成完成后的最终值才同步到表单和业务逻辑。
为什么需要区分合成状态
输入法输入并不是一次普通的键盘输入。以拼音输入"张三"为例,用户会先输入拼音、看到候选词、再确认文字。浏览器在这个过程中会触发合成事件。
compositionstart:开始输入法合成。change:候选词或临时文本变化。compositionend:用户确认选词,得到最终内容。
在合成期间如果立即执行格式化、远程搜索或将旧的受控值回写到输入框,就会干扰候选词。组件需要将"临时显示值"和"最终业务值"分开处理。
项目结构
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.isComposing 与 compositionstart/end 的 ref 标记同时使用,可以更稳妥地处理不同浏览器的事件顺序。
组件还会监听来自 Form 的 value 更新,以支持 resetFields、setFieldsValue 等外部操作;合成期间不会用外部旧值覆盖正在输入的文本。
完整代码见 src/components/IMEInput.tsx。
编写数字输入组件
IMENumberInput 直接包装 Ant Design InputNumber,不重复实现数字解析或格式化。InputNumber 的现有属性都会原样透传,包括:
precisionformatter与parserstringModemin、max、stepprefix、suffix、controls- 其他
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。
表单同步与去抖的边界
去抖不应该延迟表单值本身。否则用户输入后立刻提交,表单可能还没有收到最新内容。
组件采用以下分工:
onChange 用于同步 Form,onDebouncedChange 用于成本较高的业务副作用。两者互不等待。
在 Form 中使用
文本字段支持 Ant Design Input 的属性,并额外支持 debounce 和 onDebouncedChange:
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
启动后可以验证以下行为:
- 在姓名或备注中连续使用拼音输入并多次选词。
- 输入超过 20 个字符的备注,确认仍可继续编辑,提交时显示长度错误。
- 在备注停止输入 500ms 后观察去抖结果,同时确认立即提交依然能得到最新值。
- 在金额字段中使用
InputNumber的精度、范围或格式化能力。
API
IMEInput
除 InputProps 外,额外提供:
| 属性 | 类型 | 说明 |
|---|---|---|
debounce |
number |
onDebouncedChange 的延迟时间,单位毫秒。 |
onDebouncedChange |
(value: string) => void |
合成完成且防抖结束后触发的业务回调。 |
IMENumberInput
除全部 InputNumberProps 外,额外提供:
| 属性 | 类型 | 说明 |
|---|---|---|
debounce |
number |
onDebouncedChange 的延迟时间,单位毫秒。 |
onDebouncedChange |
`(value: string | number |