Safari 中文输入法的诡异 Bug:为什么输入 @ 会变成 @@? ## 开头 做 @ 提及功能的时候,测试同学用 Safari 测出了个奇怪的问题

Safari 中文输入法的诡异 Bug:为什么输入 @ 会变成 @@?

开头

做 @ 提及功能的时候,测试同学用 Safari 测出了个奇怪的问题------输入框里按一下 @ 键,结果出现了两个 @@

更诡异的是:

  • Chrome 上正常,只有一个 @
  • Safari 用英文输入法也正常
  • 只有 Safari + 中文输入法会重复

第一反应是"我代码写错了",但看了半天逻辑没毛病啊。难道是浏览器的 Bug?

深入研究才发现,这是 Safari 在处理中文输入法时的特殊行为,涉及到 compositionend 事件和 beforeinput 事件的微妙差异。更有意思的是,这个问题还让我重新理解了一个问题:为什么中文输入需要"合成",而英文直接映射就行?

问题复现

测试场景

代码逻辑很简单:在 keydown 阶段监听 @ 键,插入 @ 字符并弹出用户列表。

typescript 复制代码
// 简化的问题代码
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === '@') {
    e.preventDefault();
    
    // 手动插入 @ 字符
    insertText('@');
    
    // 显示用户列表弹窗
    showMentionPopup();
  }
};

诡异的现象

浏览器 输入法 输入 @ 后的结果
Chrome 中文 @
Chrome 英文 @
Safari 英文 @
Safari 中文 @@

问题稳定复现,但只在一个特定组合下出现:Safari + 中文输入法

深入原因

事件触发顺序对比

先来看看正常情况下的事件流:

Chrome(正常):

ini 复制代码
1. keydown (key='@')
2. preventDefault() 阻止默认行为
3. ✅ 后续 beforeinput、input 事件都不会触发

Safari + 中文输入法(异常):

ini 复制代码
1. keydown (key='@')
2. preventDefault() 在 keydown 阶段生效
3. compositionstart (输入法激活)
4. compositionend (输入法合成完成)
5. ❌ beforeinput 事件竟然还是触发了!(data='@')
6. input 事件触发,字符被插入

关键差异在第 5 步:Safari 在 compositionend 后重新发起了 beforeinput 事件 ,完全无视之前在 keydown 阶段调用的 preventDefault()

为什么会有两个 @?

sequenceDiagram participant User as 用户 participant IME as 中文输入法 participant Safari as Safari 浏览器 participant Handler as 事件处理器 User->>IME: 按下 @ 键 IME->>Safari: keydown event Safari->>Handler: 触发 handleKeyDown rect rgb(255, 200, 200) Note over Handler: ❌ 第一次插入 Handler->>Handler: preventDefault() Handler->>Handler: insertText('@') Note right of Handler: segments = ['@'] end Note over IME,Safari: Safari 的特殊行为 IME->>Safari: compositionstart Safari->>Safari: 输入法激活 IME->>Safari: compositionend rect rgb(255, 200, 200) Note over Safari: ❌ Safari 忽略了之前的 preventDefault Safari->>Handler: beforeinput (data='@') Handler->>Handler: insertText('@') Note right of Handler: segments = ['@', '@'] end

双重插入的本质

  1. keydown 阶段:我们手动插入了第一个 @
  2. compositionend 后:Safari 认为"输入法合成完成了,该插入字符了",重新触发 beforeinput,又插入了第二个 @

为什么只有中文输入法有问题?

这就要从输入法的原理说起了。

英文输入(直接映射)

arduino 复制代码
按键 'A' → 字符 'A'(一对一)
按键 '@' → 字符 '@'(一对一)
  • 英文字母只有 26 个 + 数字 10 个 + 符号几十个
  • 标准键盘有 104 个按键
  • 键盘够用,所以可以直接映射
  • 不需要输入法参与

中文输入(需要合成)

arduino 复制代码
按键 'n' 'i' 'h' 'a' 'o' → 需要输入法处理 → '你好'
  • 常用汉字 3500+ 个
  • 标准键盘只有 104 个按键
  • 键盘远远不够
  • 必须通过输入法合成:多个按键 → 一个汉字

那为什么 @ 也要合成?

理论上 @ 这种单字符完全可以直接映射,不需要走输入法。但实际中文输入法的实现是这样的:

