隐形的崩溃:无效代理对如何破坏现代编辑器
在软件工程的世界里,有些 bug 是微不足道的,而另一些则会成为传奇。最可怕的是那些无声的失败——系统看起来运行完美,但数据却消失在虚无之中。对于一个使用 TipTap、ProseMirror 和 Yjs 构建协作编辑器的团队来说,当用户报告他们的编辑内容根本无法保存时,这场噩梦变成了现实。
没有控制台错误,没有堆栈跟踪,也没有崩溃。编辑器保持响应,但向 Yjs 文档的同步却停止了。这是一个关于存在于人类感知字符的方式与 JavaScript 存储字符的方式之间的缝隙中的 bug 的故事。
无声失败的解剖
这个 bug 非常罕见,几乎无法复现,直到一位产品经理发现了一个特定的模式:在两个多字节 emoji 之间插入一个字符(具体是 🟢 和 🔴)。这个特定操作触发了底层 CRDT (Conflict-free Replicated Data Type) 库中的一次 "splice",从而将一个代理对从中间切断了。
当代理对被切断时,产生的碎片不再是有效的字符。虽然它们在浏览器中可能会渲染为替换字符 (),但当传递给某些 JavaScript 函数时,它们会导致灾难性的失败。在这种情况下,孤立的代理被传递给了 encodeURIComponent,从而抛出了 URIError: URI malformed。
由于这个错误在 Yjs 或 TipTap 同步循环中未被捕获,整个同步过程就死掉了。用户继续在本地输入,却不知道他们的更改不再被传输到服务器。
理解 Unicode 层级结构
要理解为什么一个简单的 .slice() 会导致应用程序崩溃,必须理解现代计算中字符串表示的三个层级:
1. 代码单元 (Code Units)
JavaScript 内部使用 UTF-16。代码单元是原始的 16 位值。这是 .length 统计的对象,也是 .slice() 操作的对象。对于标准 ASCII,一个代码单元等于一个字符。对于 emoji,情况很少如此。
2. 码点 (Code Points)
码点是 Unicode 标准定义的实际原子单位。高于 U+FFFF 的字符(包括大多数 emoji)对于单个 16 位代码单元来说太大了。UTF-16 通过使用代理对 (surrogate pair) 来处理这个问题:一个高代理和一低代理。如果你恰好在两者之间切断字符串,你就会创建一个“无效代理”,这是一个没有伴侣就无法存在的代码单元。
3. 字形簇 (Grapheme Clusters)
字形簇是人类感知的单个字符。有些 emoji 实际上是多个码点的组合。例如,女性宇航员 (👩🚀) 由一个女性 emoji、一个零宽连字符和一个火箭 emoji 组成。这一个字形包含三个码点和五个代码单元。
| 字符 | 代码单元 | 码点 | 字形 |
|---|---|---|---|
| A | 1 | 1 | 1 |
| 🤠 | 2 | 1 | 1 |
| 👩🚀 | 5 | 3 | 1 |
| 👨👨👧👧 | 11 | 7 | 1 |
解决路径
修复存在于上游依赖项(在这种情况下是 lib0)中的 bug 需要多层级的策略。团队实施了多层防御:
即时对冲: 团队启用了离线支持。虽然这不是主要的修复方案,但这确保了如果同步失败,CRDT 会继续在本地更新,从而在连接恢复或页面重新加载后允许潜在的合并。
核武器选项: 添加了一个全局的 window.addEventListener("error", ...) 监听器来捕获 URIError: URI malformed。当检测到时,应用会触发一个模态框,要求用户重新加载,从而防止数据的无声丢失。
结构化修复: 在 ProseMirror 中,团队将 emoji 定义为“原子节点类型”。通过将 emoji 视为不可分割的单元,编辑器防止了光标进入代理对内部,从而消除了触发 splice bug 的诱因。
上游补丁: 最终,lib0 得到了补丁,用于检测孤立的代理并将其替换为 Unicode 替换字符 (U+FFFD),从而防止抛出 URIError。
现代开发中的教训
这个 bug 凸显了一个基本事实:任何假设 str[0] 或 str.slice(0, 1) 返回完整字符的代码都可能存在问题。这在从姓名中生成用户首字母的工具中很常见;如果用户的名字以 emoji 开始,生成的“首字母”往往是半个代理对,从而导致渲染故障或崩溃。
现代解决方案:Intl.Segmenter
对于在 JavaScript 中进行字符串操作的开发者来说,标准的 .slice() 通常是错误的工具。现代的答案是 Intl.Segmenter,它允许按字形簇切分字符串:
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = [...seg.segment("👩🚀A👍")].map((s) => s.segment);
// 结果: ['👩🚀', 'A', '👍']
社区观点
围绕这个 bug 的讨论揭示了语言处理文本时更深层次的紧张关系。一些开发者认为 UTF-16 是一个“非强制性错误”,且字符串应该使用固定大小的 64 位值。其他人则指出,无论使用何种编码,字形簇的复杂性都是不可避免的的。
此外,环境因素也可能引入自身的风险。正如一位贡献者所指出的,像 LANG 这样的服务器端环境变量可能会改变 Unicode 的处理方式,从而导致仅在生产环境中出现且在开发者通过 SSH 进入服务器进行调查时便消失的 bug。
最终,代理对 bug 提醒我们,“字符串”这一抽象概念远比它看起来要更“漏水”。在处理文本时,特别是在协作或国际化环境中,唯一的安全路径是将字符串视为字形簇序列,而不是字符数组。