@laigary.com~/posts/ten-years-of-buildin….md$
$ cd ..
$ cat ./posts/ten-years-of-building-web-editors.md
---
title:   "開發網頁編輯器的十年筆記"
date:    2026-08-05
reading: 80 min
tags:    []
---

開發網頁編輯器的十年筆記

難的從來不是文字那一層

先講結論好了:目前我自己搭建出了了一個我還滿意的編輯器,過去這十年的經驗慢慢帶我到這裡來的,我會記錄這十年之間,我對於很多事情的取捨。

很多東西其實不是我一開始就想清楚的東西,是一路撞了幾次牆之後才慢慢長出來的。

最早我做自己的網站、也幫別人做網站,用 Rails。處理文字這層一直都很順 —— 一個 textarea、一個 parser,Rails 那套 MVC 對「一段文字進資料庫再吐出來」處理得非常好。

會變複雜的,是文字以外的東西。

第一件事:互動元件。 只要需求變成「我想在文章中間放圖」,事情就不一樣了。編輯器負責產生 <img src="...">,但 src 從哪來是你的事。於是你要做上傳端點、處理暫存、處理刪文之後的孤兒檔案,還要讓那個 JavaScript 編輯器知道你的後端長什麼樣。文字很好處理,圖片就沒那麼單純。

第二件事:換編輯器的時候。 這件事教我最多。換一套編輯器,舊內容不一定能直接用 —— 每一套編輯器吐出來的 HTML 都有自己的習慣:自己的 class、自己的 wrapper、自己的 <div> 結構。舊文章在新編輯器裡打開,版面就跑掉了。而新編輯器又帶著自己的一整包 CSS,跟前台的 style 互相覆蓋,你得一條一條去對哪個選擇器蓋掉了哪個。

第三件事:編輯器停止維護的時候。 這件事很難避免。套件停更、不再支援新版的 Ruby 或 Rails、或是它依賴的 plugin 先停了。你就卡在一個舊版本上;而要升級 Rails 的那天,整個專案裡最難處理的,往往就是那個已經沒人維護的編輯器 gem。

三件事加起來,讓我看清楚一件事:真正的問題不是編輯器好不好用,而是內容跟工具綁在一起。

所以我慢慢除了注意編輯器的體驗,也很重視檔案格式的選擇,最後我收斂到存檔格式是 Markdown 的方向走。不是因為我特別喜歡 Markdown 的語法,而是因為它同時滿足三件我很在意的事。

一、它活得比編輯器久。 這是最主要的理由。Markdown 是純文字,可以 grep、可以 diff、可以用任何一個文字編輯器打開。它不需要當年產生它的那包 JavaScript 還在維護 —— 而前面三件事教我的,正是這個「還在維護」有多不可靠。

二、搬遷成本低。 換框架、換資料庫、換部署平台,內容那一欄基本上原封不動搬過去就好。這十年我換過的東西比我想像的多,唯一沒被換掉的就是那些純文字。

三、對 LLM 友善 —— 這是我這兩年開始才意識到的。 現在讀我文章的不只有人,還有各種模型和 agent。給它們一份乾淨的 Markdown,遠比讓它們去 parse 一頁充滿 wrapper div 和樣式 class 的 HTML 要準確。

第三點我後來直接做成了功能:這篇文章的網址後面加上 .md,就會回傳它的 Markdown 原始碼,前面帶一段 YAML front matter,把只存在於頁面裝飾裡的資訊(標題、canonical URL、日期、標籤)補回去。你現在就可以試:

https://laigary.com/posts/ten-years-of-building-web-editors.md

這個功能只花了一個下午,而它能成立的唯一原因,就是我的內容本來就是 Markdown。如果當初存的是編輯器產生的 HTML,這件事要做就得先寫一個 HTML→Markdown 的轉換器,而那種轉換永遠是有損的。


但選了 Markdown,是把難題從資料層搬到 UX 層

存檔格式這關過了,接下來全部是新問題,而且沒有一個有標準答案:

Markdown editing 和 WYSIWYG editing 要怎麼共存? 同一份內容有兩種編輯方式。切換的時候游標在哪?還沒存檔的狀態怎麼辦?可是只給一種,另一種的使用者就會不太順手 —— 而那兩種使用者,常常是同一個人在不同時刻。

編輯器上到底要顯示什麼?

  • 顯示 render 後的結果?對於閱讀友善,但是就要去考慮 markdown 到底支不支援?會不會需要有 embedded HTML 的情況。
  • 顯示純 Markdown?對於編輯友善,但是如果我想要編輯 Table 就會崩潰。
  • 還是顯示 render 結果但要能看得到 source —— 那 source 要在哪裡看,整份切換、分割畫面、還是只看游標所在的那個區塊?

要支援哪一種 flavor? CommonMark?GFM 的表格和刪除線?數學要不要?那是 $...$ 還是 \(...\)?這個決定不只是編輯器的事 —— 編輯器 parse 的語法,必須跟前台 render 的語法是同一套,不然你會在發布之後才發現它們對同一段文字有不同意見。(這件事後來真的發生了,見坑 1。)

圖片上傳怎麼支援? Markdown 的語法只有 ![](url),它對「這個 url 是怎麼來的」完全沒有意見。上傳中要顯示什麼?上傳失敗要留下什麼?上傳到一半按存檔會存到什麼?

