谈谈浏览器中富文本编辑器的技术演进
作者簡介:劉楊,抖音前端團隊低代碼平臺核心開發者。
發展歷程
富文本編輯器按發展歷程而言,分為 L0、L1、L2 三個階段,每個階段都比上一個階段定制程度更高,由瀏覽器導致的問題也更少(因為強依賴瀏覽器 API 的情況更少),同時開發難度也更大。本文將詳細講解各個階段,然后列舉一些相關的產品來加以說明。
L0 階段
這是富文本編輯器的早期階段,這個階段的編輯器強依賴于 DOM API,包括:
可編輯內容依賴 contenteditable API;
編輯內容使用 document.execCommand API。
并且沒有抽象的數據模型來描述富文本編輯器的內容與狀態。這個階段的編輯器有大名鼎鼎的 UEditor,也有 CKEditor 1 - 4,至今許多郵件編輯器也依然處于這個階段。
Content Editable
L0 階段的編輯器主要就是依賴于 Content Editable API 來實現功能的。首先,任何 HTML 元素加上 contenteditable="true" 之后里面的內容都可以被編輯,然后,如果想點擊某個按鈕來操控這些內容,則可以通過瀏覽器提供 document.execCommand API 來實現。document.execCommand 支持的操作類型多種多樣,包括加粗、改背景、綁定鏈接、復制、剪切等等。
查看有哪些 command[1]。(鏈接見文末)
優勢
L0 階段的編輯器主要優勢有:
技術門檻低;
基于瀏覽器原生編輯能力,輸入非常流暢;
沒有令人頭疼的組合輸入問題,這點會在后文中詳細說明。
劣勢
當然,這個階段的劣勢也比較明顯。
劣勢一
首先,第一個問題就是不同瀏覽器對于同一個操作有不同的實現,導致視覺與實際 DOM 存在一對多的關系。比如對于設置 粗斜體 這個操作,不同瀏覽器的實現的 DOM 就不同,可能存在以下情況:
<strong><em>粗斜體</em></strong> <em><strong>粗斜體</strong></em> <strong><em>粗</em></strong><strong><em>斜</em></strong><strong><em>體</em></strong> <em><strong>粗</strong></em><em><strong>斜</strong></em><em><strong>體</strong></em>這種結構最直接的影響就是樣式不好設置,上例還好,有些操作會用不同標簽實現,這樣一來就得兼容多個瀏覽器,給多個標簽設置樣式。
劣勢二
第二個問題是不同瀏覽器的選區實現也不同,導致視覺選區與實際 DOM 選區之間存在多對多的關系。
再以選中 粗斜體 的斜體為例,視覺上選中了斜體,實際上就不清楚了,假設 DOM 結構是 <strong><em>粗斜體</em></strong>,那么就存在下列可能:
選中了 粗斜體 ,如果做刪除操作,會遺留標簽;
選中了 <em>粗斜體</em>,如果做刪除操作,也會遺留 strong 標簽;
選中了 <strong><em>粗斜體</em></strong>。
再結合上面多種 DOM 結構,每種都可能有多種選區,那么我們要做的兼容處理就更加復雜。
劣勢三
第三個問題是光標放置的位置也是不確定的。
假設在 粗斜體 前插入 i 字符,實際上可能是這些情況:
i粗斜體 => <strong>i<em>粗斜體</em></strong>;
i粗斜體 => <strong><em>i粗斜體</em></strong>;
i 粗斜體 => i<strong><em>粗斜體</em></strong>。
劣勢四
第四個問題發生在復制粘貼操作上,永遠都不可能確定從別的地方復制過來的內容粘貼到 L0 階段的編輯器上會發生什么,因為:
復制的內容的 HTML 標簽充滿著可能性;
永遠不知道瀏覽器是怎么處理這些標簽的。
劣勢五
沒有辦法實現協同。
總結
隨著瀏覽器的演變,上述問題可能有些已經越來越趨向于統一,但仍不可避免的還會有許多,更關鍵的是不受控制,BUG 隨時可能會出現,修修補補也將會越來越多,項目也會越來越難以維護。因此,L1 階段的編輯器就應運而生。
L1 階段
這個階段是大多數現在富文本編輯器所處在的階段,比如 Quill,CKEditor 5,Slate,Draft.js 等等,它最明顯的兩個特點是:
仍然依賴于 contenteditable API 來使得內容能編輯,但是不再依賴 document.execCommand API 來操作內容,而是改為自己實現。
有抽象的數據模型來描述富文本編輯器的內容與狀態。
Modal - 編輯器內容的抽象
上面說到,L1 階段的編輯器會有抽象的數據模型來描述富文本編輯器的內容與狀態,這個數據模型就被稱為 Modal,當然,不同編輯器給取的名字會不一樣,下面舉兩個編輯器來說明。
L1 階段的鼻祖 Quill 的 Modal
例如下面這段話:
A
B C D
在 Quill 就會用這樣一個數據結構去表示:
{"ops":?[{"insert":?"A\nB?"},{"insert":?"C","attributes":?{"bold":?true}},{"insert":?"D"}] }Quill 的 Modal 基于 OT 模型,有 ?retain,insert,delete 三種操作類型,使用可選的 attributes 屬性來標記內容的一些特性。這種模型使得它天生就支持協同。Quill 管這個 Modal 叫 Delta。
Quill 拋棄了 DOM 的節點樹的層次,因此完全看不出包裹文字的標簽和節點關系,只有一個扁平化后的數組 ops。幾乎所有的 L1 階段的富文本編輯都會做或多或少的扁平化,Quill 是最徹底的那一類。當然,扁平化帶來的好處是對性能提升有幫助,弊端則是在表示一些復雜的嵌套內容時會比較吃力,比如在表格的單元格中插入另一個表格。
Slate 編輯器的 Modal
Slate 保留了 DOM 的樹形結構,因此節點的層次關系是比較直觀明了的。下面是上面那個例子的表示:
[{"type":?"paragraph","children":?[{"text":?"A"}]},{"type":?"paragraph","children":?[{"text":?"B?"},{"text":?"C","bold":?true},{"text":?"?D"}]} ]View - 將 Modal 渲染出來
View 層類似 React 中的 render,將 Modal 數據給渲染出來,渲染出來的內容包括編輯器內的內容和選區等。這樣一來,就能夠自己決定什么樣的內容輸出什么樣的 DOM 結構,不依靠瀏覽器的實現,從而避免 L0 中 DOM 結構多樣性的問題。不同編輯器對 View 層的稱呼不一樣,比如 Slate 就稱之為 Rendering。如今,很多編輯器都基于 VM 框架(如 React)來實現 View 層。
Selection 的進一步封裝
無論是 L0 還是 L1 階段,選區都需要在原生 Selection API 的基礎上進行封裝來實現。原生的 Selection 對象是由多個 Range 對象組成的,Range 對象內包含以下四個屬性:
anchorNode:代表鼠標開始按下處的文字或葉子節點;
anchorOffset:代表鼠標開始按下處的文字在文字節點中的第幾個或葉子節點之前的同級節點數;
focusNode:代表鼠標松開時的文字或葉子節點;
focusOffset:代表鼠標松開時的文字在文字節點中的第幾個或葉子節點之前的同級節點數。
相比 L0 階段,L1 階段的編輯器會將 Modal 的一些數據封裝到 Selection 對象中去。下面舉幾個編輯器的例子來說明。
Quill 的抽象
Quill 的 Selection 與原生的不同,只有一個 Range 對象。Range 也只由 { index, length } 這種極其簡單的數據組成:
index 表示選區開始的內容距離開頭的絕對位置;
絕對位置的計算就是把當前內容之前所有的內容數量都加起來;
單個文字、圖片、視頻等這樣的內容都是按數量 1 來計算的。
length 表示選中區域的內容的數量。
Quill 的 Range 能這么設計與它的 Modal 設計是強相關的。
Slate 的抽象
Slate 的 Selection 對象也只有一個 Range 對象。它 的 Selection 對象參考了原生的實現,有 anchor 和 focus 兩個對象。
anchor 對象由 path 和 offset 屬性組成,path 相當于 anchorNode 的角色,用來確定節點的位置 offset 相當于 anchorOffset,用來確定文字等內容在節點中的位置,focus 也類似。
例如下例中的 Text 節點的 path 是 [0, 1, 0],然后假設 Text 節點的內容是 123,那么其中 2 的 offset 就是 1。
上圖中,每一層最左邊的節點都標記為 0,然后向右依次加一,這樣,任何一個節點的位置都能通過一個編號數組給唯一確定。
總結
L1 階段的編輯器對 Selection 的封裝方式使得選區也有一種數據結構,從而使得同一份數據結構有唯一的渲染,避免 L0 中選區的問題。
Commands - 能被稱為 L1 階段的核心要點
L1 階段的編輯器摒棄了瀏覽器的 document.execCommand,從而完全自己來實現對編輯器內容的操作,它能在很大程度上避免 L0 中瀏覽器操作的不確定性。
事件監聽
L1 階段的編輯器大都會通過事件監聽來猜測用戶想要對內容的操作。這種方式的好處顯而易見:通過監聽 DOM 事件來操作 Modal,從而能保證唯一的渲染。
如果用戶點擊編輯器上的按鈕,那么這種操作就是非常確定的,因為按鈕的作用是我們自己定的,比如加粗、改成斜體等。
如果用戶在編輯區域進行輸入的話,那么可以通過 beforeinput 之類的事件知道用戶準備輸入什么。但這種方法依然會遇到一些問題,最大的問題就是上文中提到的組合輸入問題。
組合指的就是 操作系統、輸入法、瀏覽器 加在一起的組合。一個用戶輸入的內容經過這三者才到達我們的事件監聽函數中。那么其中任何一環出現差錯就可能會帶來如下問題:
不是所有的用戶操作都有事件支持,例如某些安卓機輸入法的聯想詞;
有些操作觸發的事件是錯誤的,例如用戶在輸入文字,但識別成刪除文字;
瀏覽器對事件支持的兼容性問題,例如 beforeinput 就有些瀏覽器不支持,或支持不全面。
DOM 變更監聽
除了事件監聽,另一個方式就是 DOM 變更監聽。我們依然使用 contenteditable="true" 來使得編輯器內容是可以被編輯的。然后使用 Mutation Observer[2] 來監聽編輯器內容的變化,接著根據內容的變化反推用戶的操作,從而修改 Modal,最后再次根據 Modal 渲染一遍編輯器內的內容,確保內容的確定性。
這種方式彌補了一些事件監聽的不足,但仍然有缺陷:
反推的過程完全是根據經驗,難免有經驗不足,某些情況沒考慮到的情況;
可能發生錯誤的推測,造成錯誤的渲染。
使用 VM 框架帶來的一些問題
上文中說到現在很多編輯器會使用 VM 框架來做渲染,這就使得 Modal 和 View 之間還存在 Virtual DOM。但由于 contenteditable 能繞開 VM 框架直接操作 DOM,所以可能會遇到狀態不一致或者渲染錯誤的問題,這就需要去花時間解決。
Slate 的 Commands 實現
Slate 完全重寫了 Commands API,它是結合事件監聽和 Mutation Observer 一起來實現的,它自己定義了一套 Transform API 去更改 Modal,使用者可以自定義 Modal 到 DOM 的渲染邏輯。它的渲染層可以是 React,也可以是其他框架。
Operations - 多人協同必備
Operations 就是記錄了一系列原子化操作的數組。以 Quill 為例,它的 Operations 的數據結構與 Modal 一致(上文提到過 Quill 的 Modal 就是一系列原子化的操作),不過與 Modal 描述當前內容不同,Operations 記錄了所有的原子化操作,也可以說 Modal 是 Operations 經過一定的轉換(例如合并一些項)得到的。
Operations 對實現 OT 算法很有幫助。OT 算法本質上是將不同用戶的原子化操作合并的過程,目的是為了解決多人編輯之間的沖突。Quill 的 OT 算法在 quill-delta 庫中實現的。
要了解 OT 算法可以 閱讀這篇文章[3]。(鏈接見文末)
優勢
L1 階段的編輯器總結下來有這么一些優點:
從原理上解決了大部分 L0 中各種不確定的問題,減少了 BUG 的產生;
能支持多人協同;
能滿足大部分使用場景。
劣勢
相比 L0 階段,引進了一些組合輸入問題;
相比 L0 階段,引進了可能要處理一些 DOM 變更監聽的未知性問題;
依然擁有 contenteditable 的一些問題,比如光標的兼容性問題等等。
L1 階段富文本編輯器的比較
下面舉了一些富文本編輯器的例子做了些簡單的比較:
| Quill | 首創的 Delta 抽象數據模型 | 數據模型是線性的,無法較容易的支持復雜的嵌套場景,未來可能有大版本改動 |
| Prosemirror | 強大的定制能力 | 新概念和 API 比較多,有著高昂的學習上手成本 |
| Draft.js | React ?友好的編輯器 | 數據模型是線性的,無法較容易的支持復雜的嵌套場景 |
| CKEditor 5 | 強大的功能和定制能力 | 幾乎沒有缺點 |
| Slate | 強大的數據模型,靈活的設計,理論上能實現任何強大的功能 | 底層重構多次,目前版本可能還不穩定 |
L2 階段
Google Docs 的問世開創了 L2 級別編輯器的時代,它完全不依賴 Content Editable API,包括選區、光標等,都是是自己繪制的,甚至自己實現了一個基于元素和絕對定位的排版引擎,基本上脫離了瀏覽器自身的大部分排版規則,可以說是非常復雜了。這樣做帶來的好處是顯而易見的:
所有瀏覽器無論做什么操作進行選區的選中,都能夠保持一致性,例如在不同瀏覽器中雙擊選中,可能有些瀏覽器選中的是一個詞語,有些瀏覽器選中的是一句話,這是由瀏覽器自身邏輯決定的,而自己繪制則完全能避免這些問題;
不會有光標的兼容性問題,比如光標位置問題偏移、光標顯示不正常等等,自行繪制光標無論是在哪個瀏覽器下,都是一致的;
不依賴于瀏覽器大部分的排版,雖然說 L1 階段我們可以控制渲染的 DOM 結構和 CSS,但仍然依賴瀏覽器去排版繪制,像 Google Docs 這種直接基于絕對定位等少量的瀏覽器特性去自行計算各元素的位置,進行排版,就能極大程度上避免瀏覽器在排版上的問題。
同時,像分頁、標尺、腳注等一些高級功能在有了自己的排版引擎后實現起來就比較簡單了。當然,這個階段的編輯器已經極為復雜了,一般的團隊不需要自研這么復雜的編輯器,也用不到。
未來
Google 在今年發表了一篇文章 Google Docs will now use canvas based rendering: this may impact some Chrome extensions[4],宣布將來將進一步脫離 DOM,使用 Canvas 來進行渲染。對于 Google Doc 這樣一種極為專業的編輯器來說,這是肯定要走的路,因為上面也說到了,Google Doc 并沒有完全脫離瀏覽器的排版與繪制,另外,像一些字體的渲染仍需要利用瀏覽器基本的實現,這可能導致不同瀏覽器的效果存在差異。使用 Canvas 來自己繪制會進一步減少這種問題。另一條需要關注的路線是瀏覽器的實現逐漸靠著標準進行統一,說不定哪天很多功能又能重回原生了!
參考資料
[1]
查看有哪些 command: https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand#%E5%91%BD%E4%BB%A4
[2]Mutation Observer: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
[3]閱讀這篇文章: https://nicodechal.github.io/2020/08/10/ot-js-transform-analysis/
[4]Google Docs will now use canvas based rendering: this may impact some Chrome extensions: http://workspaceupdates.googleblog.com/2021/05/Google-Docs-Canvas-Based-Rendering-Update.html
往期推薦
2021 TWeb 騰訊前端技術大會精彩回顧(附PPT)
面試題:說說事件循環機制(滿分答案來了)
專心工作只想搞錢的前端女程序員的2020
最后
歡迎加我微信,拉你進技術群,長期交流學習...
歡迎關注「前端Q」,認真學前端,做個專業的技術人...
點個在看支持我吧
總結
以上是生活随笔為你收集整理的谈谈浏览器中富文本编辑器的技术演进的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 逐梦路上,坚定信念
- 下一篇: CPP----C++练习100题