跳到主要内容

IME 合成窗口(Composition Window)详解

⌨️ 系统理解中文 / 日文 / 韩文输入法(IME)的「合成(composition)」过程:合成窗口是什么、浏览器会派发哪些事件、为什么受控输入框和富文本编辑器会在中文输入时出错,以及正确的处理范式。

一、什么是合成窗口

使用拼音、五笔、日文假名等输入法时,用户按下的键并不会直接变成最终文本,而是先进入一段未确认的中间态

  1. 用户按 n i h a o,输入法把按键流转换成拼音串。
  2. 屏幕上出现一个由操作系统 / 输入法绘制的浮层,展示候选词(你好、拟好、逆号…),这就是合成窗口(Composition Window / 候选词窗口)
  3. 用户按空格或数字键选词,或按 Enter 直接上屏,输入法才把最终字符串交给网页。
  4. Esc 则整段合成被丢弃。

关键点:

  • 合成窗口是浏览器之外的原生 UI,网页无法读取它的内容,也不能直接控制它的位置或样式。
  • 合成期间,输入框里显示的「nihao」或带下划线的「你好」是未提交文本(preedit / composing text),随时可能被整体替换或撤销。
  • 网页能感知的只有:合成开始、合成更新、合成结束这三个时刻,以及每次的中间字符串。

英文直接输入通常不产生合成过程;但 Android 软键盘、拼写联想、语音输入、Mac 长按输入重音字符等场景同样会触发合成事件。不要把「合成」等同于「中文输入」。

二、事件模型

1. 三个 composition 事件

事件触发时机event.data
compositionstart合成开始(首次出现候选窗)通常为空字符串
compositionupdate合成串每次变化当前未提交文本
compositionend选词上屏或取消合成最终提交的文本(取消时可能为空)

2. 与 input / keydown 的交织顺序

输入「你好」的典型序列(Chrome):

keydown(n) → compositionstart → compositionupdate("n") → input(isComposing=true)
keydown(i) → compositionupdate("ni") → input(isComposing=true)
...
keydown(Space) → compositionend("你好") → input(isComposing=false)

要点:

  • 合成过程中也会派发 input 事件,此时 event.isComposing === true,输入框的值是中间态。
  • compositionend 与随后的 input 事件顺序在不同浏览器上并不完全一致:Chrome / Firefox 通常是 compositionend 在前,旧版 Safari 及部分 Android WebView 可能相反。不要依赖固定顺序
  • 合成期间 keydownevent.keyCode 常常是 229(表示「按键已被 IME 消费」),这是老代码里判断合成状态的历史手段。现代代码应优先使用 event.isComposing 或自己维护的合成标志位。

3. beforeinput 的 inputType

beforeinput 事件提供了更精确的语义:

  • insertCompositionText:合成中间态写入
  • insertFromComposition(部分实现)/ insertText:合成结束提交
  • deleteCompositionText:合成被取消或替换

富文本编辑器通常在 beforeinput 层面拦截合成,从而避免在中间态修改 DOM。

三、最常见的三类坑

1. 受控输入框:中文被打断、字符重复

// ❌ 每次 input 都同步/格式化,会打断合成
<input value={value} onChange={e => setValue(e.target.value.trim().toUpperCase())} />

合成中间态被 React 重新写回 DOM,会让输入法认为文本已被外部修改,从而中断合成或产生「nihao你好」这类残留。

// ✅ 合成期间只更新本地展示,结束后再提交
function SearchInput({ onSearch }: { onSearch: (v: string) => void }) {
const [value, setValue] = useState("")
const composingRef = useRef(false)

const commit = (v: string) => {
if (!composingRef.current) onSearch(v)
}

return (
<input
value={value}
onChange={e => {
setValue(e.target.value)
commit(e.target.value)
}}
onCompositionStart={() => {
composingRef.current = true
}}
onCompositionEnd={e => {
composingRef.current = false
// 部分浏览器 compositionend 后不再触发 input,需要在这里补一次
setValue(e.currentTarget.value)
onSearch(e.currentTarget.value)
}}
/>
)
}

useRef 而不是 useState 保存合成标志:状态更新是异步的,onChange 里读到的可能是过期值。

2. 回车键提前提交

