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

本文來自網易雲社區前端

 

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


跨平臺架構設計
這必須從有道雲筆記的富文本編輯器的基本架構提及。正則表達式


有道雲筆記編輯器使用了前端技術構建編輯器的核心,並運行在特定的宿主環境——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)+ 前端技術的方式來構建編輯器呢?這是由於windows

  • 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特性,自實現了全部組件——這給編輯器插上了翅膀,讓其翱翔在自由的天空。

 

本文來自網易雲社區,經做者付雲貴受權發佈。

原文地址:有道雲筆記跨平臺富文本編輯器的技術演進

更多網易研發、產品、運營經驗分享請訪問網易雲社區

相關文章
相關標籤/搜索