深入淺出contenteditable富文字編輯器

壞男孩Dean發表於2019-03-04

富文字編輯器一直是前端領域的一個天坑,但若不是深入接觸編輯器開發的工程師,卻不一定清楚富文字編輯器到底坑在哪裡,作為有幸和編輯器打了一年交道的前端,今天來聊聊Web富文字編輯器的那些事。

通常當我們拿到一個帶有富文字編輯器的需求時,我們首先要理清這個需求的使用場景,然後我們可以為這些具體的業務場景選擇一款合適的開源富文字編輯器,進行定製開發

看看目前市面上我們可以選擇的開源編輯器的實現方式,大致分為兩種:

第一種是基於THML DOM的Contenteditable屬性來實現,代表如UEditor、tinyMec、Quill

這是使用最久的傳統富文字編輯器實現方式,這種實現方式的優勢很明顯,contenteditable是瀏覽器Dom的一個原生屬性,值為true時表示該元素變為可編輯狀態。因此原生就直接支援很多內容編輯操作,包括游標位移、內容選擇的行為、鍵盤事件(如方向鍵控制游標)等等,甚至是富文字編輯所需要用到的絕大部分實現(document.execCommand

這些原生支援使得效能和輸入體驗都非常棒,在此基礎之上進行二次開發看起來相當容易,輔以iframe技術,可以將編輯器放在一個獨立的docment物件下,與頁面的document物件分離

缺點也非常要命,以why-contenteditable-is-terrible為代表的文章,幾乎說明了一切,總結下來無非是:瀏覽器相容性差、使用者行為難以控制、難以抽象編輯器內的檢視邏輯關係並將它們對映到程式碼模型中(試想一下你要抽象一個變化規則不可掌控的可變Dom結構的邏輯關係)、游標(選區)的視覺位置與邏輯位置可能不吻合

第二種是基於自定義Model的實現,代表如:draft.js、trix

這種實現方式,簡單的來說就是定義一套編輯器內部使用的資料結構(model),與使用者在編輯器內所見的Dom檢視相對映;通過捕獲使用者的操作行為,由原先的直接操作Dom,改為更新資料結構狀態,再將更新後的狀態對映至檢視的方式,來實現編輯器的所見即所得,顯然操作行為對資料結構的更新是非常可控的

這是一種十分先進的編輯器設計理念,它幾乎拋棄了contenteditable的特性,這也意味著contenteditable所帶來的副作用都消失了

這種實現方式的另一個好處在於,它可以適用於多人線上協作的業務場景。由於使用者操作實際影響的是內部的資料結構,且每次操作產生的結果都被控制在一定範圍內,可以較為容易的通過diff演算法來合併短時間內的多次修改。

看起來這顯然是一個比contenteditable編輯器更好的選擇

遺憾的是目前這種實現方式的開源編輯器可供選擇的並不多,實際情況中可能並不能滿足所有的開發場景,比如draft.js只能基於react,而如trix這樣相對小眾的專案在國內則有些水土不服(別問我怎麼知道的),如果你目前使用的不是react或者就想要一個開箱即用的編輯器去做定製,又沒有條件自己造個輪子,在不需要考慮多人協作場景的情況下,我們依然可以從contenteditable編輯器上尋求突破


回過頭來看看contenteditable編輯器,現實情況其實也沒有那麼糟糕,畢竟這是使用最為廣泛的一種實現方式,擁有大量的實踐,這些成熟的開源專案早已為我們提供瞭解決方案

來看看它們是怎麼做的吧:

以國內熟知的UEditor為例(也是微信公眾號所用的編輯器),它的核心提供了這麼幾樣東西

dtd規則:用來規定編輯器內的dom巢狀規則,和過濾方法搭配使用,避免出現<span><p>xxx</p></span>

uNode物件:根據HTML DOM抽象而成的文件模型物件,抽象了dom的屬性和層級關係,保留了一些dom操作的方法(與第二種實現方式的自定義model類似),將編輯器內容的HTML對映過來之後可以很方便的執行規則過濾,如剔除冗餘屬性和非白名單標籤等

Range物件:游標和選區的資訊物件,記錄了 當前游標(選區)的開始、結束邊界的容器節點和偏移量以及當前游標(選區)的閉合狀態,還提供了一系列對游標(選區)操作的API

EventBase:提供註冊、銷燬和觸發自定義事件監聽器的方法,用來生成一些鉤子

execCommand指令集:document.execCommand增強版,執行指令的通用介面,富文字格式操作的核心,提供了一系列指定命令的執行和狀態查詢方法(如對選區內容執行字型加粗命令、查詢當前選區內容是否處於加粗狀態)

undoManager:撤銷重做的堆疊,記錄內容變化過程

domUtils:Dom操作方法集

可以利用上面這些核心方法組合出一些實用的工具,比如在UEditor中非常重要的過濾規則體系,就是利用了eventBase與uNode的組合實現的(通過對eventbase封裝了註冊規則的方法和執行過濾的方法,引數就是根據編輯器內容的dom轉化而來的uNode物件,基於該物件執行具體的過濾)

整個UEditor正是圍繞著這些核心方法構建的,並且在此基礎上提供了大量的API以便開發者進行定製化的開發,顯然作為一個contenteditable編輯器它已經足夠成熟了

但在實際的生產環境中,面對不同的產品需求我們依然需要處理一些棘手的情況

固定結構內容

一個常見的場景是,固定結構內容,比如圖片與圖片註釋

這就是一個典型的固定結構內容,編輯器中出現了一個不可更改的固定搭配,即圖片後面必須跟著註釋輸入框

來看看要實現這個需求需要考慮哪些要問題

  1. 圖片和註釋元素必須一對一
  2. 圖片和註釋元素的位置順序不能改變
  3. 游標不允許插入到固定結構中間
  4. 游標可以定位在註釋元素裡
  5. 註釋元素裡只能放純文字

contenteditable編輯器的設計原則之一是編輯器內的一切內容皆可自由編輯,而固定結構元素某種程度上違背了這一原則,這會帶來很多問題,使用者有太多方法可以破壞你預設的結構

一種常見的解決方案是將固定結構的元素包裹在一個不可編輯元素內,併為其中的可互動元素獨立設定互動事件(比如點選輸入、貼上內容過濾)

但這還不夠,有幾個問題:

  1. 編輯器中存在不可編輯元素,會有瀏覽器相容性的問題,如火狐瀏覽器下游標無法正確移動甚至無法刪除這個元素
  2. 兩個不可編輯器的塊級元素在相鄰位置時,游標無法插入中間,退格鍵也會同時刪除多個
  3. 複製貼上這個內容,結構可能會錯亂
  4. 其他操作也可能會破壞結構

為了解決上述問題,就需要劫持使用者的游標操作(滑鼠點選、方向鍵、退格鍵),同時設立一套結構規則來檢查當前結構是否有錯亂

預覽一下效果

簡而言之,就是通過劫持,判斷游標是否處於不可編輯元素的最近位置,符合條件時,用自定義行為代理瀏覽器預設的選擇、刪除、複製剪下等行為,再通過對游標移動事件(onSelectionChange)的監聽,檢查內容中的固定結構是否符合規則(如兩個不可編輯元素之間必須至少存在一個用於插入游標的空行標籤等)

面對固定結構內容,根據不同的使用場景,可以有兩種解決方案,

對於結構簡單但需要進行互動的場景,就像圖片註釋那樣,可以使用前面提到的contenteditable=false+行為劫持+過濾規則的方式實現

對於結構較為複雜但不需要進行互動或互動場景較為簡單的情況,則可以使用canvas來實現

使用canvas的好處是不用擔心結構問題,這完全就是一張圖片,如果在文章釋出後需要其他互動也可以在詳情頁將之轉化為正常的DOM結構,缺點是生成的圖片需要上傳至圖片伺服器這會佔用額外的儲存資源

另一個需要考慮的問題是在safari瀏覽器下如果畫布上有其他域過來的圖片,就算設定了允許跨域也會被safari的安全策略block[SecurityError (DOM Exception 18): The operation is insecure.],這就可能需要使用本地佔點陣圖來解決

可以根據實際情況來選擇解決方案

游標

除此之外,UE也存在一些作為contenteditable編輯器的通病,一個最常見的問題就是游標的視覺位置與邏輯位置的問題

試想有這麼一段標紅的粗體文字

當我們將游標放在這段文字的開頭,我們會發現,游標的實際位置有4種可能

  • |<p><span ...
  • <p>|<span class="font-color-red-01">...
  • <p><span class="font-color-red-01">|<strong>...
  • <p><span class="font-color-red-01"><strong>|text content

儘管視覺上的表現沒有什麼區別,但游標在不同位置時使用者進行某些操作就會產生不同的結果

原本我們只是想用退格鍵將標題上移一行,但由於游標位置在<h1>|...</h1>的位置上,結果將標題的格式也給清空了

解決方法也很簡單,還是 劫持=>判斷=>代理,這也是編輯器對游標進行嚴格控制的通用解決方案

撤銷重做堆疊

撤銷重做堆疊也是一個問題,正常情況下undoManager會按照一個最小時間段自動記錄每一次的內容變化,以便使用者撤銷回上一步的狀態,但這也會帶來一些問題,試想一個這樣的場景

我們從本地插入一張圖片,這張圖片最終需要上傳到伺服器上,所以我們先在編輯器內插入了一個佔點陣圖,然後開始上傳本地圖片,等伺服器返回了正確的圖片地址後,再將正確的圖片元素替換到佔點陣圖所在的位置上,順便為圖片新增圖片註釋的元件

那麼 (插入佔點陣圖 => 上傳圖片 => 替換佔點陣圖 => 新增附加元件)就是一個完整的事件流,如果undoManager單獨記錄了這個事件流中每一個步驟,當使用者執行撤銷操作的時候就會出現問題

因此我們需要為自動記錄設定一個暫停開關,這樣就可以控制undoManager的記錄時機

生命週期鉤子

為了使編輯器更加穩定,我們還可以通過eventBase來設計某些事件的生命週期鉤子

比如可以分發撤銷、重做操作完成前後的回撥來做一系列額外的處理,也可以對圖片上傳的過程分發鉤子函式


富文字編輯器的話題其實遠不止上面這些,比如如何優雅的與編輯器內元素進行互動,如何由State驅動Dom,如何做移動端的適配,表格操作等等,每一點都可以深入探討,篇幅有限,這裡就不再展開


總結一下,基於contenteditable編輯器穩定可靠的定製開發要注意的幾個點


  1. 嚴格控制內容(格式規則檢查、內容輸入和輸出過濾)
  2. 嚴格控制游標(劫持、檢查、代理)
  3. 控制撤銷重做堆疊
  4. 為一些關鍵操作新增生命週期鉤子


相關文章