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()。
为什么会有两个 @?
双重插入的本质:
keydown阶段:我们手动插入了第一个@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);
}
}
}
为什么要这样设计?
- 状态机简化 - 统一处理比特殊判断简单
- 标点歧义 - 某些标点有全角/半角之分(
,vs,) - 词库扩展 - 现代输入法把符号也加入候选(输入
haha可能出现 😄) - 历史包袱 - 早期输入法这样设计,沿用至今
所以即使是 @,中文输入法也会走完整的 compositionstart → compositionend 流程。
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:
beforeinput被preventDefault()阻止,用户继续输入其他字符 - 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 + 中文输入法 这种组合
浏览器事件机制对比
标准输入事件流
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 个音节块)
为什么 @ 也要走合成流程?
这是工程妥协,而非必然设计:
- 状态机简化 - 统一处理所有按键,代码更简单
- 标点歧义 - 某些标点有全角/半角之分(
,vs,) - 词库扩展 - 现代输入法支持 emoji 和符号候选
- 历史兼容 - 早期设计被沿用至今
如果输入法对 @ 特殊处理,会导致:
python
用户输入: n i h a o @
如果 @ 直接插入:
nihao → (合成中)
@ → (直接插入) ❌ 破坏合成状态!
用户无法继续输入
统一走合成:
nihao → (合成中)
用户确认 → (插入"你好",结束)
@ → (新合成,立即结束) ✅ 状态一致
总结
研究完这个 Bug,我的理解是:
问题本质:
- Safari 将 composition 流程视为独立事件链
compositionend后重新发起beforeinput- 忽略了之前在
keydown阶段的preventDefault()
修复原则:
- ❌ 不在
keydown中插入普通字符 - ✅ 在
beforeinput统一处理字符插入 - ✅
keydown仅用于特殊按键(Enter、Backspace、Escape) - ✅ 使用
ref记录辅助数据,避免不必要的重渲染
深层收获:
- 理解了为什么中文需要"合成":键盘按键数 << 汉字数量
- 理解了为什么
@也走合成流程:输入法的统一状态管理 - 意识到浏览器兼容性测试的重要性:Safari + 中文输入法 是个特殊组合
实用建议:
- 核心功能必须在 Safari 上测试,特别是输入相关的
- 不要假设 preventDefault() 能阻止所有后续事件
- 事件处理职责分离:keydown 处理特殊键,beforeinput 处理字符插入
- 使用 ref 存储不需要触发渲染的辅助数据
下次遇到类似的输入问题,你会知道:
- 先看事件触发顺序(打印日志)
- 检查是否在 keydown 阶段插入了字符
- 在 Safari + 中文输入法下测试
- composition 流程可能带来意外的事件触发
如果你的项目也有 @ 提及、# 话题这类功能,建议现在就去 Safari 上测一测。数据显示,Safari 在中国的市场份额约 20%(主要是 iOS 用户),别让这 20% 的用户遇到诡异的重复输入问题。
参考资料
W3C 标准文档
- UI Events Specification - Composition Events - composition 事件规范
- Input Events Level 2 - beforeinput event - beforeinput 事件规范
MDN 文档
- CompositionEvent - composition 事件详解
- InputEvent - input 事件详解
- Event.preventDefault() - preventDefault 的作用范围
浏览器差异
- WebKit Bugzilla - Safari 已知问题
- Chromium Issue Tracker - Chrome 事件处理实现
相关文章
- IME (Input Method Editor) 原理 - 输入法编辑器工作原理