無形的崩潰:無效代理對如何破壞現代編輯器

在軟體工程的世界中,有些錯誤是微不足道的,而有些則會成為傳奇。最可怕的是那些無聲的失敗——系統看起來運作完美,但數據卻消失在虛空中。對於一個使用 TipTap、ProseMirror 和 Yjs 構建協作編輯器的團隊來說,當用戶回報他們的編輯內容根本無法儲存時,這場噩夢變成了現實。

沒有控制台錯誤、沒有堆疊追蹤,也沒有崩潰。編輯器保持響應,但與 Yjs 文檔的同步卻停止了。這是一個關於存在於人類感知字符與 JavaScript 儲存字符之間差距的錯誤的故事。

無聲失敗的解剖

這個錯誤非常罕見,幾乎無法重現,直到一位產品經理發現了一個特定的模式:在兩個多位元組的表情符號(特別是 🟢 和 🔴)之間插入一個字符。這個特定操作觸發了底層 CRDT (Conflict-free Replicated Data Type) 函式庫中的「splice」操作,將一個代理對(surrogate pair)從中間切斷了。

當代理對被切斷時,產生的碎片不再是有效的字符。雖然它們在瀏覽器中可能會渲染為替換字符 (),但當傳遞給某些 JavaScript 函數時,會導致災難性的失敗。在這種情況下,孤立的代理對被傳遞給了 encodeURIComponent,這導致拋出了 URIError: URI malformed

因為這個錯誤在 Yjs 或 TipTap 的同步循環中未被捕獲,整個同步過程就停止了。用戶繼續在本地端進行輸入,卻不知道他們的更改不再被傳輸到伺服器。

理解 Unicode 層級結構

要理解為什麼簡單的 .slice() 會導致應用程式崩潰,必須理解現代計算中字串表示法的三個層次:

1. 碼元 (Code Units)

JavaScript 在內部使用 UTF-16。碼元是原始的 16 位元值。這是 .length 計算的對象,也是 .slice() 操作的對象。對於標準 ASCII,一個碼元等於一個字符。對於表情符號,情況很少如此。

2. 碼點 (Code Points)

碼點是 Unicode 標準定義的實際原子單位。高於 U+FFFF 的字符(包括大多數表情符號)對於單個 16 位元碼元來說太大了。UTF-16 透過使用代理對(surrogate pair)來處理此問題:一個高代理 (high surrogate) 和一個低代理 (low surrogate)。如果你在兩者之間精確地切斷字串,你就會創建一個「無效代理」,這是一個沒有夥伴的碼元。

3. 字形集群 (Grapheme Clusters)

字形集群是人類感知的單個字符。有些表情符號實際上是多個碼點的組合。例如,女性太空人 (👩‍🚀) 由一個女性表情符號、一個零寬連接符 (zero-width joiner) 和一個火箭表情符號組成。這是一個字形,三個碼點,以及五個碼元。

字符 碼元 碼點 字形
A 1 1 1
🤠 2 1 1
👩‍🚀 5 3 1
👨‍👨‍👧‍👧 11 7 1

解決路徑

修復存在於上游依賴項(在此案例中為 lib0)中的錯誤需要多層次的策略。團隊實施了幾層防禦:

立即緩衝: 團隊啟用了離線支援。雖然這不是主要的修復方案,但這確保了如果同步失敗,CRDT 會繼續在本地端更新,從而允許在連線恢復或頁面重新整理後進行潛在的合併。

核彈選項: 添加了一個全域的 window.addEventListener("error", ...) 監聽器來捕獲 URIError: URI malformed。當偵測到時,應用程式會觸發一個彈窗,要求用戶重新整理,以防止數據的無聲損失。

結構性修復: 在 ProseMirror 中,團隊將表情符號定義為「原子節點類型 (atomic node type)」。透過將表情符號視為不可分割的單元,編輯器防止了游標進入代理對內部,從而消除了觸發 splice bug 的誘因。

上游補丁: 最終,lib0 被修復了,使其能夠偵測孤立的代理對並將其替換為 Unicode 替換字符 (U+FFFD),從而防止拋出 URIError

給現代開發的教訓

這個錯誤凸顯了一個根本的真理:任何假設 str[0]str.slice(0, 1) 會返回完整字符的代碼都可能存在問題。這在生成用戶姓名縮寫的工具中很常見;如果用戶的名字以表情符號開頭,產生的「縮寫」往往是半個代理對,導致渲染錯誤或崩潰。

現代解決方案:Intl.Segmenter

對於在 JavaScript 中進行字串操作的開發者來說,標準的 .slice() 通常不是正確的工具。現代的答案是 Intl.Segmenter,它允許按字形集群來分割字串:

const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const segments = [...seg.segment("👩‍🚀A👍")].map((s) => s.segment);
// Result: ['👩‍🚀', 'A', '👍']

社群觀點

圍繞這個錯誤的討論揭示了語言處理文本的深層矛盾。一些開發者認為 UTF-16 是個「非強制性錯誤」,字串應該要是固定大小的 64 位元值。其他人則指出,無論編碼方式如何,字形集群的複雜性都是不可避免的。

此外,環境本身也可能引入風險。正如一位貢獻者所指出的,伺服器端環境變數(如 LANG)可能會改變 Unicode 的處理方式,導致錯誤僅在生產環境中出現,並在開發者透過 SSH 進入伺服器調查時立即消失。

最終,代理對錯誤提醒了我們,「字串」的抽象概念比看起來要脆弱得多。在處理文本時,特別是在協作或國際化環境中,唯一的安全路徑是將字串視為字形序列,而非字符陣列。

Sources