typescript 复制代码
// 中文输入法的实现逻辑(简化)
class ChineseIME {
  isActive = true;  // 输入法始终激活
  
  onKeyPress(key) {
    // ❌ 为了统一处理,所有按键都启动合成
    this.startComposition();
    
    if (this.needsCandidates(key)) {
      // 字母:显示拼音候选
      this.showCandidates();
    } else {
      // 标点:立即结束合成
      this.endComposition(key);
    }
  }
}

为什么要这样设计?

  1. 状态机简化 - 统一处理比特殊判断简单
  2. 标点歧义 - 某些标点有全角/半角之分(, vs
  3. 词库扩展 - 现代输入法把符号也加入候选(输入 haha 可能出现 😄)
  4. 历史包袱 - 早期输入法这样设计,沿用至今

所以即使是 @,中文输入法也会走完整的 compositionstartcompositionend 流程。

Safari 为什么特殊?

Chrome 的逻辑

typescript 复制代码
// Chrome 记住了 preventDefault 标记
keydown.preventDefault()
  → 标记该按键"已阻止"
  → compositionend 后检查标记
  → 发现"已阻止"
  → 不触发 beforeinput ✅

Safari 的逻辑

typescript 复制代码
// Safari 把 composition 当作独立流程
keydown.preventDefault()
  → 只影响 keydown 自己
  → compositionend 后
  → 重新评估"是否该插入字符"
  → 触发 beforeinput ❌

本质是 Safari 将 composition 流程视为独立的输入事件链 ,不继承 keydown 阶段的 preventDefault() 状态。

修复方案

核心思路

问题根源 :在 keydown 阶段手动插入字符,与 Safari 后续的 beforeinput 冲突。

解决方法 :不在 keydown 插入字符,统一在 beforeinput 阶段处理。

修复前的代码

typescript 复制代码
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === '@' && supportMention) {
    e.preventDefault();
    
    // ❌ 问题:在 keydown 阶段手动插入
    handleSegmentChange({
      changeType: ESegmentChangeType.Add,
      changeInfo: {
        start, end,
        contentType: InputSegmentType.Text,
        content: '@',  // ← 第一次插入
      },
    });
    
    // 显示弹窗
    setShowMentionList(true);
    inputRef.current?.blur();  // ❌ 失焦,无法继续输入
  }
};

// ❌ Safari 会在 compositionend 后再次触发
const handleBeforeInput = (e: React.FormEvent) => {
  const inputEvent = e.nativeEvent as InputEvent;
  
  if (inputEvent.data) {
    handleSegmentChange({
      changeType: ESegmentChangeType.Add,
      changeInfo: {
        content: inputEvent.data,  // ← 第二次插入 '@'
      },
    });
  }
};

修复后的代码

typescript 复制代码
const mentionTriggerIndexRef = useRef<number | null>(null);

const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === '@' && supportMention) {
    e.preventDefault();
    
    // ✅ 改进:只记录位置,不插入字符
    const { start } = getCursorRange(inputRef.current);
    mentionTriggerIndexRef.current = start;
    
    // 显示弹窗
    setShowMentionList(true);
    setMentionKeyword('');
    
    // 计算弹窗位置
    const { left, bottom } = getCursorPositionPx(inputRef.current) || {};
    setMentionPosition({ left: 12 + left, bottom });
    
    // ✅ 保持焦点,允许继续输入
    inputRef.current?.focus();
  }
};

// beforeinput 会自然触发,插入 @(只插入一次)
const handleBeforeInput = (e: React.FormEvent) => {
  const inputEvent = e.nativeEvent as InputEvent;
  
  if (inputEvent.data) {
    handleSegmentChange({
      changeType: ESegmentChangeType.Add,
      changeInfo: {
        content: inputEvent.data,  // ✅ 只会插入一次
      },
    });
  }
};

// 在 handleSegmentChange 后更新 mention 追踪
const updateMentionTracking = useCallback(
  (nextRawText: string, cursorPosition: number) => {
    if (!showMentionList) return;
    
    const triggerIndex = mentionTriggerIndexRef.current;
    if (triggerIndex === null) return;
    
    // 提取 @ 后的搜索词
    const keyword = nextRawText.slice(triggerIndex + 1, cursorPosition);
    
    // 包含空格则关闭弹窗
    if (/\s/.test(keyword)) {
      closeMentionList();
      return;
    }
    
    // 更新搜索关键词
    setMentionKeyword(keyword);
  },
  [showMentionList, closeMentionList]
);