聊天框、搜索框最典型的 bug:用户按 Enter 只是想选词上屏,却把半成品消息发了出去。

input.addEventListener("keydown", e => {
// ✅ isComposing 为 true 时忽略回车
if (e.key === "Enter" && !e.isComposing) {
send()
}
})

注意:React 的合成事件在部分版本中不透传 isComposing,可用 e.nativeEvent.isComposing,或结合 keyCode === 229 兜底。同理,Esc、方向键、数字键在合成期间都属于输入法,不应触发业务快捷键。

3. 搜索联想 / 自动保存被中间态污染

合成期间每次 input 都发请求,会用「nih」「niha」这类拼音串去查询,既浪费请求又结果错误。

const onInput = (e: InputEvent) => {
if (e.isComposing) return // 中间态不查询
debouncedSearch((e.target as HTMLInputElement).value)
}

对协作编辑同理:合成期间不要把中间态广播给远端,否则其他人会看到一串跳动的拼音,且撤销栈会被污染。

四、富文本编辑器中的合成

ProseMirror / Tiptap、Slate、Quill 等编辑器都维护自己的文档模型与 DOM 的映射,而合成期间 DOM 由输入法直接改写,两者极易冲突。

通用策略:

  • 合成期间冻结外部写入:暂停远端 patch 应用、装饰(decoration)重算、setContent 等一切会重建 DOM 的操作,等 compositionend 后统一 flush。
  • 不要在合成中读取文档状态做业务判断(如 Markdown 快捷输入、/ 命令菜单),中间态会误触发规则。ProseMirror 的 handleTextInput 在合成时需要特别判断。
  • 光标与选区:合成期间修改 selection 会强制结束合成。NodeView 内的输入框、浮层要避免抢焦点。
  • CRDT 协作:合成结束后一次性生成事务,能避免为每个拼音字符生成 Y.js Item,减少文档膨胀和冲突。

相关笔记:Tiptap / ProseMirror 文档模型与插件机制Y.js CRDT 原理与协作编辑架构

五、合成窗口的定位

输入法候选窗默认跟随光标位置,浏览器根据当前编辑区的插入符位置告知系统。常见问题:

  • 使用「隐藏 input + 自绘光标」的方案(如 Canvas 编辑器、终端模拟器)时,若隐藏 input 被放在屏幕角落或 left: -9999px,候选窗会漂到奇怪的位置。正确做法是把该 input 定位到视觉光标处,宽高设为 1px,用 opacity: 0color: transparent 隐藏而非移出视口。
  • contenteditable 元素设置 transformoverflow: hidden 或处于 iframe 中时,候选窗定位也可能偏移。
  • 桌面端可用实验性的 EditContext API(Chromium 121+)显式控制合成区域与候选窗锚点,适合自绘编辑器,但兼容性有限。

六、跨平台差异清单

  • Android WebView / 输入法compositionend 后可能不触发 input;部分输入法(如搜狗、Gboard 联想)在英文输入时也持续处于合成态;isComposing 支持度不稳定。
  • iOS Safari:中文键盘的联想候选栏会在未合成状态下改写文本;beforeinput 的 inputType 与桌面存在差异。
  • macOS 原生输入法:长按字母键的重音选择也会走合成流程。
  • Windows 微软拼音:分段合成(先上屏一部分)会产生多轮 compositionstart / compositionend

因此测试用例必须覆盖:拼音连打、分段上屏、Esc 取消、选词后立刻回车、粘贴与合成交替、合成中触发远端更新。

七、检查清单

  • 所有 Enter 提交逻辑都判断了 isComposing
  • 搜索 / 自动保存 / 埋点在合成中间态被跳过
  • 受控组件在合成期间不做值改写(trim、格式化、大小写转换)
  • compositionend 中补齐一次同步,兼容不触发 input 的浏览器
  • 合成标志用 ref 而非 state 保存
  • 协作 / 远端更新在合成期间挂起,结束后 flush
  • 自绘光标场景中隐藏 input 位于视觉光标处
  • 在 Android Chrome、iOS Safari、Windows 微软拼音上分别回归

✅ 一句话原则:合成期间的文本是「草稿」,不是「数据」。只读不写、只展示不提交,等 compositionend 后再让业务逻辑介入。