富文本編輯器一直是前端領域的一個天坑,但若不是深刻接觸編輯器開發的工程師,卻不必定清楚富文本編輯器到底坑在哪裏,做爲有幸和編輯器打了一年交道的前端,今天來聊聊Web富文本編輯器的那些事。前端
一般當咱們拿到一個帶有富文本編輯器的需求時,咱們首先要理清這個需求的使用場景,而後咱們能夠爲這些具體的業務場景選擇一款合適的開源富文本編輯器,進行定製開發react
看看目前市面上咱們能夠選擇的開源編輯器的實現方式,大體分爲兩種:算法
第一種是基於THML DOM的Contenteditable屬性來實現,表明如UEditor、tinyMec、Quillcanvas
這是使用最久的傳統富文本編輯器實現方式,這種實現方式的優點很明顯,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編輯器它已經足夠成熟了
但在實際的生產環境中,面對不一樣的產品需求咱們依然須要處理一些棘手的狀況
一個常見的場景是,固定結構內容,好比圖片與圖片註釋
這就是一個典型的固定結構內容,編輯器中出現了一個不可更改的固定搭配,即圖片後面必須跟着註釋輸入框
來看看要實現這個需求須要考慮哪些要問題
contenteditable編輯器的設計原則之一是編輯器內的一切內容皆可自由編輯,而固定結構元素某種程度上違背了這一原則,這會帶來不少問題,用戶有太多方法能夠破壞你預設的結構
一種常見的解決方案是將固定結構的元素包裹在一個不可編輯元素內,併爲其中的可交互元素獨立設置交互事件(好比點擊輸入、粘貼內容過濾)
但這還不夠,有幾個問題:
爲了解決上述問題,就須要劫持用戶的光標操做(鼠標點擊、方向鍵、退格鍵),同時設立一套結構規則來檢查當前結構是否有錯亂
預覽一下效果
簡而言之,就是經過劫持,判斷光標是否處於不可編輯元素的最近位置,符合條件時,用自定義行爲代理瀏覽器默認的選擇、刪除、複製剪切等行爲,再經過對光標移動事件(onSelectionChange)的監聽,檢查內容中的固定結構是否符合規則(如兩個不可編輯元素之間必須至少存在一個用於插入光標的空行標籤等)
面對固定結構內容,根據不一樣的使用場景,能夠有兩種解決方案,
對於結構簡單但須要進行交互的場景,就像圖片註釋那樣,可使用前面提到的contenteditable=false+行爲劫持+過濾規則的方式實現
對於結構較爲複雜但不須要進行交互或交互場景較爲簡單的狀況,則可使用canvas來實現
使用canvas的好處是不用擔憂結構問題,這徹底就是一張圖片,若是在文章發佈後須要其餘交互也能夠在詳情頁將之轉化爲正常的DOM結構,缺點是生成的圖片須要上傳至圖片服務器這會佔用額外的存儲資源
另外一個須要考慮的問題是在safari瀏覽器下若是畫布上有其餘域過來的圖片,就算設置了容許跨域也會被safari的安全策略block[SecurityError (DOM Exception 18): The operation is insecure.],這就可能須要使用本地佔位圖來解決
能夠根據實際狀況來選擇解決方案
除此以外,UE也存在一些做爲contenteditable編輯器的通病,一個最多見的問題就是光標的視覺位置與邏輯位置的問題
試想有這麼一段標紅的粗體文本
當咱們將光標放在這段文字的開頭,咱們會發現,光標的實際位置有4種可能
儘管視覺上的表現沒有什麼區別,但光標在不一樣位置時用戶進行某些操做就會產生不一樣的結果
本來咱們只是想用退格鍵將標題上移一行,但因爲光標位置在<h1>|...</h1>的位置上,結果將標題的格式也給清空了
解決方法也很簡單,仍是 劫持=>判斷=>代理,這也是編輯器對光標進行嚴格控制的通用解決方案
撤銷重作堆棧也是一個問題,正常狀況下undoManager會按照一個最小時間段自動記錄每一次的內容變化,以便用戶撤銷回上一步的狀態,但這也會帶來一些問題,試想一個這樣的場景
咱們從本地插入一張圖片,這張圖片最終須要上傳到服務器上,因此咱們先在編輯器內插入了一個佔位圖,而後開始上傳本地圖片,等服務器返回了正確的圖片地址後,再將正確的圖片元素替換到佔位圖所在的位置上,順便爲圖片添加圖片註釋的組件
那麼 (插入佔位圖 => 上傳圖片 => 替換佔位圖 => 添加附加組件)就是一個完整的事件流,若是undoManager單獨記錄了這個事件流中每個步驟,當用戶執行撤銷操做的時候就會出現問題
所以咱們須要爲自動記錄設置一個暫停開關,這樣就能夠控制undoManager的記錄時機
爲了使編輯器更加穩定,咱們還能夠經過eventBase來設計某些事件的生命週期鉤子
好比能夠分發撤銷、重作操做完成先後的回調來作一系列額外的處理,也能夠對圖片上傳的過程分發鉤子函數
富文本編輯器的話題其實遠不止上面這些,好比如何優雅的與編輯器內元素進行交互,如何由State驅動Dom,如何作移動端的適配,表格操做等等,每一點均可以深刻探討,篇幅有限,這裏就再也不展開
總結一下,基於contenteditable編輯器穩定可靠的定製開發要注意的幾個點