@laigary.com~/posts/ten-years-of-buildin….md$
$ cd ..
$ cat ./posts/ten-years-of-building-web-editors.md
---
title:   "開發網頁編輯器的十年筆記"
date:    2026-08-05
reading: 75 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 的轉換器,而那種轉換永遠是有損的。


我想要的編輯器,具體長什麼樣

在講取捨之前,先講我當初想要什麼。這張清單是我真的列過的。

插入東西不用離開鍵盤。/ 就跳出選單:表格、程式碼區塊、圖片、YouTube、分隔線、各級標題。不用去工具列上找那排我永遠認不出來的 icon —— 尤其 H1 和 H2 常常只是同一個字母的兩種大小。

連結要快,而且要連得到自己。@ 跳出搜尋,選一篇我自己的文章就插進去;選一段字按 ⌘K 貼網址。而且 @ 存下去要是 [標題](/posts/slug),一個普通的 Markdown 連結,不是某個只有我的網站看得懂的自訂節點。

貼上截圖就直接上傳。 不要跳出「選擇檔案」的對話框。反過來說,我也要能貼「純文字」—— 從網頁複製一段東西過來的時候,我常常只想要字,不想要它整包樣式。

程式碼要像程式碼。 能標語言、有語法高亮、按 Tab 要縮排,而不是把游標踢到送出按鈕上。

數學要能寫。$E=mc^2$,而且編輯的時候就看得到渲染後的樣子。

表格要能編輯,不只是插入。 加一列、刪一欄,這些控制項要在表格旁邊,不是藏在工具列某個只有游標剛好在表格裡才會亮起來的按鈕後面。

排版的基本盤。 粗體斜體底線、螢光筆、文字顏色、對齊、任務清單。這些沒什麼好說的,但少一個就會在某天卡住。

長文要有目錄。

隨時能看到這份文件的 Markdown 原始碼。

寫成清單之後我才發現一件事:這上面沒有任何一項是關於「文字」的。

前面說過,文字那層從來不是問題。這張清單上的每一項,都是文字以外的東西 —— 而且每一項最後都必須落地成一行純文字 Markdown,一個十年後我用 cat 就讀得完的檔案。

我當初以為我在列一張功能清單。做下去才知道,我列的是一張問題清單:上面每一項背後都有一個沒有標準答案的決定,而且那些決定會互相牽扯。


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

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

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

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

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

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

圖片上傳怎麼支援? 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 讓我學到一件事:選編輯器其實是在選它的資料格式,而資料格式是最難回頭的那個決定。

前後端完全分離

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

Draft.js

Draft.js (Meta 出品),Draft.js的模型是不可變的 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 很好,代價是它非常「你自己來」—— 它給你一副骨架,表格、程式碼區塊、貼上處理,全部自己寫。

非常的有彈性是他的優點,但也是他的缺點,我還是偏向使用更現成的解決方案,並且在上面做微調,Slate 對我來說又有點太輕量了。

現在

Tiptap

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

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

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


BUT

如果到這裡就結束,那就太完美了,但是很可惜到現在沒有完美的答案。

Tiptap 開箱就很好用 —— 如果你主要書寫的內容是英文。我的情況稍微不同:我打中文,而且我寫程式碼。這兩件事很少有編輯器會預設幫你想好,它們也是我後面大部分工作的來源。

其實我在很多 Web App 的文字編輯器也常會有遇到問題,很大一部分通常是產品都是使用拉丁語系的思考去開發,沒有考慮過 CJK 的輸入方式。


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

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

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

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

至於前面列的第三個選項 —— 「顯示 render 結果但要能看得到 source」—— 我後來也做了,但刻意只做一半:工具列上一個 </>,開一個唯讀的側欄,裡面就是這份文件現在的 Markdown,附一個複製鈕。

唯讀是重點。你不能在裡面打字,所以「哪一邊是真相」這個問題根本不存在,前面那個「不共存」的決定沒有被推翻。我缺的從來不是第二個可以打字的地方,是「我剛剛那個構造到底被序列化成什麼」這個問題的答案 —— 而寫這篇文章的過程裡,這是我問得最多的一個問題。

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

圖片上傳怎麼支援? 貼上或拖放就上傳,走 presign → PUT → confirm 到自己的 R2。上傳中的佔位符是 decoration 而不是 node(條件五會再談這件事),所以它永遠不會進到存檔的 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」的決定,全部押在這一層上。如果編輯器吐出來的 Markdown 跟你打的不是同一回事,前面所有理由都不成立。

