本文來自網易雲社區。前端
使用過有道雲筆記的讀者會發現,該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
contenteditable
特性支持富文本的編輯,適合開發編輯器。
有道雲筆記編輯器的迭代
宿主環境(瀏覽器/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
的特性document.execCommand
API,雖然自實現部分或者所有命令,但依然存在難於解決的bug, 也不便於實現協同編輯、相似Word分頁等功能。第三代編輯器
所以,在2015年,編輯器團隊對編輯器進行從新思考與定位,開始了第三代編輯器的探索。
不一樣於前兩代編輯器,第三代編輯器在存儲層採用了XML對數據及格式進行嚴格定義。編輯器運行時,將XML轉換成JavaScript對象表示的數據層。視圖層與數據層進行了分離,負責視圖渲染及交互輸入。
第三代編輯器再也不依賴瀏覽器的contenteditable
特性,命令執行再也不依賴document.execCommand
API。數據、選區(Range/Selection)、編輯命令、視圖渲染等全部組件徹底由編輯器本身定義和實現——這使得編輯器更加可控,但也致使編輯器更復雜,增長了開發的難度和成本。
基於contenteditable 的編輯器實現
基於contenteditable
的第二代編輯器主要有如下幾個核心:服務器
document.execCommand
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
這樣的參數。所以,編輯器須要複寫部分或所有命令,新增命令以及管理命令,提供相似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字符串解析成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 的編輯器實現
因爲基於瀏覽器contenteditable
特性實現的編輯器存在沒法根除的bug,難於實現協同編輯、相似Word的分頁等功能,有道雲筆記編輯器團隊從新思考與設計編輯器,開發了第三代編輯器。
與第二代相比,第三代編輯器的主要特色是:
NoteRange
/NoteSelection
及其繪製contenteditable
特性,使用中間層對接輸入法document.execCommand
, 自實現所有命令及命令的管理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
特性,自實現了全部組件——這給編輯器插上了翅膀,讓其翱翔在自由的天空。
本文來自網易雲社區,經做者付雲貴受權發佈。
原文地址:有道雲筆記跨平臺富文本編輯器的技術演進
更多網易研發、產品、運營經驗分享請訪問網易雲社區。