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) 原理 - 输入法编辑器工作原理
相关推荐
Demon--hx1 天前
[C++]const成员
前端·javascript·c++
良木林1 天前
Node.js基础:模块化与包
开发语言·前端·node.js
z***67771 天前
SpringBoot(7)-Swagger
android·前端·后端
Jackson@ML1 天前
用ASP.NET创建一个Blazer Web应用程序
前端·asp.net·blazor
shepherd1261 天前
Vue-Vben-Admin 从入门到实战:后端开发的前端探索之旅
前端·javascript·vue.js
CircleMouse1 天前
介绍几个axios接口请求顺序的问题
开发语言·前端·javascript·ecmascript
o***Z4481 天前
React自然语言
前端·react.js·前端框架
J***Q2921 天前
React部署方案详解
前端·react.js·前端框架
q***R3081 天前
React组件性能分析
前端·react.js·前端框架
5***79001 天前
React趋势
前端·react.js·前端框架