打造簡潔有力版面的小秘訣:Dcard 編輯器實作經驗分享

Dcard Tech
Dcard Tech Blog
Published in
10 min readSep 15, 2021

Dcard 從 2011 年上線到現在,從最初期陽春的 Web 版,到現在有了獨立的 APP,這中間發文和回應功能使用的編輯器其實也經歷過了非常多次的改變。

(Shared by Web Frontend Developer / Maxam)

緣起

Dcard 的編輯器從一開始 Next 改版之後,我們就使用 Draft.js,這是一個 Facebook 開源的 editor,但因為內部用了 immutable.js 所以 library 非常的肥大。後來我們就換成用 Slate.js ,除了 API 相當友善外也比原先的 libray 輕量,但對於依賴 IME 的輸入法(ex. 中/日文)支援度非常差,當時 patch 了非常多的 code 上去,但還是有很多使用者回報問題,所以也不太好用。

主要的原因是因為我們使用 React ,所以會需要去解決 React renderer (React-DOM) 更新 Browser DOM 和使用者打字直接更新 Browser DOM 時所造成的 conflict(註:conflict 的發生取決於瀏覽器對於 contentedit div 的實作,常發生於使用 IME 輸入法時)。

在 Bug 很多的情況下,PM 們就決定要針對編輯器做調整,於是我們花了三個禮拜左右的時間來做改版。

HTML 的編輯器是怎麼做出來的?

基本上 Dcard 的編輯器相對簡單,因為不需要粗體、斜體、和畫底線,只需要做一個支援圖文穿插的編輯器,設計上相對會比較單純。

如果對 HTML 有點了解的人應該都知道,如果我們在 div 上面加上 contenteditable = true 的話他就會變成一個可以插入文字跟圖片的編輯器,這邊有一個簡單的 Demo。

Demo 示意圖

但如果單單只用最基本的 HTML Code 做編輯器,其實會產生一些問題,第一點是 HTML 會自動幫我們更新 DOM 的結構,第二點是相容性上有些問題,按下 Enter 後 HTML 可能會插入 <br /> 的 Tag ,或是插入一個 <div />,所以要標準化這件事是非常困難的。

最大的問題是他主動操作 DOM 這件事情,首先我們先看一下這邊 DOM 的結構:

這個結構裡面有開的 Tag <div>,以及封閉的 Tag </div>,兩者之間的就是他們的 children,可以插入各種 tag 繼續長下去。而像是 <img src =“…” /> 就是一個 Self close tag

對於工程師來說,我們可以把這個結構想像成是如同下面的這棵樹:

而如果把上面提到的 HTML 畫成樹,他就會長成這樣:

最上面的 root 就是 contenteditable = true 的 div,下面有三個節點,分別對應到 HTML 中 tag 中間的子句,而往下分別接著 text node 和 img,因為是最底層,我們會叫他 leaf。你會發現 tag 內的子句就是 tree 上對應到的 children 而 leaf 只會是 self close tag 或是 text node。

而 Dcard 的網頁因為是使用 React,所以當打字時如果編輯器主動更新 DOM 的話會和 React DOM 的更新打架,發生 conflict,因此我們採用了另一個邏輯去改變這件事,維持先更新 Virtual DOM ,統一讓 React (DOM) 去更新 Browser DOM ,藉此改變 contenteditable div 會主動操作 DOM 的問題。

Virtual DOM,讓我們來拯救編輯器的效能吧!

Virtual DOM 是 ReactJS 的一個特點,他會用 JS 的 object 做出一個可以 Mapping 到 DOM 的樹狀結構,當狀態改變時,React 會做比較,看要不要 re-render,如果需要的話 react renderer 就會更新對應的區塊在 Browser DOM 上。

好處是可以提升整體的效能,如果直接操作 DOM 本身的話,新增或移除 DOM node 對於效能的負擔是比較大的。透過更新 Virtual DOM,再由 Virtual DOM 的變化去決定要不要更新 Browser DOM,就能實現效能上的提升。

但要做到這件事也不是非常容易,這是因為我們都是透過 Virtual DOM 去進行網頁的調整,但如果今天有個行為是需要直接更新瀏覽器上的 DOM ,又剛好讓 React 去更新那個區塊的話就會導致整個網頁炸掉。

目前針對這個問題的解法,就是讓打字或進行編輯動作的時候,不要直接更新到 DOM 本身,而是藉由吃下所有的事件,先更新 React DOM 再去更新 Browser 的 DOM,也就是所謂的 controlled。

我們先看目前在 Dcard 編輯器上會發生的動作,例如分為打出一般的字母(beforeinput / keydown)、打出中文字(composition),還有例如 Copy paste,或者拖拉圖片等行為,都會歸類在編輯動作上。

當使用者在編輯器上輸入一串文字之後,我們就會在定義好的資料結構內插入一個文字的 Node,因為狀態更新所以 Virtual DOM 也會更新,並遵從 React 的機制更新到 Bowser DOM 上。

因為 Dcard 的編輯器比較簡單,所以要存的 Node 就只分成文字和圖片,文字就只要存文字,圖片的話就只要存圖片的網址,再來就是上傳的進度。特別的一點是每個 node 我們都會給予一個 id,我們當成 key 會綁在 React Node 上,當發生無法解決的 conflict 時,可以藉由更新 id 來讓整個 node 重新渲染一次。