关键改动点

改动项 修复前 修复后
字符插入 keydown 手动插入 beforeinput 自然插入
位置记录 isMentioningSaveRangeRef(需要 +1/-1) mentionTriggerIndexRef(直接记录)
焦点管理 blur() 失焦 focus() 保持焦点
状态追踪 在 keydown 阶段设置 在 handleSegmentChange 后更新

修复后的事件流

效果

  • Chrome:beforeinputpreventDefault() 阻止,用户继续输入其他字符
  • Safari:beforeinput 触发,插入 @(只插入一次),然后更新追踪状态

技术要点总结

1. 不要在 keydown 中插入普通字符

typescript 复制代码
// ❌ 错误做法
handleKeyDown = (e) => {
  if (e.key === 'a') {
    e.preventDefault();
    insertText('a');  // ← 可能导致重复插入
  }
};

// ✅ 正确做法
handleKeyDown = (e) => {
  if (e.key === 'a') {
    // 只设置标记,不插入字符
    setShouldDoSomething(true);
  }
};

handleBeforeInput = (e) => {
  // 统一在这里处理字符插入
  insertText(e.data);
};

原则

  • keydown:处理快捷键、特殊按键(Enter、Backspace、Escape)
  • beforeinput:统一处理字符插入
  • compositionstart/end:管理输入法状态

2. preventDefault() 的作用范围

typescript 复制代码
e.preventDefault();  // 只阻止当前事件的默认行为

// 不会阻止的(在 Safari 中文模式下):
// - compositionstart/end
// - beforeinput(composition 后重新发起)
// - input

如果要阻止字符插入,应该在 beforeinput 阶段调用 preventDefault(),而不是 keydown

3. 使用 ref 记录辅助数据

typescript 复制代码
// ✅ 使用 ref(不触发重渲染)
const mentionTriggerIndexRef = useRef<number | null>(null);
mentionTriggerIndexRef.current = start;

// ❌ 不要用 state(会触发重渲染,性能差)
const [mentionTriggerIndex, setMentionTriggerIndex] = useState<number | null>(null);

选择原则

  • useState:需要触发 UI 更新的数据(如搜索关键词、选中索引)
  • useRef:辅助计算的数据(如触发位置、缓存值)

4. 浏览器兼容性测试的重要性

这个 Bug 提醒我们:

  • ✅ 不要假设所有浏览器行为一致
  • ✅ 核心功能必须在 Safari、Chrome、Firefox 中测试
  • ✅ 特别注意 Safari + 中文输入法 这种组合

浏览器事件机制对比

标准输入事件流

flowchart TD A[用户按键] --> B[keydown] B --> C{是否 composing?} C -->|否 英文输入| D[beforeinput] D --> E[input] E --> F[keyup] C -->|是 中文输入| G[compositionstart] G --> H[compositionupdate] H --> I[compositionend] I --> J[beforeinput] J --> K[input] K --> L[keyup] style D fill:#c8e6c9 style J fill:#fff9c4

preventDefault() 在不同浏览器的表现

浏览器 keydown preventDefault() 是否阻止后续 beforeinput?
Chrome ✅ 阻止所有后续事件 ✅ 是
Firefox ✅ 阻止所有后续事件 ✅ 是
Safari(英文) ✅ 阻止所有后续事件 ✅ 是
Safari(中文) ⚠️ 仅阻止 keydown 阶段 ❌ 否(composition 后仍触发)

为什么中文需要合成?一个有趣的对比

字符数量的差异

diff 复制代码
英文字母: 26 个
+ 大写: 26 个
+ 数字: 10 个
+ 符号: ~20 个
= 总共约 80 个字符

标准键盘: ~104 个按键

✅ 键盘够用 → 可以直接映射
makefile 复制代码
常用汉字: 3,500 个
GB2312: 6,763 个
Unicode: 20,000+ 个

标准键盘: ~104 个按键

❌ 键盘远远不够 → 必须用合成方案

不同语言的输入方式

英文(直接映射)

css 复制代码
按键 A → 字符 A
按键 @ → 字符 @

中文拼音(音码)

makefile 复制代码
输入: z h o n g g u o (8 个按键)
显示: 拼音候选 → 中国、钟国、忠国...
选择: 按空格或数字
输出: 中国 (2 个汉字)

日文(两层合成)