那 embed 呢? 這個是後來才真正咬到我的。我想在文章裡放 YouTube 影片、放一則推文 —— 而 Markdown 對這種東西完全沒有語法。它只有連結。

於是選擇只有三種:把原始 HTML 寫進 Markdown 裡(那你的「純文字」就不再那麼純)、發明一套自己的簡寫語法(那它就不再是標準 Markdown,換工具時第一個壞掉的就是它)、或是把 embed 拉出正文變成獨立欄位(那一篇文章就散在兩個地方)。

三個都有代價,而且這個決定會一路影響到編輯器、渲染器和安全性。後面我會講我怎麼選的。

這五個問題,就是後面那些坑的源頭。

說到底,所見即所得的編輯器天生的資料結構是一棵 document tree(或者是 HTML),而 Markdown 是一種有損的線性語法。中間那層轉換 —— 序列化與反序列化 —— 就是這類專案最需要花心思的地方。

自己做一個 blog,比較費工的其實是這裡,不是版面也不是部署。是「我想用得順手,同時希望資料能活得比工具久」這件事 —— 到 2026 年,它還是需要自己做一些取捨。畢竟如果編輯器不順手,我就不會想要繼續寫文章。


一路上用過的編輯器

大禮包時代

我是寫 Rails 出身的。最早我對於開發編輯器的理解就是:把一整包 JavaScript 丟進 public 資料夾。

Rails 對 rich text 曾經沒有任何意見,所以要編輯器就得自己去外面搬 —— TinyMCECKEditor

「整合」的意思是:把 JS 和 CSS 檔案手動複製到 public/ 底下。沒有套件管理,沒有版本控制,升級就是再複製一次然後祈禱。

我沒有參與到這一段的全盛時期,但我維護過它留下來的東西 —— vendor/assets 底下一包已經沒人記得版本的 CKEditor,和一個大家都不太敢動的 upload controller。

那個年代還有另一半故事,我覺得很有意思:不少 Rails 開發者選擇不用 WYSIWYG,改用 Markdown 或 Textile 的 parser,配一個 AJAX 預覽就好。

也就是說,我今天在想的這件事,十年前就有人想過了。他們選擇保住資料、在體驗上讓一步。我想試的是兩邊都要 —— 而多要的那一半,正好就是難的那一半。

模組化管理

工程師是一群很喜歡秩序的群體,想當然爾上面的方法一定很快有很多人去改善他的弊端。

Rails 3.1 的 Asset Pipeline 讓前端資產可以打包進 gem,社群整合套件於是大爆發:bootstrap-wysihtml5-railswysiwyg-rails。還有 Bootsy —— 把 wysihtml5 跟一套 Rails 後端綁在一起,終於有人把「圖片上傳」包好了。

我大多數開始接的第一批專案就活在這個生態裡。安裝從「複製檔案」變成「加一行 Gemfile」,這是很實在的進步 —— 但編輯器本身沒有變得更好,是變得更好裝。而且中間多了一層:你不只依賴那個 JS 函式庫,還依賴把它包起來的那個 gem 有沒有人繼續維護。

上面那幾個 gem 有些現在還有更新,但是大多已經停在某個版本了,或者是沒有隨著 Ruby or Ruby on Rails 的更新繼續更新了。

Trix (Action Text)

再來是 Trix(Basecamp 出的,後來變成 Rails 的 Action Text)。Trix 是第一個讓我覺得「有人真的想清楚了」的編輯器 —— 它刻意不用 contenteditable 的預設行為,自己管理 document model,所以行為在各瀏覽器之間是一致的。

Trix 的立場也很明確:它的資料格式是 HTML,而且不打算讓你插一個它沒設計過的節點。對 Basecamp 那種「留言框」的場景這很合理,只是跟「寫技術文章」的需求不太一樣。Trix 讓我學到一件事:選編輯器其實是在選它的資料格式,而資料格式是最難回頭的那個決定。

2018–2022:Draft.js

前端玩久了以後,我終於有能力自己在網頁上做編輯器,而不是找一個來裝。

Draft.js(Meta 出品)的模型是不可變的 EditorState:每一次使用者互動都產生一個新的 state snapshot,裡面包含完整內容和內建的 undo/redo stack。react-draft-wysiwyg 這類 wrapper 把它直接攤出來給你用。

概念很漂亮。實務上,只要碰到 Draft.js 沒有內建的東西 —— 一個自訂節點、一個貼上行為 —— 就會進到文件比較少、範例也比較少的區域。

但是發生什麼事情了?後來 Meta 停止維護它了。

這件事剛好印證了前面那個結論:內容的壽命,不該取決於某個團隊還想不想維護一個函式庫。 如果我當年存的是 Draft.js 的 raw state JSON,今天要處理的就是一份遷移腳本了。

Slate.js

Slate.js 換了一套模型,比 Draft.js 的快照模型有彈性太多,也是後來大家都走的路。Slate 很好,代價是它非常「你自己來」—— 它給你一副骨架,表格、程式碼區塊、貼上處理,全部自己寫。

非常的有彈性是他的優點,但也是他的缺點。

Now: Tiptap

