有道雲筆記跨平台富文本編輯器的技術演進

有道雲筆記跨平台富文本編輯器的技術演進

來自專欄網易雲社區

本文來自網易雲社區。

使用過有道雲筆記的讀者會發現,該App在windows、Mac OS、桌面瀏覽器(webkit內核)、iOS、Android等終端提供了富文本編輯能力。在不同終端實現基本一致的編輯能力,這是如何做到的呢?

跨平台架構設計

這必須從有道雲筆記的富文本編輯器的基本架構說起。

有道雲筆記編輯器使用了前端技術構建編輯器的核心,並運行在特定的宿主環境——Native App提供的瀏覽器環境——中。在不同平台,瀏覽器環境不一樣,以下是有道雲筆記在不同平台中使用的瀏覽器環境。

平台宿主環境備註WindowsCEFMac osWebView桌面瀏覽器瀏覽器自身僅支持webkit內核iOSUIWebView亦可使用 WKWebView (iOS 8+)AndroidCrossWalk(Android 4.0+)

WebView(Android 7.0+)在Windows 平台的客戶端中,有道雲筆記使用了CEF(Chromium Embedded FrameWork)提供瀏覽器環境。CEF是一個由Marshall Greenblatt在2008建立的開源項目,基於Chromium的內核,跨Windows/Mac/Linux桌面平台,性能好,支持HTML5/CSS3 等新特性。

在Android 4.0+ 中,有道雲筆記使用了CrossWalk提供瀏覽器環境。CrossWalk 是 Intel 公司的一個開源項目, 目的是為Android 4.0+ 系統提供一個一致的性能強勁的WebView。由於隨著Android 系統不斷的更新迭代,系統自帶WebView已使用Chromium內核, CrossWalk的優勢在高版本的Android 中不明顯。目前,Intel 已聲明不再維護該項目。故在Android 7.0+ 中使用了系統自帶的WebView。

雖然內嵌CEF, CrossWalk能夠提供性能更好特性更豐富的瀏覽器環境,但程序安裝包大小會增加20M左右。因此, iOS/Mac 平台由於系統自帶的WebView 滿足要求,故使用系統自帶的WebView。

為什麼採用Native App + 宿主環境(瀏覽器/WebView)+ 前端技術的方式來構建編輯器呢?這是因為

  • HTML+CSS 特性豐富,布局靈活,適合展現文本,圖片等富文本內容。
  • 瀏覽器的contenteditable特性支持富文本的編輯,適合開發編輯器。
  • 可跨平台開發,不同平台編輯器的核心代碼基本可以復用,降低開發成本。
  • Native App 具有更高的許可權,當HTML+CSS+JavaScript能力受限時,可由Native App 提供介面來補充。

有道雲筆記編輯器的迭代

宿主環境(瀏覽器/WebView)的挑選為編輯器提供了良好的運行環境,而編輯器的好壞取決於如何設計與實現編輯器。在發展過程中,有道雲筆記共自研發了三代編輯器,每一代的設計與實現各不相同。

編輯器持久存儲層編輯時數據層視圖層是否依賴WebView的特性第一代HTMLHTML/DOM 樹無特殊依賴第二代HTMLHTML/DOM 樹contenteditable第三代XMLNote/BlockNoteView/BlockView不依賴contenteditable第一代編輯器

在有道雲筆記發展早期(2012年左右),由於當時Android自帶的WebView不支持 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的第二代編輯器主要有以下幾個核心:

  1. Range/Selection
  2. document.execCommand
  3. undo/redo
  4. 內容過濾
  5. 與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

編輯器使用Range/Selection選定內容,使用document.execCommand來對選定的內容進行編輯修改。

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

如需要對選定內容設置為紅色,只需要執行document.execCommand("foreColor", false, "red")即可。

瀏覽器原生的命令

  • 未必符合產品需求,如fontSize命令只能傳入 1-7 的參數,無法傳入類似10px這樣的參數。
  • 本身實現有bug

因此,編輯器需要複寫部分或全部命令,新增命令以及管理命令,提供類似document.execCommand的editor.execCommand介面。

undo/redo

使用document.execCommand對內容修改時,瀏覽器內部會對該contenteditable區域維護一個undo/redo棧,使得每一個修改行為可以撤銷和重做。

如果一旦使用了document.execCommand之外的DOM API修改內容,就會破壞undo/redo棧的連續性,導致撤銷和重做出錯或失效。比如,使用jQuery查找一個元素,其Sizzler引擎在查找過程中可能會對HTML元素添加屬性,並在查找完成後刪除新添加屬性。在該過程中,Sizzler使用了DOM API操作添加和刪除屬性,會導致瀏覽器內部的undo/redo出錯。

在複寫或新增命令時,不可避免地會使用DOM API操作內容,破壞瀏覽器內部的undo/redo管理,因此,編輯器必須自身實現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棧。

