硅基俊宁.
Skills · 技能体系 ↗
← 返回文章

修复中文输入法按回车误发送:React Chat Composer 的 IME 事件陷阱

俊宁 · 2026-07-21
迁自历史博客,保留原文、署名、引用与原始日期;文中的版本、成果与性能结论不代表重新完成了当前验收。
查看原始出处 ↗ · 查看迁移清单
修复中文输入法按回车误发送:React Chat Composer 的 IME 事件陷阱封面

最近在做 Agentic Chat 时,我遇到一个看似很小、实际会直接破坏输入体验的问题:使用中文输入法输入英文片段,候选框还开着,此时按 Enter 本来只是确认候选词,Composer 却把这次 Enter 当成了“发送消息”。

这个 Bug 特别容易被纯英文开发环境漏掉。按钮点击正常,英文键盘正常,甚至多数浏览器里只判断 isComposing 也能正常;但到了 macOS 输入法和 WebKit 的真实事件时序,消息仍然会提前发出去。

一句话总结

Chat Composer 不能把所有 Enter 都视为提交。发送前至少要同时检查 KeyboardEvent.isComposing、IME 的兼容值 keyCode === 229,并处理部分 WebKit 环境中 compositionend 早于提交用 keydown 到达的时序差异。

最终的判断不是“用户有没有按 Enter”,而是:这次 Enter 的语义是确认输入法候选,还是提交已经完成的消息?

先理解 IME:输入框里的文字不一定已经提交

IME 是 Input Method Editor。中文拼音、日文假名、韩文以及部分语音和手写输入,并不是每按一个键就立即产生最终字符,而是先进入一个 composition session:

compositionstart
  ↓
compositionupdate × N
  ↓
compositionend

在这个阶段,输入框中看起来已经出现了 auto,但它可能仍处于输入法的组合态。用户按 Enter 的意图是从候选列表中选择 automation,不是把整条聊天消息发给 Agent。

浏览器通过 KeyboardEvent.isComposing 暴露当前按键是否发生在 composition session 内。MDN 对它的定义很直接:在 compositionstart 之后、compositionend 之前触发的键盘事件,该值应为 true。

因此最基础的发送逻辑应该是:

function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
  if (event.key !== "Enter") return;

  if (event.nativeEvent.isComposing) {
    return;
  }

  if (!event.shiftKey) {
    event.preventDefault();
    sendMessage();
  }
}

这能解决大多数标准事件顺序,但还不够。

为什么只判断 isComposing 仍然会误发送

问题出在浏览器和操作系统对 composition 与 keyboard event 的排序并不完全一致。

理想情况下,用户按 Enter 确认候选时,keydown 发生在 composition 结束之前:

keydown Enter (isComposing: true)
compositionend
keyup Enter

此时 isComposing 足以阻止发送。

但 WebKit 在 macOS 上长期存在另一种时序:

compositionend
keydown Enter (isComposing: false)
keyup Enter

对业务代码来说,收到 keydown 时 composition 已经结束,isComposing 自然变成 false。如果发送条件只是:

event.key === "Enter" && !event.nativeEvent.isComposing

这次用于确认候选的 Enter 就会穿透保护,直接提交表单。

WebKit Bugzilla 的相关记录明确提到:macOS 上接受 IME 输入的 Enter 可能在 compositionend 之后才以 keydown 到达,因此 isComposing === false。这不是 React 自己的状态问题,而是底层事件顺序已经让上层失去了原始语义。

keyCode 229 为什么仍然有价值

keyCode 已经是 legacy API,正常键盘处理应该优先使用 event.key 和 event.code。但 UI Events 规范仍然保留了一条与 IME 直接相关的兼容规则:如果 keydown 正由 Input Method Editor 处理,返回值是 229。

因此,IME guard 中使用 keyCode === 229 不是把所有键盘逻辑退回旧 API,而是把它作为一个窄范围的兼容信号:

const nativeEvent = event.nativeEvent;

const isImeEvent =
  nativeEvent.isComposing ||
  nativeEvent.keyCode === 229;

它能覆盖一部分 isComposing 不可靠、但浏览器仍把按键标记为 IME processing 的情况。

不过它仍不是完整答案。有些环境可能在 compositionend 后把这次 Enter 暴露成普通的 keyCode 13。因此还需要记录组件自己的 composition 状态,并给刚结束的 composition 留一个很短的保护窗口。

稳健修复:三层 IME Guard

我最后采用了三层判断:

  1. 组件内的 composingRef,记录 compositionstart 到 compositionend。
  2. 浏览器提供的 nativeEvent.isComposing 与 keyCode === 229。
  3. compositionend 后一个短暂的 grace period,挡住乱序到达的 Enter。

React 实现如下:

import { useRef } from "react";