makefile 复制代码
输入: a r i g a t o u
第一层: ありがとう (平假名)
转换键: 按空格
第二层: 有難う (汉字+假名)
输出: 有難う

韩文(字母拼合)

makefile 复制代码
输入: ㄱ + ㅏ + ㅁ (3 个字母)
拼合: ㄱ → 가 → 감
输出: 감 (1 个音节块)

为什么 @ 也要走合成流程?

这是工程妥协,而非必然设计:

  1. 状态机简化 - 统一处理所有按键,代码更简单
  2. 标点歧义 - 某些标点有全角/半角之分(, vs
  3. 词库扩展 - 现代输入法支持 emoji 和符号候选
  4. 历史兼容 - 早期设计被沿用至今

如果输入法对 @ 特殊处理,会导致:

python 复制代码
用户输入: n i h a o @

如果 @ 直接插入:
  nihao → (合成中)
  @ → (直接插入) ❌ 破坏合成状态!
  用户无法继续输入

统一走合成:
  nihao → (合成中)
  用户确认 → (插入"你好",结束)
  @ → (新合成,立即结束) ✅ 状态一致

总结

研究完这个 Bug,我的理解是:

问题本质

  • Safari 将 composition 流程视为独立事件链
  • compositionend 后重新发起 beforeinput
  • 忽略了之前在 keydown 阶段的 preventDefault()

修复原则

  • ❌ 不在 keydown 中插入普通字符
  • ✅ 在 beforeinput 统一处理字符插入
  • keydown 仅用于特殊按键(Enter、Backspace、Escape)
  • ✅ 使用 ref 记录辅助数据,避免不必要的重渲染

深层收获

  • 理解了为什么中文需要"合成":键盘按键数 << 汉字数量
  • 理解了为什么 @ 也走合成流程:输入法的统一状态管理
  • 意识到浏览器兼容性测试的重要性:Safari + 中文输入法 是个特殊组合

实用建议

  1. 核心功能必须在 Safari 上测试,特别是输入相关的
  2. 不要假设 preventDefault() 能阻止所有后续事件
  3. 事件处理职责分离:keydown 处理特殊键,beforeinput 处理字符插入
  4. 使用 ref 存储不需要触发渲染的辅助数据

下次遇到类似的输入问题,你会知道:

  • 先看事件触发顺序(打印日志)
  • 检查是否在 keydown 阶段插入了字符
  • 在 Safari + 中文输入法下测试
  • composition 流程可能带来意外的事件触发

如果你的项目也有 @ 提及、# 话题这类功能,建议现在就去 Safari 上测一测。数据显示,Safari 在中国的市场份额约 20%(主要是 iOS 用户),别让这 20% 的用户遇到诡异的重复输入问题。

参考资料

W3C 标准文档

  1. UI Events Specification - Composition Events - composition 事件规范
  2. Input Events Level 2 - beforeinput event - beforeinput 事件规范

MDN 文档

  1. CompositionEvent - composition 事件详解
  2. InputEvent - input 事件详解
  3. Event.preventDefault() - preventDefault 的作用范围

浏览器差异

  1. WebKit Bugzilla - Safari 已知问题
  2. Chromium Issue Tracker - Chrome 事件处理实现

相关文章

  1. IME (Input Method Editor) 原理 - 输入法编辑器工作原理
相关推荐
没有故事、有酒4 小时前
Ajax介绍
前端·ajax·okhttp
朝新_4 小时前
【SpringMVC】详解用户登录前后端交互流程:AJAX 异步通信与 Session 机制实战
前端·笔记·spring·ajax·交互·javaee
裴嘉靖4 小时前
Vue 生成 PDF 完整教程
前端·vue.js·pdf
毕设小屋vx ylw2824264 小时前
Java开发、Java Web应用、前端技术及Vue项目
java·前端·vue.js
冴羽5 小时前
今日苹果 App Store 前端源码泄露,赶紧 fork 一份看看
前端·javascript·typescript
蒜香拿铁5 小时前
Angular【router路由】
前端·javascript·angular.js
brzhang6 小时前
读懂 MiniMax Agent 的设计逻辑,然后我复刻了一个MiniMax Agent
前端·后端·架构
西洼工作室6 小时前
高效管理搜索历史:Vue持久化实践
前端·javascript·vue.js
广州华水科技6 小时前
北斗形变监测传感器在水库安全中的应用及技术优势分析
前端