HTML內容與Range每次快照都存儲整篇筆記,佔用的內存較大。因此,內存中只保留有限個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 則提供requestImageThumbrequestInsertImage等介面供編輯器調用。與Web App相比,Native App有更好的性能和可靠性,可訪問各種設備,如持久存儲、相冊相機、震動器。Native App提供的介面極大豐富了編輯器的能力,能夠實現無限次撤銷重做、插入圖片/視頻、圖像糾偏、手寫筆記等功能。

超越 contenteditable 的編輯器實現

由於基於瀏覽器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這樣的塊級標籤不能相互嵌套,而textinline-styles等行內標籤的嵌套也有嚴格定義。

數據層

運行時,第二代編輯器操作的數據和展現給用戶的視圖使用的是同一份HTML/DOM。通過對 Etherpad Lite,Quip,Google Doc 等產品的調研與分析,第三代編輯器重新設計了運行時的數據層。所有數據可以分為塊狀(Block) 和 行內(Inline)數據, 筆記內容由若干個塊數據(Block)組成, 每個塊數據(Block)由行內(Inline)數據組成——這與XML定義存儲層時的邏輯一致。

在運行時, paragraph標籤會被轉化成Block的子類Paragraph 對象。行內數據 textinline-styles 則轉化成一個RichText 對象, RichText 由若干個RichChar 組成。而styles標籤則會被轉化成blockStyles對象。Paragraph 負責整個段落,管理RichTextblockStyles對象。

一篇筆記中有不同類型的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負責管理當前的NoteRangeNoteSelectionView負責繪製NoteSelection

視圖層

在第三代編輯器中,視圖層與數據層進行了分離。BlockView對象負責數據層Block對象的渲染和交互,不同的Block類型對應不同的BlockView,比如ParagraphView負責ParagraphImageView負責Image

BlockView 之上存在NoteViewNoteView負責管理所有的BlockView, 以及BlockView級別上無法處理的交互。

除了NoteView外, NoteSelectionView也是視圖層的一部分。NoteSelectionView是一個絕對定位的半透明層,懸浮在NoteView上方。在計算NoteSelection的位置信息時,會調用在選區中的每個BlockViewgetClientRectsForRange 方法以獲取一組ClientRectNoteSelectionView 根據這些ClientRect即可繪製出選區。值得注意的是,NoteSelectionView需要將其CSS pointer-events屬性設置為none以禁止其接收滑鼠點擊等任何用戶交互。

一個完整的編輯器一般會提供工具欄,編輯器需要給工具欄提供命令狀態查詢介面。

綜上, 編輯器存儲層、數據層、視圖層的關係如下:

輸入法對接

由於拋棄了contenteditable特性,編輯器無法使用系統默認游標/選區來支持輸入法的輸入,但真實的游標/選區又必須存在,瀏覽器才能接收到輸入法的輸入,該如何處理呢?

業界普遍採用的方式是將真實的游標/選區放置在一個用戶不可見的<input/>元素或者<textarea/>元素中。<input/><textarea/>元素監聽keydowntextInputcompositionstart/compositionupdate /compositionendcopy/cut/paste等鍵盤、輸入法、剪貼板相關事件。

在第三代編輯器中,使用不可見的<textarea/>元素,並由HiddenInputView組件負責管理。HiddenInputView會將來自<textarea/>元素的事件稍加整理,然後交與整個編輯器的控制器Controller處理。

命令及其管理

當控制器Controller接收到鍵盤按鍵、輸入法、剪貼板等相關事件時,會執行對應的命令(Command)。

編輯器不能直接去修改數據層的Note/Block,必須通過執行命令(Command)的方式間接修改數據。任何修改操作行為都必須抽象成命令(Command),每個命令都必須實現 doApplyundoApplyredoApply方法,以便於整個編輯器實現撤銷和重做功能。

比如,當我們將選中文字加粗時,會將執行SetInlineStyle命令。其doApply方法優先調用數據層Block的get方法獲取將要被修改的格式,並將這些格式數據備份,然後調用Block的set方法設置加粗格式。當undo時,undoApply方法將調用Block的set方法設置成之前備份的格式。執行redo時,redoApply方法將調用Block的set方法設置加粗格式。

Block的set方法被調用時,Block會通知對應的BlockViewBlockView收到數據發生變化通知後,隨即局部更新視圖或者全部重新渲染。也就是說,視圖更新的粒度控制在Block/BlockView級別;被修改的Block對應的BlockView更新視圖即可,不需要更新整個NoteView視圖。

每個命令(Command)的除了會接受操作參數(如加粗)外,還會接收一個參數startNoteRange——描述被修改的數據的範圍。命令的doApply方法會計算endNoteRange——命令執行完畢後的選區。當執行doApplyredoApply方法時,編輯器會將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編輯器(序章)

TAG:有道雲筆記 | 網易雲 | 文本編輯器 |