IME 安全的 Enter 键提交

Web 功能: 键盘事件

许多聊天界面会在用户在 <textarea> 中按 Enter 时提交消息。

这适用于使用拉丁键盘直接输入(例如英语)的用户,但对于使用输入法 (IME) 输入日语、中文或韩语等语言的用户,则无法正常工作。

在这些情况下,Enter/Return 键用于确认当前的字符转换候选字词。如果自定义 JavaScript 监听 Enter 上的 keydown 并立即提交,则用户的消息会在他们仍在转换字符时发送,从而导致消息不完整、不连贯或不正确。

组合活跃检查适用于对 Enter 执行操作的任何自定义 keydown 处理程序,而不仅仅是多行 <textarea> 字段。具有自己的 Enter 处理方式的单行 <input> 字段(例如搜索栏和查找栏、内嵌重命名字段、模态对话框字段和命令面板)会收到与 <textarea> 相同的 IME 确认 Enter 按键,其中 isComposing 设置为 true。从没有自定义 Enter 处理程序的 <input> 中原生隐式提交 <form> 不需要显式处理,因为当 IME 消耗 Enter 按键时,浏览器会抑制隐式提交。

实施策略

对于具有自定义“按 Enter 键提交”功能的 <textarea> 或 <input>,请在提交内容之前检查 KeyboardEvent 的原生 isComposing 属性。此外,还要调用 event.preventDefault():Enter 的默认操作是在 <textarea> 中插入换行符,而在 <form> 内的 <input> 中,它会触发浏览器的隐式提交,从而导致第二次提交。

<form id="chat-form">
  <label for="chat-input" class="visually-hidden">Message</label>
  <textarea id="chat-input" placeholder="Type a message..."></textarea>
  <button type="submit" id="send-button">Send</button>
</form>
const textarea = document.getElementById('chat-input');
const form = document.getElementById('chat-form');

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    // Prevent the default action (newline in a textarea, implicit form submission in an input)
    event.preventDefault();

    // If the user is composing text, return early.
    if (event.isComposing) {
      return;
    }

    form.requestSubmit();
  }
});

对于单行 <input>,请使用不含 !event.shiftKey 条件的同一处理程序,该条件仅在 Shift+Enter 在 <textarea> 中插入换行符时存在。

无障碍功能和测试

  1. 显式提交按钮:始终包含 <button type="submit"> 元素。键盘快捷键是辅助工具,不得取代原生表单提交路径。
  2. 无障碍输入:确保所有 <input> 和 <textarea> 元素都使用匹配的 id 和 for 属性以编程方式与 <label> 相关联。

后备策略

在最近的 Safari 版本中,事件排序问题(WebKit bug 165004)导致 compositionend 在确认 Enter keydown 之前被传递给脚本处理程序,即使底层事件是以相反的顺序分派的。在 keydown 处理程序运行之前,event.isComposing 已重置为 false,这意味着仅凭标准检查无法阻止过早提交。

如果您需要支持旧版 Safari 和其他平台之间的跨浏览器兼容性,请采用以下后备策略之一:

通过将 event.isComposing 与 event.keyCode === 229 检查配对,您可以可靠地捕获 Safari 的无序确认 Enter keydown。由于此代码嵌套在 event.key === 'Enter' 门控下,因此可以安全地避免移动虚拟键盘可能使用 229 进行常规字符布局输入。

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();

    // Block submission if composing natively or if keyCode is 229
    if (event.isComposing || event.keyCode === 229) {
      return;
    }

    form.requestSubmit();
  }
});

策略 2:event.timeStamp 窗口解决方法(替代方案)

对于严格禁止使用已废弃 API(如 keyCode)的代码库,或者在特定目标环境中 229 检查存在已知问题,您可以改为跟踪浏览器报告的事件分派时间戳。

由于 Safari 派发确认 Enter keydown 事件的时间与 compositionend 事件的时间非常接近(通常在 5 毫秒内,有时由于处理程序传递顺序颠倒,keydown 时间戳会略微提前),因此检查时间差非常可靠,在标准条件下不太可能在移动虚拟键盘上触发误报。

let lastCompositionEndAt = null;

textarea.addEventListener('compositionend', (event) => {
  // IMPORTANT: Always use event.timeStamp, not Date.now() or performance.now().
  // Handler-time measurements are vulnerable to drift when the main thread is blocked.
  lastCompositionEndAt = event.timeStamp;
});

textarea.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();

    if (event.isComposing) {
      return;
    }

    // Block submission if the event occurs within a 50ms window of composition ending.
    // Math.abs handles Safari's inverted event delivery timing bug.
    if (
      lastCompositionEndAt !== null &&
      Math.abs(event.timeStamp - lastCompositionEndAt) < 50
    ) {
      return;
    }

    form.requestSubmit();
  }
});