我踩到的是數學。編輯器裡的行內數學是一個自訂節點,而我當時用的 Markdown bridge 沒有它的 mapping —— 遇到不認識的節點,它會退回去用 HTML 輸出。於是資料庫裡存的不是 $E=mc^2$,是一段 escape 過的 HTML span,前台的 LaTeX renderer 拿到 &gt; 直接解析失敗。

這件事本身很好修,但它教的東西比較大:序列化層要選跟核心同版本、由核心團隊維護的那一個,優先於功能比較多的那一個。 因為功能多的那個,通常也是最先跟不上核心的那個。而序列化層壞掉的時候,你不是壞了一個畫面,是在寫壞資料。

還有一條連帶的:編輯器 parse 的語法,必須跟前台 render 的語法是同一套。 數學要走 $...$,兩邊就都得遵循同一份慣例;程式碼語言的自動偵測清單,兩邊也必須是同一份。任何一邊自己有意見,你都會在發布之後才發現。

然後你要能查證。 前面提到的那個唯讀 source 檢視和 .md 網址,就是為了這件事。這不是為了炫技 —— 這篇文章第一次存進資料庫的時候就被序列化層咬了一口:後半段整段沒有被轉換,** 全變成跳脫的字元,還掉了幾個字。我是把發布出去的 .md 跟手上的草稿 diff 才發現的。那個為了 LLM 做的功能,第一個認真的使用者是我自己,用途是稽核我自己的編輯器有沒有偷改我的東西。

怎麼知道你做到了:寫一份包含數學、表格、程式碼區塊、巢狀清單的文件,存檔、重新載入、再存一次。第二次存下來的內容必須跟第一次一模一樣。 只要有一個字元漂移,你的序列化層就有洞,而那個洞會隨時間累積。

條件二:編輯器不能改我沒叫它改的東西

所見即所得有一個沒說出口的承諾:所存即所見。我打什麼,存進去就是什麼。

而這個承諾一直在被偷偷違約,違約的通常不是編輯器本身,是它預設帶著的那些「貼心」功能。

我遇過三次,三次都不在我的程式碼裡。直引號被改成彎引號,於是 U+2018 這種字元滲進我的 Markdown。行內程式碼兩側冒出一對反引號,怎麼樣都刪不掉 —— 因為那是 CSS 畫上去的,根本不在文件裡。引言的字被一對引號包起來,而我發布出去的頁面根本沒有引號。

這三個都屬於同一類:樣式套件對「一段內容該長什麼樣」有自己的意見,而它的意見跟你的不一樣。 前兩個甚至會污染存檔內容,第三個只是畫在畫面上 —— 但使用者分不出來,因為它們看起來一模一樣。

還有一種更難查的:兩條規則搶同一串字。 我打三個 - 想要分隔線,但破折號規則在第二個 - 就先動手了,所以分隔線背後藏的其實是「破折號加一個 hyphen」。平常看不出來,直到你按 Backspace,還原出來的是你根本沒打過的東西。

同一類還有一個:我打三個反引號開一個程式碼區塊,然後接著打 const x = 1; —— 結果 const 被吃掉,變成那個區塊的語言標籤。規則在猜我的意圖,而它猜錯了。任何一條會猜使用者意圖的規則,都該有一份可以驗證的白名單:「後面那個字是語言」是猜的,「後面那個字在我註冊的那幾十種語法裡面」才是可以查證的。差別聽起來很小,但前者會刪掉使用者剛打的字。

怎麼知道你做到了:隨便打一段有引號、有 --、有 ...、有 (c)、有行內程式碼的文字,存檔,然後去看資料庫裡那一欄。如果它跟你打的不一樣,你的編輯器正在替你做決定。而這種決定,你多半要到發布之後才會發現。

條件三:CJK 是一等公民,不是 fallback

中文要經過輸入法,而輸入法意味著 composition event —— 使用者按了鍵、但字還沒定案的那一段時間。

幾乎所有編輯器功能的預設值都假設「一個按鍵 ≈ 一個字」。我按十次鍵可能只產生兩個字,而中間任何一次讀取,拿到的都是半成品。這不是誰的疏忽,預設值總得先服務多數情境。但身為中文使用者,這一段要自己補。

觸發字元要能在中文後面直接觸發。 @ 提及的預設規則通常要求前面是空白 —— 英文自然會有空格,中文不會。「我覺得@某某」中間沒有空格,選單就不會跳出來。

搜尋要 gate 在 composition 上。 否則我打「賴」的過程中,laiㄌㄞˋ 這些半成品會一路被送去查詢。使用者會看到選單在自己還沒打完字的時候瘋狂閃爍。