Tiptap 建在 ProseMirror 上。ProseMirror 有嚴格的 schema、有 transaction、有 plugin、有 decoration —— 這些是我用過的東西裡面,第一次讓我覺得這個 model 本身是對的

Markdown 這件事也終於有了官方答案:@tiptap/markdown 跟 core 同版本,核心 extension 自帶 parseMarkdown / renderMarkdown spec。前面講的那道「縫」,第一次有人正式把它當成一等公民處理。

以後我會用什麼?我也不知道。TinyMCE、Trix、Draft.js 我當年也都覺得夠用了。但至少 Tiptap 現在真的順手,而且因為存的是 Markdown,下一次要搬家的時候,需要重寫的是編輯器,不是內容。


我剛好是兩種比較少被預設值照顧到的使用者

Tiptap 開箱就很好用 —— 如果你寫的是英文散文。

我的情況稍微不同,而這兩件事也是我後面大部分工作的來源:

我是中文使用者。 中文要經過輸入法,也就是 composition event —— 使用者按了鍵、但字還沒定案的那一段時間。多數編輯器功能(自動完成、觸發字元、debounce 搜尋)的預設值都假設「一個按鍵 ≈ 一個字」。中文使用者按十次鍵可能只產生兩個字,中間任何一次讀取拿到的都是半成品。

我寫程式碼。 程式碼區塊在 WYSIWYG 編輯器裡的位置比較特別:它是 rich text 裡唯一一塊「不能 rich」的地方。所有自動轉換(智慧引號、Markdown 語法、貼上解析)在這裡都要退場,同時它還要語法高亮、要能標語言、要能按 Tab 縮排。

這兩件事很少有編輯器會預設幫你想好,所以接下來就是我實際做的功課。


十四個坑

每個坑我盡量寫三件事:症狀我一開始以為的原因真正的原因。第二件通常是花我最多時間的那件,所以我把猜錯的過程也留著。

坑按主題分成五組,最通用的放前面,最跟環境有關的放最後。標記:🔧 是寫程式碼的人比較容易遇到的,☁️ 是平台相關(只有最後一個),🀄 那一節是中文輸入相關的。

第一組:存進去的東西才是重點

這一組是我認為最通用、也最值得先看的。它們的共同點是:出錯的時候壞掉的不只是畫面,而是存進資料庫的內容本身 —— 而那份內容,是這整個專案唯一不能重做的東西。

坑 1:社群 Markdown bridge 把 LaTeX 變成 HTML entity

症狀:含行內數學的筆記,前台渲染直接 ParseError。翻資料庫,Markdown 裡有 &gt;

原因:我當時用的 tiptap-markdown 0.9 是社群套件,為 Tiptap v2 寫的,後來沒有再更新。它沒有 inlineMath 的 mapping,於是 fallback 到 renderHTML 的輸出 —— 一段 escape 過的 HTML span。前台把 &gt; 餵給 LaTeX renderer,自然就解析失敗了。