export function ChatComposer() {
  const composing = useRef(false);
  const compositionJustEnded = useRef(false);
  const compositionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  return (
    <textarea
      onCompositionStart={() => {
        composing.current = true;
        compositionJustEnded.current = false;

        if (compositionTimer.current) {
          clearTimeout(compositionTimer.current);
        }
      }}
      onCompositionEnd={() => {
        composing.current = false;
        compositionJustEnded.current = true;

        if (compositionTimer.current) {
          clearTimeout(compositionTimer.current);
        }

        compositionTimer.current = setTimeout(() => {
          compositionJustEnded.current = false;
        }, 80);
      }}
      onKeyDown={(event) => {
        const nativeEvent = event.nativeEvent;

        const isImeEnter =
          event.key === "Enter" &&
          (
            composing.current ||
            compositionJustEnded.current ||
            nativeEvent.isComposing ||
            nativeEvent.keyCode === 229
          );

        if (isImeEnter) {
          event.preventDefault();
          return;
        }

        if (event.key === "Enter" && !event.shiftKey) {
          event.preventDefault();
          sendMessage();
        }
      }}
    />
  );
}

这里的 80ms 不是协议常量,而是一个很窄的事件时序缓冲。它只保护 composition 刚结束后紧随而来的 Enter,不会改变用户完成输入后再次按 Enter 发送的习惯。如果产品允许调整发送方式,提供 Cmd/Ctrl + Enter 模式仍然是很好的补充,但不能用它掩盖默认 Enter 模式的 IME Bug。

在 assistant-ui 里应该在哪里拦截

应用侧使用 assistant-ui 的 ComposerPrimitive.Input。这类 headless primitive 通常已经包含基础的 isComposing 判断,但宿主应用仍可能需要为特定桌面 WebView、输入法和系统版本补充 guard。

关键是把处理放在 Composer 输入边界,而不是 sendMessage() 里事后猜测:

<ComposerPrimitive.Input
  onCompositionStart={handleCompositionStart}
  onCompositionEnd={handleCompositionEnd}
  onKeyDown={handleComposerKeyDown}
/>

原因有三点:

如果组件库会组合用户传入的 handler 与内部 handler,需要确认组合顺序及 defaultPrevented 语义。理想行为是外部 handler 先运行;外部调用 preventDefault() 后,内部 Enter-submit 不再执行。

不要只测“中文能不能输入”

这个 Bug 的验收重点是事件语义,而不是文字最终有没有出现在 textarea。

至少覆盖下面这些用例:

场景期望结果
中文输入法候选打开,按 Enter确认候选,不发送
日文输入法候选打开,按 Enter确认候选,不发送
composition 完成后再次按 Enter发送一次
普通英文输入后按 Enter发送一次
Shift + Enter插入换行,不发送
composition 中按 Shift + Enter交给输入法,不发送
连续快速确认候选不丢字、不重复发送
Agent 正在运行时输入遵守 queue / steer 策略,不被 IME 改写

自动化测试可以构造 compositionStart、keyDown、compositionEnd 序列,但不要只相信 JSDOM。IME 与 WebView 的差异发生在浏览器事件层,至少还需要一轮 macOS Safari / WKWebView、Chrome,以及 Windows 中文输入法的真机测试。

下面是一个简化的测试意图:

fireEvent.compositionStart(textarea);
fireEvent.keyDown(textarea, {
  key: "Enter",
  keyCode: 229,
  isComposing: true,
});
fireEvent.compositionEnd(textarea);

expect(sendMessage).not.toHaveBeenCalled();

await waitForCompositionGuardToExpire();
fireEvent.keyDown(textarea, { key: "Enter", keyCode: 13 });

expect(sendMessage).toHaveBeenCalledTimes(1);

更通用的工程判断:快捷键不能脱离输入状态

这次问题不只属于聊天框。搜索框、命令面板、评论框、AI Prompt Composer、表格快捷编辑都可能把 Enter、Escape、方向键当作产品命令,但这些按键在 IME、自动补全、mention、slash command 和 accessibility 输入中还有另一层语义。

我现在会把键盘处理按下面的优先级设计:

IME composition
  → suggestion / mention / command popover
  → editor-local behavior
  → product shortcut
  → global shortcut

越靠前的输入状态,越应该先消费事件。全局快捷键不能因为“捕获到了 Enter”就认为用户要求执行命令。

这也是 Agentic Chat 与普通表单的差别:Composer 一旦误发送,触发的不只是一次 HTTP 请求,而可能是一个拥有文件、终端或外部系统权限的 Agent run。输入确认和任务执行之间必须有可靠边界。

要不要用 / 我的判断框架

值得上: 所有 Enter-to-submit 的 Chat、搜索、评论和命令输入框,都应该至少判断 isComposing;面向 macOS Safari、WKWebView 或 Tauri WebView 的产品,再补 keyCode 229 和 composition 结束保护。

可以再等: 如果产品强制点击按钮发送,或者唯一快捷键是明确的 Cmd/Ctrl + Enter,grace period 的优先级可以降低,但 composition 状态仍应被组件正确维护。

重点关注: 不要把 80ms 当成神奇数字散落在多个组件里。把 IME guard 封装成 hook 或 Composer adapter,集中处理计时器清理、平台差异和测试。未来替换编辑器或 UI primitive 时,也只需要重新验证这一层。

参考资料: