两个彩色圆点,能让一个协同编辑器停止保存。

不是夸张。开发者 George Mandis 复盘了一个罕见但很吓人的 bug:用户在富文本编辑器里把 🟢 和 🔴 这类 emoji 放在相邻位置,再做一次特定插入或替换,编辑器表面还在工作,本地还能继续打字,但内容不再同步到 Yjs 文档。

下次打开页面,故障点之后的编辑可能就没了。

这类 bug 最阴险的地方不在“炸”。它不白屏,不报给用户,不阻止输入。用户继续写,系统已经不再保存。

两个 emoji 怎么把同步链路切断

这套编辑器的上层是 TipTap,底层是 ProseMirror;协同同步靠 Yjs,Yjs 又依赖工具库 lib0。

问题出在一个很窄的缝里:lib0 的 splice 内部用了 JavaScript 的 slice。某些高位 emoji 在 UTF-16 里需要 surrogate pair 表示。一旦 splice 的切点落在代理对中间,一个 emoji 就会被切成“半个字符”。

故障链路很短:

环节发生了什么结果
输入相邻放置特定高位 emoji字符串里出现 surrogate pair
编辑CRDT splice 落在代理对中间产生孤立 surrogate
编码孤立 surrogate 进入 encodeURIComponent抛出 URIError: URI malformed
错误处理异常未被捕获同步停止,本地 UI 仍可编辑

这也是它难查的原因。它不像网络断连,也不像 WebSocket 抖动。UI 没死,编辑器没崩,用户没有得到明确提醒。

触发条件也不宽。需要特定 emoji、特定相邻关系、特定编辑位置同时出现。它不是普遍性灾难,但后果足够坏:一旦踩中,用户以为自己在写,系统却已经断流。

最相关的受影响对象很明确。

前端工程师要检查所有把 str[0]slice(0, 1)substring 当“取一个字符”的地方。头像 initials、字符串截断、富文本装饰、mention、emoji 处理,都在范围内。

协同编辑产品开发者更该紧张。因为协同产品卖的不是“能输入”,而是“输入会被可靠保存,并被别人看见”。本地可编辑、远端已停止,是信任层面的事故。

UTF-16 的旧账,今天还在收利息

这里不能简单说“emoji 是多字节字符”。这个说法太粗,会把真正的坑盖住。

JavaScript 字符串默认按 UTF-16 的 code unit 工作。.length 数的是 code unit,.slice() 切的也是 code unit。

但 Unicode 里的一个字符可能是 code point。用户眼里的一个“字”,还可能是 grapheme cluster。

内容Code unitsCode pointsGraphemes
A111
🤠211
👩‍🚀531
👨‍👨‍👧‍👧1171

很多老代码默认“一个字符就是一个索引位置”。在英文、数字、常见符号里,这个幻觉通常成立。到了 emoji、组合字符、零宽连接符,它就露馅。

这像早期铁路的轨距问题。不完全一样,但结构相似:本地系统跑得好好的,一跨边界,就要换规则。

今天的软件栈也是这样。编辑器有自己的字符观,CRDT 有自己的字符串操作,JavaScript 背着 UTF-16 遗产,URI 编码又有合法性要求。平时互相兼容,一到边界就翻脸。

“积羽沉舟”。不是一个 emoji 有多重,而是很多层抽象都假装自己没重量。

真正要改的,不只是那一行 slice

lib0 后来打了补丁:如果 slice 后发现开头是没有配对的 high surrogate,就用 Unicode replacement character 替换,避免 URIError 继续炸掉同步。

产品侧的临时兜底也很现实:加离线支持,让本地 CRDT 先留住改动;再挂全局 error 监听,捕获 URIError: URI malformed 后弹窗提醒用户刷新。

更靠近产品语义的修法,是把 emoji 在 ProseMirror / TipTap 里做成 atomic node,让编辑器把它当成不可拆的单元。现代浏览器里,如果要按用户感知字符切分,也可以考虑 Intl.Segmenter,按 grapheme 粒度处理。

但这里有边界。Intl.Segmenter 适合解决“用户眼里的一个字符怎么切”。它不是所有底层编码问题的万能药,也不能替代同步层的错误处理。

我更在意的是这件事暴露出的工程习惯:我们长期把“字符”这个概念外包给默认 API。默认 API 又背着几十年前的编码债务。

于是产品层看见的是“用户输入一个符号”。底层处理的却是 code unit、code point、grapheme cluster 三套秩序在抢方向盘。

这不说明 JavaScript 全面不可靠,也不说明 Yjs 不可靠。判断要收住。问题是特定字符串操作、特定数据结构、特定未捕获错误,刚好排成了一条事故链。

但这条链很有代表性。

对前端工程师,接下来该做的不是“禁止 emoji”,而是查清三个点:哪里按索引切字符串,哪里把用户感知字符当 code unit,哪里存在未捕获的编码异常。

对协同编辑产品团队,采购或自研评估也不该只看编辑体验。要看异常时的行为:同步失败有没有可见提示,本地改动有没有离线兜底,恢复后有没有冲突处理。

接下来最该观察的也不是“还会不会有 emoji bug”。而是补丁是否进入依赖链,产品是否升级,错误监控里是否能抓到类似 URIError,以及同步失败时用户能不能被及时告知。

回到那两个圆点。它们没有打败编辑器。

打败编辑器的是一串看似合理的默认选择:默认 slice,默认编码,默认异常会有人处理,默认用户还在写就代表系统还在保存。

软件最危险的缝,往往不在崩溃处。崩溃至少诚实。静默失败才会让用户替系统结账。