修法:換成官方 @tiptap/markdown,所有 @tiptap/* 對齊同一版本。數學節點補上第一級的 markdown mapping:$...$ / $$...$$ 的 tokenizer,遵循 remark-math 的慣例 —— 編輯器 parse 的,必須跟前台 render 的是同一套語法

順手做了自癒:節點的 parseHTML 會撿起舊的髒 span(DOM 會把 entity 解回來),重新序列化就吐出乾淨的 dollar 語法。舊資料自己會好。

這就是開頭講的那道「縫」實際出現的樣子。序列化層出問題的時候,影響的不只是一個畫面,而是存進資料庫的內容本身 —— 而那份 Markdown,正是我當初選 Markdown 的全部理由。所以這一層我後來補了不少測試,它值得。

心得:選套件的時候,「跟核心同版本、由核心團隊維護」這件事,優先於「功能比較多」。序列化層尤其如此。

坑 2:上傳中的圖片,不可以是 document 的一部分

貼上截圖會非同步上傳,上傳中要顯示佔位符。

第一直覺是插入一個 placeholder node。這是錯的:node 是 document 的一部分,所以上傳中的狀態會被序列化進交給表單的 Markdown —— 上傳到一半按 ⌘S 就存下一坨沒有意義的東西。

正解是 widget decoration。它永遠不會被序列化,但仍然會 map 過每一個 transaction —— 所以上傳期間繼續打字,圖片會落在正確的位置,而不是被釘在過期的 offset。作者若在完成前刪掉那個區域,圖片會被丟棄,而不是憑空出現在他沒在看的地方。

兩個細節:fetch 沒有預設 timeout,卡住的 PUT 永遠不會 settle,「Uploading...」會留到重整為止 —— 自己加 deadline。還有,從網頁複製圖片時剪貼簿同時帶著 file 和 <img>,優先取 file,位元組才會進自己的 R2 而不是熱連別人的伺服器。

心得:「暫時的 UI 狀態」和「文件內容」是兩個 layer。放錯 layer,使用者按 ⌘S 的那一刻就會知道。

坑 3:貼上是一個 dispatch table,不是一個功能 🔧

換掉社群 bridge 之後(坑 1),貼上 Markdown 原始碼會變成字面文字。補這個轉換的時候才發現「貼上」是一個五岔路口:

  1. 剪貼簿是圖片 → 交給上傳外掛
  2. 剪貼簿帶 text/html → 交給 ProseMirror 自己 parse(它知道來源結構,比我猜得準)
  3. 游標在 code block 或 inline code 裡 → 字元必須逐字存活,什麼都不要做
  4. 文字帶有明確的 Markdown 構造 → 轉成 rich content
  5. 其他 → 原樣貼上

第 5 條是刻意的。把每一段純文字都丟進 Markdown parser 會更一致,但會改變「貼上普通文字」的行為:Markdown 把單一換行當空格,多行純文字會塌成一段;散文裡的 *_ 會變成強調。

偵測條件寫得很保守。例如表格必須同時有 pipe 列底下那條 dash 分隔線 —— 單獨一行含 | 的東西,是 console 輸出的機率遠高於 Markdown。(第 3 條和這一條,都是「寫程式的人」這個身分逼出來的。)

再來還要有逃生口:Mod-Shift-V 貼上純文字。這裡有個 API 限制值得記:快捷鍵本身讀不到剪貼簿。它只能記下意圖然後 return false,讓瀏覽器繼續跑自己的 paste,剪貼簿事件才會到。那個意圖一秒後過期,免得一個平台從未轉成 paste 的按鍵,在幾分鐘後默默改變一次真正的貼上。

坑 4:目錄要從 render 後的 HTML 回頭讀,不能掃 Markdown

技術文章需要目錄,而目錄需要兩個東西:標題清單,和每個標題的 anchor id。

直覺的做法是掃 Markdown source,把 ## xxx 抓出來、自己算一個 slug 當 id。這也是我一開始想做的,但它會在幾個地方跟實際的 anchor 對不上 —— 而且剛好都是技術文章最常出現的標題:

  • 標題帶行內程式碼或強調:一個 h2 的文字裡包著 range(n) 和粗體,從 Markdown 算出來的 slug,和 rehype-slug 從 render 後的文字算出來的,不會是同一個。
  • 重複標題的流水號:rehype-slug 的計數器是跨所有標題層級遞增的,包含你在 Markdown 掃描時根本不會看到的 h4,以及用原始 HTML 寫的標題。
  • 只要有一個 id 對不上,那個目錄項目就是一個點了沒反應的連結。

所以目錄是從render 後的 HTML 回頭解析出來的:rehype-slug 產生 id,我就讀它產生的結果,而不是另外算一份。這樣兩邊不可能不同步,因為根本只有一份。

這件事跟這篇文章講的其他事情是同一個道理 —— 只是這一次,我在寫錯之前就先想到了。

(另外兩個小決定:目錄項目用純 <a> 錨點,所以 hydration 之前就能用;少於兩個標題就不顯示,一個項目的目錄是雜訊不是導覽。)

心得:兩份資料「應該要一致」的時候,與其想辦法讓它們一致,不如乾脆讓其中一份從另一份算出來。前者要一直維護,後者是結構上就不可能不一致 —— 這種偷懶我很願意。

第二組:bug 不在你寫的那一層

這一組每一個我都花了不少時間讀自己的程式碼,最後發現問題在別的地方 —— 第三方套件的預設值、CSS、或是一個沒有作用的 class。跟 Tiptap 沒什麼關係,任何前端專案都可能遇到。

坑 5:我花了半天 debug 一個不在我程式碼裡的 bug

症狀:「打引號會自動變成一對」,看起來像 inline code 的自動配對。

我很有信心地去查 inline code 的 input rule。寫 repro、開瀏覽器、確認 mark 不 sticky、要兩個反引號才會觸發、沒有任何 auto-pairing。查到最後結論是:inline code 本身完全正確。

然後我就卡在那裡了。程式碼看起來沒問題,但畫面上那對引號確實在那裡,它總得從什麼地方來。

真正的原因:Typography extension 的 smart quotes。直引號被改寫成成對的彎引號,看起來就像自動配對 —— 而且 U+2018/2019/201C/201D 會滲進存下來的 Markdown。關掉那四條規則,保留 em-dash、刪節號、箭頭。

還沒完。 同一個 issue 還抱怨 inline code 兩側有反引號,而且刪不掉:按 backspace 會從裡面吃掉真正的程式碼,直到整個 chip 一次消失。

那對反引號不是文件裡的字元,是 @tailwindcss/typography

code::before { content: "`"; }
code::after  { content: "`"; }

編輯器表面和預覽都掛在 prose 底下,所以每一個 inline code 都長這樣,新舊皆然。刪不掉,因為它根本不是內容。

心得:後來我養成一個習慣 —— 只要出現「我打字,畫面卻多了我沒打的東西」,我第一件事不是讀自己的 extension,而是先問「這個字元到底在不在 document 裡」。一行 editor.getText() 就能回答,而答案是「不在」的次數,比我想像的多很多。嫌犯通常就那三個:input rule、CSS 的 pseudo-element、瀏覽器或輸入法。

坑 6:語法高亮「有在跑」,只是沒有人在畫 🔧

症狀:編輯器和預覽裡的程式碼是灰色板磚,發布出去的頁面卻有完整高亮。預覽既不像編輯器、也不像網站 —— 一個所見即所得編輯器最沒有資格出的錯。

原因:lowlight 一直都有把 hljs-* class 塞進編輯器 DOM。但顏色主題被 scope 在 .tm-prose 底下,編輯器沒有這個 class。所以每次按鍵都在算那些 class,然後從來沒有被畫出來。

修法:scope 放寬到 .tm-code,兩邊都掛。--tm-* token 定義在 :root 跟著 data-theme 走,一份定義同時處理兩個表面的亮暗色。

順便把預覽改成用公開頁面同一個 <Prose> component,而不是自己寫的 prose dark:prose-invert —— 不要對「一篇文章長什麼樣」有第二種意見。

還有一個相關的:編輯器從 common(約 37 種 grammar)建 lowlight,前台 renderer 卻只在一個手寫的 9 種子集裡自動偵測。同一個沒標語言的區塊,寫的時候和發布後可能被判成不同語言。現在兩邊從同一個清單來。

坑 7:z-50 放在 position: static 上,什麼事都不會發生

症狀/ 選單被 toolbar 切掉一半,在筆電上還會超出畫面。

原因z-50 掛在一個 position: static 的 div 上 —— z-index 在那裡完全無效。popup 於是 stack 在 auto,被 z-10 的 sticky toolbar 蓋過去。class 必須放在 floating-ui 實際定位、實際 append 到 <body> 的那個元素上。

第二個問題:11 個項目高 419px,而且 overflow: hidden —— 尾巴不只看不到,是碰不到

至於「選單開在錯的位置」這個抱怨,placement 本身沒問題 —— suggestion plugin 的 flip 預設就是 true。它缺的是一個「兩側都塞得下」的高度。症狀描述的位置,跟真正的原因是兩回事。

心得:把沒有作用的 class 刪掉,不要留著讓它看起來是對的。一個 no-op 的 z-50 會讓三個月後的你以為這裡處理過了。

第三組:兩個時鐘不同步

React 的 render 和 ProseMirror 的 transaction 是兩個獨立的時鐘,瀏覽器的游標位置又是第三個。這一組全部是同一件事的不同面貌 —— 只要你把一個編輯器核心接進 React,換成 Slate、Lexical 也一樣要面對。

坑 8:React NodeView 的游標慢了一幀 🔧

症狀:新建 code block 之後,第一個按鍵落在區塊外面

原因:這個最難查,因為所有測試都通過。建立 code block 時 ProseMirror 立刻給了正確的 selection —— state.selection 從頭到尾都對。但這個 code block 是 React NodeView,它的 contentDOM 晚一幀才掛上,所以瀏覽器的游標留在區塊後面。state 說一套,DOM 做一套。

requestAnimationFrame(() => {
  if (editor.isDestroyed) return;
  editor.view.focus();   // 不是 chainable 的 focus()
});

必須是 view.focus()。可鏈式的 focus() 在編輯器已 focus 時 no-op,而每一個需要這個修正的路徑,編輯器都已經 focus 了。

心得:ProseMirror 的 state 和瀏覽器的 selection 是兩個東西。把 NodeView 交給 React,等於在兩者之間插入一個 render cycle。jsdom 測不到,只能開瀏覽器。

坑 9:shouldShow 讀 React state,永遠慢一個 transaction

症狀:表格的 BubbleMenu 從來不出現。

原因:我第一版從一個 useEditorState 的 boolean 去算 shouldShow。但 plugin 是在 ProseMirror transaction 進行中評估它的,那時 React 還沒 re-render。所以那個 boolean 永遠慢一個 transaction —— 包括「建立表格」的那一個。

修法shouldShow 在被呼叫的當下直接問 editor,不要捕捉任何 derived value。

這跟坑 10 是同一個病的兩面:React 的 render cycle 和 ProseMirror 的 transaction 是兩個不同步的時鐘。 坑 10 是「不小心依賴 React 才正確」,坑 9 是「依賴 React 所以永遠不對」。

順帶一提這個功能的起點:使用者回報「我找不到怎麼加 row 和 col」。沒有任何東西壞掉。 那七個 command 全都能用、都有測試在驗真實的行列數。它們在 toolbar 裡、藏在沒有標籤的 icon 後面、而且在游標不在表格裡時是 disabled 的 —— 你會看到它可用的那一刻,早就不看那裡了。修法是把控制項放到被編輯的那個東西旁邊

心得:「功能存在且有測試」和「功能能被找到」是兩個獨立的問題。後者不會出現在測試報告裡。

坑 10:效能問題不在我以為的地方(兩次)

我開了 issue 說「每個按鍵序列化兩次 document,太慢了」。我很確定。我錯了。

測量方式:capture-phase 的 keydown probe 配一個 MessageChannel message —— 後者會在整個 task(含 React flush)排乾之後才送達。

結果:每次按鍵約 21ms,而且從 664 字到 18,185 字完全持平。序列化的成本會隨長度成長,這個沒有。所以不是序列化。

CPU profile 直接點名:React element creation,jsxDEVcreateElement 是整個 trace 裡最大的兩個 JS frame。

真正的原因:document 流經 react-hook-form,每一個字元都 re-render 整個編輯器,重建 15 個 toolbar 按鈕跟 icon、三個選單、三個 dialog(DialogContent 的 children 不管有沒有 mount 都會被 eager 建構)。

更難看的真相是:toolbar 那些 inline 的 editor.isActive(...) 讀取,之所以一直正確,正是因為這個意外的 re-render

所以修法不是「少 render」,是「讓正確性不再依賴 render」:每個控制項用 useEditorState 訂閱自己要畫的那塊 state,然後 memo。先讓正確性獨立,才敢停止渲染。

第二個效能坑:修完之後,開著預覽仍然每次按鍵多花 12.7ms 的 Layout。不是 debounce 壞了 —— 計數器顯示 51 次 effect、但只有 1 次 timer fire、1 次 pipeline run。

是預覽本身。React 用物件識別比較 dangerouslySetInnerHTML,每次 render 給一個新的 { __html } literal,就是一次無條件的 innerHTML 賦值。MutationObserver 抓到 50 次按鍵重建了 25,150 個節點 —— 從位元組完全相同的 HTML。

情境修正前 (p50/p90)修正後
短文件,無預覽21.0 / 29.6ms10.0 / 16.0ms
18k 字,無預覽21.6 / 27.8ms12.5 / 16.5ms
18k 字,開預覽37.3 / 61.6ms11.8 / 16.2ms

開預覽的成本現在等於不開預覽,p90 落在一個 60Hz frame 之內。

心得:一、先量,而且要量「成本是否隨規模成長」—— 那條曲線的形狀就足以排除一半嫌犯。二、dangerouslySetInnerHTML 吃的是物件識別,不是字串內容。三、發現「這段程式碼一直是對的,但理由是意外的」,那不是效能問題,是正確性問題偽裝成效能問題。

第四組:鍵盤與輸入規則 🔧

這一組偏向「會寫程式碼的人才會遇到」:自動轉換規則猜錯了你的意圖,或是某個鍵被別人先接走了。

坑 11:三個反引號的 input rule 會吃掉你的下一個字 🔧

症狀:打 ````` 然後接著打 const x = 1;,得到一個標成 ````const的區塊,而且const` 從程式碼裡不見了。

原因:預設 input rule 會捕捉 fence 後面第一個 word 當語言,不管那是什麼。那個 info string 會一路傳進儲存的內容和前台 renderer。

修法:只有那個 word 真的是已註冊的 grammar 名稱時才當語言。否則那是作者正在打的程式碼,規則只吃掉 fence 把字留下。

(一個細節:觸發的空格要手動補回去,因為 ProseMirror 只在「沒有規則處理這次輸入」時才插入它。)

心得:只要一條規則在猜使用者想幹嘛,它就該有一份可以驗證的白名單。「後面那個字是語言」是猜的;「後面那個字在我註冊的那幾十個 grammar 裡面」才是可以查證的。差別聽起來很小,但前者會刪掉使用者剛打的字,後者不會。

坑 12:Tab 鍵把 focus 送去了 submit 按鈕 🔧

症狀:在 code block 裡按 Tab,沒有縮排,focus 跳到表單送出按鈕。

原因:兩層。ProseMirror 刻意把 Tab 留給瀏覽器(無障礙 —— 鍵盤使用者要能離開編輯器),而我的編輯器掛在 <form> 裡,所以 Tab 就是正常的 focus 移動。寫有縮排的 Python 或 TypeScript 等於永遠不能碰這個鍵。

修法:給 code block 自己的 keymap,只在 selection 在 code block 內時攔截 Tab,其他地方照舊,無障礙的逃生口留著。

行的數學運算被抽到一個不依賴 ProseMirror 的檔案,所以最麻煩的 off-by-one(選取剛好結束在某行開頭時那行不該被縮排)可以直接測。

心得:覆寫瀏覽器預設鍵之前,先問那個預設值是為誰存在的。答案常常是無障礙。攔截範圍要縮到最小。

第五組:測試與部署

放在最後,因為這兩個最跟環境有關 —— 尤其最後一個只有 Cloudflare Workers 適用。但兩者背後的道理都不挑平台。

坑 13:CI 全綠,然後 exit 1

586 passed, 1 unhandled error, exit code 1

TypeError: target.getClientRects is not a function
  at singleRect → coordsAtPos → scrollToSelection → dispatchTransaction

每個測試都過,整個 run 卻是失敗的。而且本機跑是綠的,只有 CI 紅 —— 我第一個反應當然是重跑一次,然後它又綠了。第三次才紅回來。

原因:jsdom 沒有實作 layout,Range.getClientRects 根本不存在(不是 no-op,是 undefined)。而 Tiptap 的 focus()scrollIntoView 丟進 requestAnimationFrame —— 於是那個 throw 落在造成它的測試已經結束之後,Vitest 只能報成 unhandled error。時序不同就不一定出現。

修法:在全域 setup 裡 shim 那兩個 Range 幾何方法,回傳空的幾何 —— 對一個沒有 layout 的 DOM 來說,這是誠實的答案。(同一類的洞後來又出現一次:jsdom 也沒有 scrollIntoView。)

心得:unhandled error + exit 1 而測試全綠,幾乎一定是有東西被排進了測試生命週期之外的timer / rAF / microtask。往那裡找,不要重跑 CI。

坑 14:編輯器的體積,跟著 SSR 一起進了伺服器 ☁️

☁️ 3 MiB 這個數字只有 Cloudflare Workers 適用,但「編輯器只有後台會用到,不該進 SSR bundle」在哪個平台都成立 —— 差別只在別的平台不會直接部署失敗,而是安靜地讓每一次 SSR 都多背一份重量。

症狀:本機都正常,一部署到 Cloudflare 就失敗,bundle 超過免費方案 3 MiB 的上限。

原因:Tiptap 全家桶加上 KaTeX 一起進了 SSR bundle。但編輯器是後台才用得到的東西,訪客永遠看不到它,卻跟著每一個 SSR 請求進 worker。

在 Next.js 這是一行 next/dynamic({ ssr: false }) 就能解決的事;TanStack Start 目前沒有對應的 API,所以要自己做:

const TiptapEditorImpl = lazy(() => import("./TiptapEditorImpl"));

export function TiptapEditor(props) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return fallback;
  return <Suspense fallback={fallback}><TiptapEditorImpl {...props} /></Suspense>;
}

這裡有個小細節:只有 lazy 還不夠,伺服器端仍然會渲染 Suspense 的內容。要加上 mounted 這道 gate,重的 chunk 才會等到 hydrate 之後才在瀏覽器載入。

心得:換框架真正的成本,往往不是學新 API,而是舊框架幫你做掉、久到你已經忘記它存在的那些事。搬家的時候值得先列一張「上一個框架幫我處理了什麼」的清單。


番外:中文輸入要多做的一些功課 🀄

這幾件事我不太想叫它們「坑」,因為它們不是 bug —— 比較像是每個功能都要再多做一次的功課。我把它們放在一起,因為背後是同一件事:大部分套件的預設值是先為英文設計的。 這很合理,只是身為中文使用者,需要自己補上這一段。

觸發字元。 @ 提及要能在中文字後面直接觸發。@tiptap/suggestionallowedPrefixes 預設要求前面是空白 —— 英文自然會有空格,中文不會。要設成 null

輸入法還沒定案的那段時間。 搜尋要 gate 在 composition 上,否則使用者打「賴」的過程中,laiㄌㄞˋ 這些半成品會被送去查詢。suggestion 的 items() 在 composition 進行中要直接 bail。

Slug。 這個我覺得蠻有意思:標題自動填 slug,在第二個字之後就停了。「十年部落格如一日」存成 shih

守衛條件是 !formState.dirtyFields.slugsetValue 不帶 shouldDirty 不會標記 dirty,但標題的下一個按鍵會:react-hook-form 的 updateTouchAndDirty 在某欄位的 pristine 狀態和表單的 isDirty 不一致時,會重新 diff 整份表單對照 defaultValues,而自動填入的 slug 跟 "" 不同,於是被標回 dirty。一次 render 之後守衛就永久關閉 —— 所以剛好是前兩個按鍵能通過。

修法是別追蹤 dirty,追蹤作者身份:記住自己上一次寫進去的值,一旦欄位裡不是那個值就退場。

心得:dirtyFields 回答的是「這份表單跟 defaultValues 不一樣嗎」,不是「使用者碰過這個欄位嗎」。語意差一個字,bug 就只在第 2 個按鍵之後發生。


回頭看,這些坑其實只有四類

整理的時候我才發現,這十四個坑其實翻來覆去就是四種。如果這篇只能留四段,我會留下面這四段 —— 它們跟 Tiptap 其實沒什麼關係,換個編輯器、甚至換個框架,大概還是一樣的。

一、Bug 常常不在你寫的那一層。 彎引號在 Typography extension,反引號在 Tailwind 的 CSS,高亮失效是 CSS scope,z-50 失效是因為 position: static。四個看起來像「編輯器 bug」的問題,最後都不在編輯器的程式碼裡。所以我現在會先確認症狀屬於哪一層,再開始讀程式碼。

二、兩個時鐘不同步。 ProseMirror 的 transaction 和 React 的 render 是兩個獨立的時鐘,瀏覽器的 caret 和 state.selection 又是第三個。NodeView 慢一幀、shouldShow 慢一個 transaction、toolbar 靠意外的 re-render 才正確 —— 都是同一件事的不同面貌。任何跨越這兩者的 derived value,都值得多看一眼。

三、我對原因的第一直覺,大概錯了一半。 以為是 inline code(其實是 smart quotes)、以為是序列化(其實是 React element creation)、以為是 debounce(其實是 innerHTML 的物件識別)、以為選單位置算錯(其實是高度沒有上限)。每次救我的都是先量再改:一條隨文件長度持平的曲線、一個 MutationObserver 的節點計數、一份 CPU profile。花在測量上的時間,通常比猜錯之後改錯地方要少。

四、有些事 jsdom 不會告訴你。 caret 的實際位置、getClientRects、一個看起來正確但其實只是碰巧正確的 re-render —— 測試套件都會全綠。jsdom 本來就沒有 layout,有些事情還是得開瀏覽器親自看一次。


那五個問題,我最後怎麼答的

回到開頭那五個沒有標準答案的問題,這是我的選擇:

Markdown editing 和 WYSIWYG 要怎麼共存? 我選了不共存。編輯面只有 WYSIWYG,Markdown 純粹是存檔格式,使用者從頭到尾看不到它。兩種模式並存聽起來很體貼,實際上是把「切換時游標在哪、哪一邊是真相」這個問題丟給使用者。既然我信任序列化層(並且為它寫了一堆測試),就沒有理由再開一扇後門。

編輯器上顯示什麼? 顯示 render 後的結果。另外有一個預設關閉、⌘⇧P 開啟的預覽窗格 —— 但它顯示的不是 source,是這篇文章發布之後長什麼樣,而且是用公開頁面同一個 component 算出來的(坑 6)。所以那不是「第二種編輯模式」,是「第二個觀點:讀者的觀點」。

要支援哪一種 flavor? GFM 那一套(表格、刪除線、任務清單)加上 $...$ 的數學。決定的方式不是查規格,是讓編輯器和前台 renderer 共用同一份清單 —— 程式碼語言的清單是同一個檔案,數學的 tokenizer 遵循前台 renderer 的慣例。flavor 的定義不是一份文件,是「兩邊必須同意」這個約束。

圖片上傳怎麼支援? 貼上或拖放就上傳,走 presign → PUT → confirm 到自己的 R2。上傳中的佔位符是 decoration 而不是 node(坑 2),所以它永遠不會進到存檔的 Markdown 裡。

Embed 呢? 我選了三條路裡的第一條:允許原始 HTML 進到 Markdown 裡。前台的渲染管線帶著 rehype-raw,Markdown 裡的 HTML 會照樣輸出;編輯器則把 YouTube 當成一個真正的節點,有自己的 NodeView,序列化回去就是那段 HTML。

我知道這讓「純文字」打了折扣,但我接受的理由是:這條折扣是有邊界的。 一篇文章裡真正需要 embed 的段落是少數,而且就算換掉整個編輯器,那段 HTML 在任何 Markdown 渲染器裡仍然是有效的 HTML —— 它壞掉的方式,比「自訂簡寫語法」溫和得多。相對地,如果我發明一個 {{youtube:xxx}},那它只在我自己的 renderer 裡有意義,換工具的那天它就變成一串沒有人看得懂的字。

代價要說清楚:開了 raw HTML,等於在渲染端關掉了一層防護。我的情境是單一作者、內容只有我自己寫得進去,所以這個取捨可以接受。如果內容會來自其他人,這個決定要完全反過來 —— 那時候比較合理的做法是只允許白名單內的 embed,把 URL 轉成受控的元件,而不是讓任意 HTML 直通。

五個答案有一個共同點:每一次我都是在減少「真相的份數」。 一種編輯模式、一個 render 管線、一份語言清單、一個不會被序列化的暫時狀態、一份不需要專屬 renderer 才看得懂的 embed。這篇文章裡幾乎每一個坑,追到底都是某個地方存在了第二份真相。


還沒做,但已經在影響決定的兩件事

有兩個功能我還沒做,不過想清楚它們要怎麼做,反而讓我更確定存檔格式的選擇是對的。

付費牆。 這件事的難處不在金流,在於一篇文章需要一個「免費的部分到這裡為止」的切點,而 Markdown 沒有這個概念。

可以選的還是那幾種:在正文裡放一個標記(Rails 時代的 <!-- more --> 就是這個),或是把文章切成兩個欄位分開存。我會選標記 —— 因為切成兩欄,等於同一篇文章從此活在兩個地方,而這篇文章從頭到尾在講的就是不要有第二份真相。標記則不然:它只是純文字裡的一行,把它拿掉,文章仍然是完整的一篇文章。

電子報。 這件事我原本以為是寄信的問題,後來發現是渲染的問題:email 的 HTML 跟網頁的 HTML 幾乎是兩種語言 —— 很多 client 不吃外部 CSS、不吃現代排版、樣式得寫成 inline。

換句話說,同一篇文章我需要兩種渲染結果。而這正好是存 Markdown 最直接的一次回報:來源只有一份,我只要再寫一條 pipeline 就好。如果我當初存的是編輯器產生的 HTML,那要做的就不是「多一條 pipeline」,而是把一份已經定型的 HTML 想辦法翻譯成另一種 HTML —— 那是完全不同量級的工作。

這兩件事都還沒動工,但它們共同指向同一個結論:存 source,不要存呈現結果。 你不會知道三年後這篇文章需要被渲染成什麼樣子 —— 網頁、email、RSS、App、或是某個還沒出現的東西。而只要 source 還在,那些都只是多一條 pipeline 的事。


值得嗎?

從我接手的第一個 vendor/assets 底下那包 CKEditor 到現在,十年,我大概用過六套編輯器。每一套在當下都覺得「這次應該就是了」,然後總會在某個地方遇到限制。

現在這套也一樣。Tiptap 總有一天也會被別的東西取代 —— 這不是壞事,前端本來就是這樣往前走的。

差別在於,這一次我的資料是 Markdown。

編輯器是我的工具,內容是我的東西。這十年我最實在的一個心得,就是這兩件事要能分開 —— 下一次要搬家的時候,需要重寫的是編輯器,不是十年份的文章。

而在那之前,我現在可以貼上截圖、打 /table 插入表格、在中文字後面直接按 @ 連到自己的另一篇文章;而存下去的,仍然是我十年後打得開的純文字。

老實說這些東西加起來花的時間,遠比我一開始估的多。如果重來一次,我大概還是會做,只是會先把「存檔格式」這件事想清楚再開始 —— 因為後面所有的選擇裡,就這一個最難回頭。

其他的,慢慢調就好,反正部落格又不會跑掉。

$ cd .. — back to all posts