有道雲筆記跨平台富文本編輯器的技術演進
來自專欄網易雲社區
本文來自網易雲社區。
使用過有道雲筆記的讀者會發現,該App在windows、Mac OS、桌面瀏覽器(webkit內核)、iOS、Android等終端提供了富文本編輯能力。在不同終端實現基本一致的編輯能力,這是如何做到的呢?
跨平台架構設計這必須從有道雲筆記的富文本編輯器的基本架構說起。- HTML+CSS 特性豐富,布局靈活,適合展現文本,圖片等富文本內容。
- 瀏覽器的
contenteditable
特性支持富文本的編輯,適合開發編輯器。 - 可跨平台開發,不同平台編輯器的核心代碼基本可以復用,降低開發成本。
- Native App 具有更高的許可權,當HTML+CSS+JavaScript能力受限時,可由Native App 提供介面來補充。
有道雲筆記編輯器的迭代
宿主環境(瀏覽器/WebView)的挑選為編輯器提供了良好的運行環境,而編輯器的好壞取決於如何設計與實現編輯器。在發展過程中,有道雲筆記共自研發了三代編輯器,每一代的設計與實現各不相同。編輯器持久存儲層編輯時數據層視圖層是否依賴WebView的特性第一代HTMLHTML/DOM 樹無特殊依賴第二代HTMLHTML/DOM 樹contenteditable
第三代XMLNote/BlockNoteView/BlockView不依賴contenteditable
第一代編輯器
contenteditable
特性且無CrossWalk這類的項目,故無法基於contenteditable
實現富文本編輯功能,不得不採用了類似普通網頁的交互形式來實現簡單的文本編輯。WebView渲染內容(HTML),當用戶點擊在渲染視圖上時,點擊處的 HTML元素會將其innerText
發給 Native App,然後Native App 調用系統原生控制項進行純文本編輯。待編輯完成後,Native App將編輯後的文本發給編輯器,編輯器更新視圖。contenteditable
的特性——這是主流web富文本編輯器採用的技術,比如國外的CKEditor、TinyMCE,國內的UEditor、KindEditor。瀏覽器的contenteditable
特性為富文本編輯提供了較為強大的功能,document.execComamnd
API提供了較多的命令,支持文本編輯,格式編輯,插入超鏈接/圖片。但不同瀏覽器編輯功能的實現有差異,且存在bug;再者,有些編輯命令未必符合產品需求,因此,不可避免的需要自實現部分(或全部)編輯命令。採用這一技術的編輯器特點是:
- 依賴瀏覽器的
contenteditable
的特性 - 特性豐富,性能較好,功能較為強大
- 操作的數據是HTML/DOM樹,數據與視圖沒有分離,都是同一份內存數據
- 對HTML的兼容性好
- 命令執行依賴瀏覽器
document.execCommand
API,雖然自實現部分或者全部命令,但依然存在難於解決的bug, 也不便於實現協同編輯、類似Word分頁等功能。
第三代編輯器
因此,在2015年,編輯器團隊對編輯器進行重新思考與定位,開始了第三代編輯器的探索。
不同於前兩代編輯器,第三代編輯器在存儲層採用了XML對數據及格式進行嚴格定義。編輯器運行時,將XML轉換成JavaScript對象表示的數據層。視圖層與數據層進行了分離,負責視圖渲染及交互輸入。第三代編輯器不再依賴瀏覽器的contenteditable
特性,命令執行不再依賴document.execCommand
API。數據、選區(Range/Selection)、編輯命令、視圖渲染等所有組件完全由編輯器自己定義和實現——這使得編輯器更加可控,但也導致編輯器更複雜,增加了開發的難度和成本。基於contenteditable 的編輯器實現基於contenteditable
的第二代編輯器主要有以下幾個核心:
- Range/Selection
document.execCommand
- undo/redo
- 內容過濾
- 與Native App的通信
Range/Selection
無論是基於contenteditable
還是超越contenteditable
的編輯器都會有Range的概念。Range 翻譯過來是範圍,幅度的意思,與數學上的概念——區間——類似。在objective 中有NSRange的概念,常用來描述字元串的中一段連續的範圍。類似的,瀏覽器提供的Range 用來描述DOM樹中的一段連續的範圍。startContainer, startOffset描述Range的起始處,endContainer, endOffset描述Range的結尾處。當一個Range的起始處和結尾處是同一個位置時,該Range就處於collapsed狀態。當給一段文本進行操作(比如加粗)時,必須使用Range來描述這段文本。Selection(選區)管理整個頁面當前的Range及Range的繪製。當Selection中的Range處於collapsed狀態時,即是日常所說的游標。游標其實是Selection的一種特殊狀態。在有道雲筆記編輯器中,由於只兼容webkit內核的瀏覽器環境,故不存在Range/Selection的兼容性問題。
document.execCommand
來對選定的內容進行編輯修改。bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)如需要對選定內容設置為紅色,只需要執行document.execCommand("foreColor", false, "red")
即可。瀏覽器原生的命令
- 未必符合產品需求,如
fontSize
命令只能傳入1-7
的參數,無法傳入類似10px
這樣的參數。 - 本身實現有bug
因此,編輯器需要複寫部分或全部命令,新增命令以及管理命令,提供類似document.execCommand
的editor.execCommand介面。
document.execCommand
對內容修改時,瀏覽器內部會對該contenteditable
區域維護一個undo/redo棧,使得每一個修改行為可以撤銷和重做。
如果一旦使用了document.execCommand
之外的DOM API修改內容,就會破壞undo/redo棧的連續性,導致撤銷和重做出錯或失效。比如,使用jQuery查找一個元素,其Sizzler引擎在查找過程中可能會對HTML元素添加屬性,並在查找完成後刪除新添加屬性。在該過程中,Sizzler使用了DOM API操作添加和刪除屬性,會導致瀏覽器內部的undo/redo出錯。
contenteditable
的編輯器使用打標記(Marker)的方式來實現undo/redo。在有道雲筆記的編輯器中,由於沒有複寫全部的命令,難於使用打標記的方式,故另闢蹊徑——使用HTML內容與Range快照的方式來實現undo/redo。要實現HTML內容與Range快照,就必須實現HTML內容與Range的序列化和反序列化。其中值得注意的一點是,Range無法單獨序列化和反序列化,必須與HTML內容綁定在一起。內容修改是通過執行命令完成的,一個或者多個命令的執行過程可以抽象成一個Operation
,每個Operation
對象會持有:
snapshotBefore
:修改前的HTML內容與Range快照snapshotAfter
: 修改後的HTML內容與Range快照
當執行修改動作後,Operation
被壓入undo棧。執行undo時,Operation
從undo棧彈出,然後snapshotBefore
被恢復到編輯器中,最後Operation
被壓入redo棧。執行redo時,Operation
從redo棧彈出,snapshotAfter
被恢復到編輯器中,最後Operation
壓入undo棧。
Operation
——這限制了撤銷和重做的次數。在PC/Mac/iOS/Android平台,Native App 可以提供持久化存儲介面。因此,可以將超出個數限制的Operation
序列化,通過Native App提供的介面保存到持久化存儲層。當內存中的Operation
個數不夠時,從持久化存儲層中獲取數據,反序列化成Operation
,並放入undo棧中。通過這種方式,可以突破內存大小的限制,實現無限次撤銷與重做,尤其適合對App內存大小有嚴格限制的移動端。
內容過濾
由於HTML特性豐富,靈活多變,因此需要對輸入的HTML內容供進行過濾處理。粘貼過來的內容,需要特殊處理,尤其是從Word,Excel粘貼過來的內容。對HTML過濾有兩種方式:- 使用正則表達式對HTML字元串進行過濾
- 將HTML字元串解析成DOM樹後進行過濾
其中,將HTML字元串解析成DOM樹時,應當使用DOMParser
API, 而不是簡單地將HTML賦給臨時元素的innerHTML。使用DOMParser
API 的主要好處是:
- 防止
<script/>
標籤的執行,避免XSS攻擊 - 防止圖片等資源的自動載入
以上兩種方式可以綜合起來,靈活運用。
HTML的過濾機制有兩種:- 白名單
- 黑名單
推薦使用白名單機制對HTML內容進行系統嚴格地過濾,對可接收的標籤,屬性,樣式都嚴格限制。
與 Native App的通信無論在哪個平台,編輯器都需要與對應的Native App進行通信。編輯器提供setContent
/getContent
等介面供Native App調用,Native App 則提供requestImageThumb
, requestInsertImage
等介面供編輯器調用。與Web App相比,Native App有更好的性能和可靠性,可訪問各種設備,如持久存儲、相冊相機、震動器。Native App提供的介面極大豐富了編輯器的能力,能夠實現無限次撤銷重做、插入圖片/視頻、圖像糾偏、手寫筆記等功能。
contenteditable
特性實現的編輯器存在無法根除的bug,難於實現協同編輯、類似Word的分頁等功能,有道雲筆記編輯器團隊重新思考與設計編輯器,開發了第三代編輯器。與第二代相比,第三代編輯器的主要特點是:
- 使用XML嚴格定義了數據
- 編輯時,數據層與視圖層分離
- 不依賴瀏覽器原生的Range/Selection,自實現
NoteRange
/NoteSelection
及其繪製 - 不依賴
contenteditable
特性,使用中間層對接輸入法 - 不依賴
document.execCommand
, 自實現全部命令及命令的管理 - 細粒度的undo/redo,佔用更少的內存
- 更加可控,擴展性更強,有利於實現協同編輯、類Word分頁等功能
XML定義數據
HTML特性豐富,靈活多變,不利於嚴格定義數據,而JSON又缺少描述文檔結構的定義。XML適合用來結構化文檔和數據,適應性強且通用——不但能夠被瀏覽器支持,而且在其他端得到了廣泛的應用和支持。在定義數據結構時,可以使用XML Schema描述XML文檔結構。比如在有道雲筆記中,一個段落被抽象成paragraph
標籤,其下有以下子標籤:
text
: 表示段落中的文本數據inline-styles
: 表示段落中的文本的格式,比如字體, 字型大小, 顏色, 背景色styles
: 表示整個段落的格式,比如行高, 縮進
比如,上圖所示的帶格式文本,使用XML可描述為:
<paragraph> <text>Think Diffent</text><inline-styles>
<bold> <from>6</from> <to>13</to> <value>true</value> </bold> <italic> <from>0</from> <to>5</to> <value>true</value></italic>
<font-size> <from>0</from> <to>5</to> <value>22</value> </font-size> <font-size> <from>6</from> <to>13</to> <value>12</value> </font-size> <color> <from>0</from> <to>5</to> <value>#f77567</value> </color> <back-color> <from>0</from> <to>5</to> <value>#daeef4</value> </back-color> <back-color> <from>6</from> <to>13</to> <value>#ffffff</value> </back-color> </inline-styles> <styles> <align>center</align> <line-height>1.5</line-height> </styles></paragraph>眾所周知, 樹狀數據不如線性數據好處理. HTM是樹狀結構的,且無深度限制——div
標籤幾乎可無限制嵌套div
——非常不利於編輯器操作數據。因此,在XML定義的文檔數據中,類似paragraph
這樣的塊級標籤不能相互嵌套,而text
, inline-styles
等行內標籤的嵌套也有嚴格定義。數據層運行時,第二代編輯器操作的數據和展現給用戶的視圖使用的是同一份HTML/DOM。通過對 Etherpad Lite,Quip,Google Doc 等產品的調研與分析,第三代編輯器重新設計了運行時的數據層。所有數據可以分為塊狀(Block) 和 行內(Inline)數據, 筆記內容由若干個塊數據(Block)組成, 每個塊數據(Block)由行內(Inline)數據組成——這與XML定義存儲層時的邏輯一致。在運行時, paragraph
標籤會被轉化成Block
的子類Paragraph
對象。行內數據 text
和 inline-styles
則轉化成一個RichText
對象, RichText
由若干個RichChar 組成。而styles
標籤則會被轉化成blockStyles
對象。Paragraph
負責整個段落,管理RichText
和blockStyles
對象。一篇筆記中有不同類型的Block
,如列表(ListItem),圖片(Image
),附件(Attachment
),表格(Table
),未知類型(Unknown
)。其中,未知類型(Unknown
)比較特殊,用於兼容未來新增的Block
定義。筆記中的所有Block
存放在一個數組中,該數組由Note
對象管理。Note
對象提供一些方法以支持Block
的獲取及增刪改。NoteRange
/NoteSelection
Range是用來描述數據範圍的,由於數據層中不同類型的Block
數據結構不一樣,因此需要不用類型的BlockRange
來描述數據範圍。比如,ParagraphRange
描述Paragraph
數據範圍,具有以下屬性:
block
:指向Block
子類Paragraph
的實例start
:數據範圍的起始end
:數據範圍的結尾
ImageRange
描述Image
的數據範圍,則具有以下屬性:
block
: 指向Block
子類Image
的實例rangeType
:枚舉常量,可取的值為ImageRange.START
(圖片左側),ImageRange.END
(圖片右側),ImageRange.ALL
(選取圖片)。
整個筆記的數據範圍則用NoteRange
來描述,其具有兩個屬性:
startBlockRange
:BlockRange
類型,筆記數據範圍的起始處。endBlockRange
:BlockRange
類型,筆記數據範圍的結尾處。
NoteSelection
負責管理當前的NoteRange
,NoteSelectionView
負責繪製NoteSelection
。
視圖層在第三代編輯器中,視圖層與數據層進行了分離。
BlockView
對象負責數據層Block
對象的渲染和交互,不同的Block
類型對應不同的BlockView
,比如ParagraphView
負責Paragraph
,ImageView
負責Image
。在BlockView
之上存在NoteView
, NoteView
負責管理所有的BlockView
, 以及BlockView
級別上無法處理的交互。除了NoteView
外, NoteSelectionView
也是視圖層的一部分。NoteSelectionView
是一個絕對定位的半透明層,懸浮在NoteView
上方。在計算NoteSelection
的位置信息時,會調用在選區中的每個BlockView
的getClientRectsForRange
方法以獲取一組ClientRect
,NoteSelectionView
根據這些ClientRect
即可繪製出選區。值得注意的是,NoteSelectionView
需要將其CSS pointer-events
屬性設置為none
以禁止其接收滑鼠點擊等任何用戶交互。一個完整的編輯器一般會提供工具欄,編輯器需要給工具欄提供命令狀態查詢介面。綜上, 編輯器存儲層、數據層、視圖層的關係如下:輸入法對接由於拋棄了contenteditable
特性,編輯器無法使用系統默認游標/選區來支持輸入法的輸入,但真實的游標/選區又必須存在,瀏覽器才能接收到輸入法的輸入,該如何處理呢?業界普遍採用的方式是將真實的游標/選區放置在一個用戶不可見的<input/>
元素或者<textarea/>
元素中。<input/>
或<textarea/>
元素監聽keydown
,textInput
,compositionstart
/compositionupdate
/compositionend
,copy
/cut
/paste
等鍵盤、輸入法、剪貼板相關事件。在第三代編輯器中,使用不可見的<textarea/>
元素,並由HiddenInputView
組件負責管理。HiddenInputView
會將來自<textarea/>
元素的事件稍加整理,然後交與整個編輯器的控制器Controller
處理。命令及其管理當控制器Controller
接收到鍵盤按鍵、輸入法、剪貼板等相關事件時,會執行對應的命令(Command
)。編輯器不能直接去修改數據層的Note
/Block
,必須通過執行命令(Command
)的方式間接修改數據。任何修改操作行為都必須抽象成命令(Command
),每個命令都必須實現 doApply
,undoApply
,redoApply
方法,以便於整個編輯器實現撤銷和重做功能。比如,當我們將選中文字加粗時,會將執行SetInlineStyle命令。其doApply
方法優先調用數據層Block
的get方法獲取將要被修改的格式,並將這些格式數據備份,然後調用Block
的set方法設置加粗格式。當undo時,undoApply
方法將調用Block
的set方法設置成之前備份的格式。執行redo時,redoApply
方法將調用Block
的set方法設置加粗格式。當Block
的set方法被調用時,Block
會通知對應的BlockView
。BlockView
收到數據發生變化通知後,隨即局部更新視圖或者全部重新渲染。也就是說,視圖更新的粒度控制在Block
/BlockView
級別;被修改的Block
對應的BlockView
更新視圖即可,不需要更新整個NoteView
視圖。每個命令(Command
)的除了會接受操作參數(如加粗)外,還會接收一個參數startNoteRange
——描述被修改的數據的範圍。命令的doApply
方法會計算endNoteRange
——命令執行完畢後的選區。當執行doApply
,redoApply
方法時,編輯器會將endNoteRange
設置給NoteSelection
;執行undoApply
方法時,編輯器會將startNoteRange
設置給NoteSelection
。當NoteSelection
發生變化時,通知NoteSelectionView
重新渲染。細粒度的undo/redo命令(Command
)之間可以相互嵌套,不被其他命令嵌套的命令被稱為頂層命令,一個編輯操作可以抽象成一個頂層命令。當執行編輯操作時,頂層命令執行doApply
方法,然後被壓入undo棧;執行撤銷時,頂層命令從undo棧彈出,執行undoApply
方法,然後被壓入redo棧;執行重做時,頂層命令從redo棧彈出,執行redoApply
方法,再次被壓入undo棧。因此,整個編輯器的撤銷和重做的粒度控制在命令級別上。直接調用Note
/Block
的方法修改數據的命令,僅會備份被修改部分的格式或數據;不直接修改數據的命令,不會備份格式或數據。因此,與第二代編輯器採用快照方式實現undo/reodo相比,第三代編輯器實現undo/redo佔用的內存更少。協同編輯當協同編輯時,命令(Command
) 會被序列化, 上傳給協同伺服器;協同伺服器接收到來自客戶端的命令後,不對命令進行處理,直接將命令分發給其他客戶端。客戶端接收到來自協同伺服器的命令後,對命令反序列化,進行衝突處理後,重新構建命令。重新構建的命令會被執行,併產生endNoteRange
——即遠端用戶編輯的位置。該endNoteRange
會被NoteSelectionView
渲染,當前用戶即可看到遠端協同用戶編輯的位置。目前,實現協同編輯最好的技術是操作變換(Operation Transformation),但實現比較困難。因此,有道雲筆記編輯器的協同沒有採用操作變換的技術。總結基於瀏覽器的富文本編輯器一般利用了contenteditable
特性,同時也被該特性束縛住,難逃離其窠臼。有道雲筆記編輯器團隊歷時數年,不斷迭代,拋棄了contenteditable
特性,自實現了所有組件——這給編輯器插上了翅膀,讓其翱翔在自由的天空。本文來自網易雲社區,經作者付雲貴授權發布。
原文地址:有道雲筆記跨平台富文本編輯器的技術演進
更多網易研發、產品、運營經驗分享請訪問網易雲社區。
推薦閱讀:
※新版移動端編輯器正式上線啦
※移動端內容編輯器(鍵盤)的設計參考
※Vim 8 中 C/C++ 符號索引:從 GTags 到 LanguageServer
※編輯器格式和屬性
※如何從頭打造一個Markdown編輯器(序章)