最重要的一條:composition 進行中,絕對不要重建文件。 這是我覺得最值得寫下來的一個。編輯器的內容常常需要跟外部狀態同步,而同步的手段通常是「重新設定整份內容」—— 那個操作會把文件拆掉重新解析。如果它剛好發生在輸入法還沒定案的時候,composition 會被中斷。使用者感受到的是:游標亂跳、字被打兩次、輸入變慢。 這幾個症狀看起來完全不像同一個原因,而且英文使用者永遠不會遇到,所以很難查。做法很簡單,同步之前先問一句「現在正在組字嗎」,是的話就跳過 —— 組字結束後那個同步會再跑一次,什麼都不會漏。

還有 slug。 中文標題轉網址 slug 是另一套問題。我的自動填入曾經在第二個字之後就停了,「十年部落格如一日」存成 shih

怎麼知道你做到了:用輸入法打一句中文,中間在某個中文字正後方按 @;打字的時候開著網路面板,看有沒有送出半形的查詢;然後在文章中段打字的同時讓表單存一次檔。

條件四:程式碼要像程式碼

程式碼區塊在所見即所得編輯器裡的位置很尷尬 —— 它是整份 rich text 裡唯一一塊不可以 rich 的地方。

所有的自動轉換在這裡都要退場:智慧引號、Markdown 語法、貼上解析,一個都不能作用。好消息是這件事通常不用自己做,成熟的編輯器核心會在程式碼區塊和行內程式碼旁邊直接放棄套用規則。壞消息是其他每一件事都要自己來。

語法高亮要跟發布後一致。 這是最容易漏的一條。我的高亮曾經在編輯器裡完全沒作用 —— 不是沒算,是算完了沒人畫:顏色的 CSS 被限定在一個編輯器沒有的 class 底下。更麻煩的是另一種:編輯器用一組語言清單自動偵測,前台 renderer 用另一組,於是同一個沒標語言的區塊,寫的時候和發布後可能被判成不同語言。兩邊必須讀同一份清單。

語言要能改。 如果語言只能在建立區塊的當下決定,標錯就只能整塊刪掉重打。

Tab 要能縮排。 這件事預設是壞的,而且壞得很合理:編輯器核心刻意把 Tab 留給瀏覽器,因為鍵盤使用者需要它離開編輯區。但如果你的編輯器像我一樣掛在 <form> 裡,按 Tab 就是把游標送到送出按鈕上。要自己攔,而且只在游標真的在程式碼區塊裡的時候攔 —— 其他地方的 Tab 要留著,那是無障礙的逃生口。

怎麼知道你做到了:在區塊裡貼一段帶引號和 -- 的程式碼,看它有沒有被改;把同一段程式碼發布出去,比對兩邊的顏色;然後按一次 Tab。

條件五:富文字的部分要真的好用

這條聽起來最像「功能」,但每一項背後都有一個容易做錯的決定。

圖片:貼上就上傳。 而上傳中的佔位符不可以是文件的一部分。這是我覺得最反直覺的一條 —— 第一直覺是插入一個 placeholder 節點,但節點會被序列化,所以上傳到一半按 ⌘S,你就把一坨沒有意義的東西存進去了。正解是用一個不會被序列化、但仍然會跟著文件位移的裝飾層。這樣上傳期間繼續打字,圖片會落在正確的位置;作者如果在完成前刪掉那一段,圖片就跟著被丟棄,而不是稍後憑空出現在他沒在看的地方。

另外兩個小陷阱:fetch 沒有預設 timeout,所以卡住的上傳永遠不會結束,「上傳中」會留在畫面上直到重整;還有從網頁複製圖片時,剪貼簿同時帶著檔案和 <img> 標籤,要優先取檔案,位元組才會進你自己的儲存空間,而不是熱連別人的伺服器。

貼上要符合直覺。 具體來說是四件事:貼截圖 → 直接上傳;貼一段 Markdown → 變成格式;貼進程式碼區塊 → 一個字都別動;貼一般文字 → 不要自作聰明。四件事沒有一件是預設行為,四件都要自己寫。而且最後一件最容易做過頭:把每一段純文字都丟進 Markdown parser 會更「一致」,但代價是多行文字會塌成一段,散文裡的 * 會變成強調。

目錄要從渲染後的結果回頭讀,不要掃原始碼。 直覺是掃 Markdown 把 ## xxx 抓出來、自己算 anchor id。但那會跟實際的 anchor 對不上 —— 標題裡有行內程式碼或粗體的時候算出來不一樣,重複標題的流水號也不一樣。只要有一個對不上,那個目錄項目就是一個點了沒反應的連結。與其想辦法讓兩份資料一致,不如讓其中一份直接從另一份算出來。