綜合上述來說,可以將 Dcard 編輯器理解成一個文字與圖片的組合,就如同下面這樣。

意想不到的難題:不會自己定位的游標

但解決了 Node 的問題後,我們還需要解決另外一件事:游標範圍(Selection)。

原因是瀏覽器中游標的位置是依賴在 DOM node 上的,今天我們打了一些字後發生了 re-render,這會讓瀏覽器原先綁定在 DOM node 上游標位置消失,因此我們會需要自己計算好游標的位置,讓他可以出現在正確的地方上。

這邊我們針對游標的顯示舉個簡單的範例:

在游標計算的流程上,假設需要插入一段文字時,第一步會先讓游標消失,因為當游標在重算的時候會先跑到最前面,使用者可能會因此覺得不舒服,所以會先讓游標消失,在算出要插入的位子時,先把文字 Node 插進去,再把游標放回來。

而要計算游標的位置,就需要操作 DOM 的 API,瀏覽器本身有提供一些 API 去計算這件事,我們要做的就是提供一個範圍(DOM Range),讓 API 理解要把游標放在哪裡,範圍用 Anchor 跟 Focus 來界定,也就是頭跟尾。

在 Anchor 跟 Focus 中,還需要另外定義 Node 跟 Offset 在哪裡,Node 的話就是目前文字在哪個 node,Offset 就是定義在 node 內的第幾個位子。

除了 DOM 本身的游標外,Editor 本身也需要存游標的位置,但這裡我們不存 node 改存行數,因為怕產生 node 移除了但 reference 還存在的情形。

同時我們也設計出了一套游標更新的機制,當 DOM 上面的游標更新時,會讓他同步更新編輯器內部存的游標位置,而編輯器的游標也會在需要時 apply 到 DOM 上面。

編輯器的背景流程是怎麼完成的?

從上面編輯器的介紹中,我們可以學到編輯器本身就是 Node 跟 Selection 的組合,我們可以把他們合成一個狀態來看,而狀態和狀態之間的變化,我們把他稱作一個 Operation。

藉由拆解編輯器的行為,我們可以定義出這七種 Operation,上面那行是操作純文字的部分,下面則是圖片相關的行為,例如插入圖片,移除圖片,或是將一張圖的狀態從預覽圖變成真正上傳後的圖。

舉個例子,像是上傳圖片之後有個 Input,我們就會把你準備上傳的圖片存成一個預覽圖在記憶體上,並插入一個 Node 在編輯器中。

下一步的話,就是開始上傳,我們會去打後端的上傳圖片的 API,當有進度更新時,我們會藉由 set_node 這個 operation 讓上傳的進度更新在 UI 上。

上傳成功後,我們也會利用 set_node 上傳好的網址換掉,並 revoke 掉預覽圖用的記憶體空間。

而這個過程就是不斷地進行許多 operation,將這些 operation 組合在一起就會變成一件有意義的事,我們可以將他表達成一個 Array Operation[],而這也就是 History Record 的基本單位。

存在裡面的意義就是因為我們需要讓瀏覽器能進行上一步和下一步的操作,因為我們不是直接操作 DOM,所以 Browser 上是不會有記錄的,需要另做一個資料結構來完成上下一步的動作。

至於要用什麼樣的資料結構來做呢?這裡需要考量兩個需求,第一個是他必須要是 cursor base 的,因為我們必須要知道誰是上一步,誰是下一步,所以需要有個 cursor 去指出現在是第幾步。而儲存的空間也是需要固定 size 的,不然無限制地存下去會導致瀏覽器炸掉。

如下圖所展示的,假設我們今天有七個存檔位置,我們會按照順序把每個步驟的操作一個一個存進去,當存滿所有的空間,要存第八個時,就會先覆蓋掉第一個存檔的位置。

因此我們可以把這個資料結構想像是一個環狀的資料結構,其中會有兩個 cursor,一個是告訴我們下一個動作發生時要存入的位置,另一個則是告訴我們目前的紀錄在哪裡。

有了上述的設計後,只要使用者觸發上一步或下一步,我們就依序執行先前存進去的操作,便可以讓編輯器回到當時的狀態。

如果使用者在按上一步後又做了新的操作呢?這時候我們會把在目前存檔之後的紀錄都先清掉,並寫入目前的操作,就和操作 word 一樣,undo 後打了一些字,就沒辦法 redo 了,把這些使用情境都考慮下去後,一個具有 undo/redo 功能的編輯器就完成了 ( ´▽` )ノ

目前 Dcard 正在徵求 Web Frontend Developer 加入我們!

我們常遇到的挑戰會是 Performance (Web Vitals)、SSR 與 i18n 相關的 issue,以及如何以良好的架構去解決實際問題。最終目的主要都是圍繞在 SEO 與使用者體驗上。團隊很追求程式品質,我們自己開發上的原則大概有 YAGNI、Boy Scout Rule、優先追求程式品質 (stability, maintainability, reusability, performance)、讓技術債可控、well-documented、持續優化 DX/CI/CD 等等⋯

歡迎隨時和我們聊聊!

--

--