表格的控制項要放在表格旁邊。 我的加列刪欄功能其實一直都能用,也一直有測試 —— 但它們藏在工具列一個沒有標籤的 icon 後面,而且只有在游標剛好在表格裡的時候才會亮。你會看到它可用的那一刻,早就已經不看那裡了。「功能存在且有測試」和「功能能被找到」是兩個獨立的問題,而後者不會出現在你的測試報告裡。

怎麼知道你做到了:貼一張截圖,然後在它上傳完成前按存檔;貼一個 Markdown 表格;點一下目錄裡最長的那個標題。

條件六:打字要跟得上手速

理由很簡單:如果打字會頓,我就不會想寫。這是唯一一條「不滿足就前功盡棄」的條件 —— 前面所有的正確性,都建立在我還願意打開這個編輯器的前提上。

我曾經很確定我知道慢在哪:每次按鍵都把整份文件序列化兩次。我開了 issue,寫得斬釘截鐵。我錯了。

真正救我的是量測,而且是量對了一件事:成本有沒有隨著文件長度成長。 每次按鍵大約 21ms,而且從 664 字到 18,185 字完全持平。序列化的成本一定會隨長度成長,這條線沒有,所以不是序列化。光是這條曲線的形狀,就排除了一半的嫌犯。

真正的原因是每一個字元都在重建整個編輯器的介面 —— 十幾個工具列按鈕、選單、還有幾個就算沒開啟也會被完整建構的對話框。而這裡有個更難看的真相:工具列那些「粗體現在是不是開著」的判斷,之所以一直正確,正是因為這個意外的重繪

所以修法不是「少重繪」,是「讓正確性不再依賴重繪」。先讓每個控制項自己訂閱它要畫的那塊狀態,然後才敢停止重繪它。順序反過來就會壞。

情境修正前 (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 落在一個畫面更新的時間內,使用者不會感覺到它。

條件七:如果你要自己接,接縫在這裡

前面六條是「編輯器要做到什麼」。這一條是「如果你打算自己把一個編輯器核心接進前端框架,你會多出什麼問題」。

你會多出一個時鐘。 編輯器核心有自己的交易機制,前端框架有自己的渲染週期,而瀏覽器的游標位置是第三個。它們不同步。

一個真實的例子:我做了表格的浮動選單,然後它從來不出現。

原因是我用框架的狀態去算「現在該不該顯示」。但那個判斷是在編輯器的交易進行中被呼叫的,那時候框架還沒重新渲染 —— 所以那個值永遠慢一拍,包括「剛剛建立表格」的那一拍。修法是在被呼叫的當下直接問編輯器,不要拿任何預先算好的值。

同一個病還有另一面。我建立程式碼區塊之後,第一個按鍵會落在區塊外面 —— 而所有測試都通過,因為編輯器的狀態從頭到尾都是對的。錯的是瀏覽器的游標:那個區塊是用框架元件渲染的,它的內容容器晚一個畫面才掛上去,游標就被留在後面。狀態說一套,DOM 做一套,而這種問題在沒有版面配置的測試環境裡看不到,只能開瀏覽器。

還有體積。 編輯器是後台才用得到的東西,訪客永遠看不到它。但如果你的框架會做伺服器端渲染,它很可能跟著每一個請求一起被載入。在某些平台這會直接讓部署失敗,在其他平台它只是安靜地讓每一次請求都多背一份重量。

怎麼知道你該擔心:找出程式碼裡每一個「從編輯器狀態算出來、然後存在框架狀態裡」的值。每一個都是候選的 bug。


回頭看,唯一真正救過我的事

七個條件寫完,我發現有一件事沒有被任何一條吸收,而它可能是全篇最通用的。

我對原因的第一直覺,大概錯了一半。

以為是行內程式碼,其實是智慧引號。以為是序列化,其實是介面重繪。以為是 debounce 壞了,其實是我每次都給了一個新的物件。以為選單的位置算錯了,其實是它沒有高度上限。以為分隔線的規則寫壞了,其實是另一條規則先動了手。

每一次救我的都是同一件事:先量再改。 一條隨文件長度持平的曲線、一個節點重建的計數、一份 CPU profile、一個手動送出的請求。這些量測花的時間,每一次都比我猜錯之後改錯地方要少。

如果這篇只留一句,我會留這句。


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

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

付費牆。 這件事的難處不在金流,在於一篇文章需要一個「免費的部分到這裡為止」的切點,而 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