現代富文字編輯器Quill的內容渲染機制
DevUI是一支兼具設計視角和工程視角的團隊,服務於華為雲DevCloud平臺和華為內部數箇中後臺系統,服務於設計師和前端工程師。
官方網站:devui.design
Ng元件庫:ng-devui(歡迎Star)
引言
在 Web 開發領域,富文字編輯器( Rich Text Editor )是一個使用場景非常廣,又非常複雜的元件。
要從0開始做一款好用、功能強大的富文字編輯器並不容易,基於現有的開源庫進行開發能節省不少成本。
Quill 是一個很不錯的選擇。
本文主要介紹Quill內容渲染相關的基本原理,主要包括:
- Quill描述編輯器內容的方式
- Quill將Delta渲染到DOM的基本原理
- Scroll類管理所有子Blot的基本原理
Quill如何描述編輯器內容?
Quill簡介
Quill 是一款API驅動、易於擴充套件和跨平臺的現代 Web 富文字編輯器。目前在 Github 的 star 數已經超過25k。
Quill 使用起來也非常方便,簡單幾行程式碼就可以建立一個基本的編輯器:
1 <script> 2 var quill = new Quill('#editor', { 3 theme: 'snow' 4 }); 5 </script>
Quill如何描述格式化的文字
當我們在編輯器裡面插入一些格式化的內容時,傳統的做法是直接往編輯器裡面插入相應的 DOM,通過比較 DOM 樹來記錄內容的改變。
直接操作 DOM 的方式有很多不便,比如很難知道編輯器裡面某些字元或者內容到底是什麼格式,特別是對於自定義的富文字格式。
Quill 在 DOM 之上做了一層抽象,使用一種非常簡潔的資料結構來描述編輯器的內容及其變化:Delta。
Delta 是JSON的一個子集,只包含一個 ops 屬性,它的值是一個物件陣列,每個陣列項代表對編輯器的一個操作(以編輯器初始狀態為空為基準)。
比如編輯器裡面有"Hello World":
用 Delta 進行描述如下:
1 { 2 "ops": [ 3 { "insert": "Hello " }, 4 { "insert": "World", "attributes": { "bold": true} }, 5 { "insert": "\n" } 6 ] 7 }
意思很明顯,在空的編輯器裡面插入"Hello ",在上一個操作後面插入加粗的"World",最後插入一個換行"\n"。
Quill如何描述內容的變化
Delta 非常簡潔,但卻極富表現力。
它只有3種動作和1種屬性,卻足以描述任何富文字內容和任意內容的變化。
3種動作:
- insert:插入
- retain:保留
- delete:刪除
1種屬性:
- attributes:格式屬性
比如我們把加粗的"World"改成紅色的文字"World",這個動作用 Delta 描述如下:
1 { 2 "ops": [ 3 { "retain": 6 }, 4 { "retain": 5, "attributes": { "color": "#ff0000" } } 5 ] 6 }
意思是:保留編輯器最前面的6個字元,即保留"Hello "不動,保留之後的5個字元"World",並將這些字元設定為字型顏色為"#ff0000"。
如果要刪除"World",相信聰明的你也能猜到怎麼用 Delta 描述,沒錯就是你猜到的:
1 { 2 "ops": [ 3 { "retain": 6 }, 4 { "delete": 5 } 5 ] 6 }
Quill如何描述富文字內容
最常見的富文字內容就是圖片,Quill 怎麼用 Delta 描述圖片呢?
insert 屬性除了可以是用於描述普通字元的字串格式之外,還可以是描述富文字內容的物件格式,比如圖片:
1 { 2 "ops": [ 3 { "insert": { "image": "https://quilljs.com/assets/images/logo.svg" } }, 4 { "insert": "\n" } 5 ] 6 }
比如公式:
1 { 2 "ops": [ 3 { "insert": { "formula": "e=mc^2" } }, 4 { "insert": "\n" } 5 ] 6 }
Quill 提供了極大的靈活性和可擴充套件性,可以自由定製富文字內容和格式,比如幻燈片、思維導圖,甚至是3D模型。
setContent如何將Delta資料渲染成DOM?
上一節我們介紹了 Quill 如何使用 Delta 描述編輯器內容及其變化,我們瞭解到 Delta 只是普通的 JSON 結構,只有3種動作和1種屬性,卻極富表現力。
那麼 Quill 是如何應用 Delta 資料,並將其渲染到編輯器中的呢?
setContents 初探
Quill 中有一個 API 叫 setContents,可以將 Delta 資料渲染到編輯器中,本期將重點解析這個 API 的實現原理。
還是用上一期的 Delta 資料作為例子:
1 const delta = { "ops": [ 2 { "insert": "Hello " }, 3 { "insert": "World", "attributes": { "bold": true } }, 4 { "insert": "\n" } ] 5 }
當使用 new Quill() 建立好 Quill 的例項之後,我們就可以呼叫它的 API 啦。
1 const quill = new Quill('#editor', { 2 theme: 'snow' 3 });
我們試著呼叫下 setContents 方法,傳入剛才的 Delta 資料:
1 quill.setContents(delta);
編輯器中就出現了我們預期的格式化文字:
setContents 原始碼
通過檢視 setContents 的原始碼,發現就呼叫了 modify 方法,主要傳入了一個函式:
1 setContents(delta, source = Emitter.sources.API) { 2 return modify.call( this, () => { 3 delta = new Delta(delta); 4 const length = this.getLength(); 5 const deleted = this.editor.deleteText(0, length); 6 const applied = this.editor.applyDelta(delta); 7 ... // 為了方便閱讀,省略了非核心程式碼 8 return deleted.compose(applied); 9 }, source, ); 10 }
使用 call 方法呼叫 modify 是為了改變其內部的 this 指向,這裡指向的是當前的 Quill 例項,因為 modify 方法並不是定義在 Quill 類中的,所以需要這麼做。
我們先不看 modify 方法,來看下傳入 modify 方法的匿名函式。
該函式主要做了三件事:
- 把編輯器裡面原有的內容全部刪除
- 應用傳入的 Delta 資料,將其渲染到編輯器中
- 返回1和2組合之後的 Delta 資料
我們重點看第2步,這裡涉及到 Editor 類的 applyDelta 方法。
applyDelta 方法解析
根據名字大概能猜到該方法的目的是:把傳入的 Delta 資料應用和渲染到編輯器中。
它的實現我們大概也可以猜測就是:迴圈 Delta 裡的 ops 陣列,一個一個地應用到編輯器中。它的原始碼一共54行,大致如下:
1 applyDelta(delta) { 2 let consumeNextNewline = false; 3 this.scroll.update(); 4 let scrollLength = this.scroll.length(); 5 this.scroll.batchStart(); 6 const normalizedDelta = normalizeDelta(delta); 7 8 normalizedDelta.reduce((index, op) => { 9 const length = op.retain || op.delete || op.insert.length || 1; 10 let attributes = op.attributes || {}; 11 // 1.插入文字 12 if (op.insert != null) { 13 if (typeof op.insert === 'string') { 14 // 普通文字內容 15 let text = op.insert; 16 ... // 為了閱讀方便,省略非核心程式碼 17 this.scroll.insertAt(index, text); 18 ... // 為了閱讀方便,省略非核心程式碼 19 } else if (typeof op.insert === 'object') { 20 // 富文字內容 21 const key = Object.keys(op.insert)[0]; 22 // There should only be one key 23 if (key == null) return index; 24 this.scroll.insertAt(index, key, op.insert[key]); 25 } 26 scrollLength += length; 27 } 28 // 2.對文字進行格式化 29 Object.keys(attributes).forEach(name => { 30 this.scroll.formatAt(index, length, name, attributes[name]); 31 }); 32 return index + length; 33 }, 0); 34 ... // 為了閱讀方便,省略非核心程式碼 this.scroll.batchEnd(); 35 this.scroll.optimize(); 36 return this.update(normalizedDelta); 37 }
和我們猜測的一樣,該方法就是用 Delta 的 reduce 方法對傳入的 Delta 資料進行迭代,將插入內容和刪除內容的邏輯分開了,插入內容的迭代裡主要做了兩件事:
- 插入普通文字或富文字內容:insertAt
- 格式化該文字:formatAt
至此,將 Delta 資料應用和渲染到編輯器中的邏輯,我們已經解析完畢。
下面做一個總結:
- setContents 方法本身沒有什麼邏輯,僅僅是呼叫了 modify 方法而已
- 在傳入 modify 方法的匿名函式中呼叫了 Editor 物件的 applyDelta 方法
- applyDelta 方法對傳入的 Delta 資料進行迭代,並依次插入/格式化/刪除 Delta 資料所描述的編輯器內容
Scroll如何管理所有的Blot型別?
上一節我們介紹了 Quill 將 Delta 資料應用和渲染到編輯器中的原理:通過迭代 Delta 中的 ops 資料,將 Delta 行一個一個渲染到編輯器中。
瞭解到最終內容的插入和格式化都是通過呼叫 Scroll 物件的方法實現的,Scroll 物件到底是何方神聖?在編輯器的操作中發揮了什麼作用?
Scroll 物件的建立
上一節的解析終止於 applyDelta 方法,該方法最終呼叫了 this.scroll.insertAt 將 Delta 內容插入到編輯器中。
applyDelta 方法定義在 Editor 類中,在 Quill 類的 setContents 方法中被呼叫,通過檢視原始碼,發現 this.scroll 最初是在 Quill 的建構函式中被賦值的。
1 this.scroll = Parchment.create(this.root, { 2 emitter: this.emitter, 3 whitelist: this.options.formats 4 });
Scroll 物件是通過呼叫 Parchment 的 create 方法建立的。
前面兩期我們簡單介紹了 Quill 的資料模型 Delta,那麼 Parchment 又是什麼呢?它跟 Quill 和 Delta 是什麼關係?這些疑問我們先不解答,留著後續詳細講解。
先來簡單看下 create 方法是怎麼建立 Scroll 物件的,create 方法最終是定義在 parchment 庫原始碼中的 registry.ts 檔案中的,就是一個普通的方法:
1 export function create(input: Node | string | Scope, value?: any): Blot { 2 // 傳入的 input 就是編輯器主體 DOM 元素(.ql-editor),裡面包含了編輯器裡所有可編輯的實際內容 3 // match 是通過 query 方法查詢到的 Blot 類,這裡就是 Scroll 類 4 let match = query(input); 5 if (match == null) { 6 throw new ParchmentError(`Unable to create ${input} blot`); 7 } 8 let BlotClass = <BlotConstructor>match; 9 let node = input instanceof Node || input['nodeType'] === Node.TEXT_NODE 10 ? input 11 : BlotClass.create(value); 12 13 // 最後返回 Scroll 物件 14 return new BlotClass(<Node>node, value); 15 }
create 方法的入參是編輯器主體 DOM 元素 .ql-editor,通過呼叫同文件中的 query 普通方法,查詢到 Blot 類是 Scroll 類,查詢的大致邏輯就是在一個 map 表裡查,最後通過 new Scroll() 返回 Scroll 物件例項,賦值給 this.scroll。
1 { 2 ql-cursor: ƒ Cursor(domNode, selection), 3 ql-editor: ƒ Scroll(domNode, config), // 這個就是 Scroll 類 4 ql-formula: ƒ FormulaBlot(), 5 ql-syntax: ƒ SyntaxCodeBlock(), 6 ql-video: ƒ Video(), 7 }
Scroll 類詳解
Scroll 類是我們解析的第一個 Blot 格式,後續我們將遇到各種形式的 Blot 格式,並且會定義自己的 Blot 格式,用於在編輯器中插入自定義內容,這些 Blot 格式都有類似的結構。
可以簡單理解為 Blot 格式是對 DOM 節點的抽象,而 Parchment 是對 HTML 文件的抽象,就像 DOM 節點是構成 HTML 文件的基本單元一樣,Blot 是構成 Parchment 文件的基本單元。
比如:DOM 節點是<div>,對其進行封裝變成 <div class="ql-editor">,並在其內部封裝一些屬性和方法,就變成 Scroll 類。
Scroll 類是所有 Blot 的根 Blot,它對應的 DOM 節點也是編輯器內容的最外層節點,所有編輯器內容都被包裹在它之下,可以認為 Scroll 統籌著其他 Blot 物件(實際 Scroll 的父類 ContainerBlot 才是幕後總 BOSS,負責總的排程)。
1 <div class="ql-editor" contenteditable="true"> 2 <p> 3 Hello 4 <strong>World</strong> 5 </p> 6 ... // 其他編輯器內容 7 </div>
Scroll 類定義在 Quill 原始碼中的 blots/scroll.js 檔案中,之前 applyDelta 方法中通過 this.scroll 呼叫的 insertAt / formatAt / deleteAt / update / batchStart / batchEnd / optimize 等方法都在 Scroll 類中。
以下是 Scroll 類的定義:
1 class Scroll extends ScrollBlot { 2 constructor(domNode, config) { 3 super(domNode); 4 ... 5 } 6 7 // 標識批量更新的開始,此時執行 update / optimize 都不會進行實際的更新 8 batchStart() { 9 this.batch = true; 10 } 11 12 // 標識批量更新的結束 13 batchEnd() { 14 this.batch = false; 15 this.optimize(); 16 } 17 18 // 在制定位置刪除制定長度的內容 19 // 比如:deleteAt(6, 5) 將刪除 "World" 20 // 在 Quill 的 API 中對應 deleteText(index, length, source) 方法 21 deleteAt(index, length) {} 22 23 // 設定編輯器的可編輯狀態 24 enable(enabled = true) { 25 this.domNode.setAttribute('contenteditable', enabled); 26 } 27 28 // 在制定位置用制定格式格式化制定長度的內容 29 // 比如:formatAt(6, 5, 'bold', false) 將取消 "World" 的粗體格式 30 // 在 Quill 的 API 中對應 formatText(index, length, name, value, source) 方法 formatAt(index, length, format, value) { 31 if (this.whitelist != null && !this.whitelist[format]) return; 32 super.formatAt(index, length, format, value); this.optimize(); 33 } 34 35 // 在制定位置插入內容 36 // 比如:insertAt(11, '\n你好,世界'); 37 // 在 Quill 的 API 中對應 insertText(index, text, name, value, source) 38 // Quill 中的 insertText 其實是 Scroll 的 insertAt 和 formatAt 的複合方法 39 insertAt(index, value, def) {} 40 41 // 在某個 Blot 前面插入 Blot 42 insertBefore(blot, ref) {} 43 44 // 彈出當前位置 Blot 路徑最外面的葉子 Blot(會改變原陣列) 45 leaf(index) { return this.path(index).pop() || [null, -1]; } 46 47 // 實際上呼叫的是父類 ContainerBlot 的 descendant 方法 48 // 目的是得到當前位置所在的 Blot 物件 49 line(index) { 50 if (index === this.length()) { 51 return this.line(index - 1); 52 } 53 return this.descendant(isLine, index); 54 } 55 56 // 獲取某一範圍的 Blot 物件 57 lines(index = 0, length = Number.MAX_VALUE) {} 58 59 // TODO 60 optimize(mutations = [], context = {}) { 61 if (this.batch === true) return; 62 super.optimize(mutations, context); 63 if (mutations.length > 0) { 64 this.emitter.emit(Emitter.events.SCROLL_OPTIMIZE, mutations, context); 65 } 66 } 67 68 // 實際上呼叫的是父類 ContainerBlot 的 path 方法 69 // 目的是得到當前位置的 Blot 路徑,並排除 Scroll 自己 70 // Blot 路徑就和 DOM 節點路徑是對應的 71 // 比如:DOM 節點路徑 div.ql-editor -> p -> strong, 72 // 對應 Blot 路徑就是 [[Scroll div.ql-editor, 0], [Block p, 0], [Bold strong, 6]] 73 path(index) { 74 return super.path(index).slice(1); // Exclude self 75 } 76 77 // TODO 78 update(mutations) { 79 if (this.batch === true) return; 80 ... 81 } 82 } 83 84 Scroll.blotName = 'scroll'; 85 Scroll.className = 'ql-editor'; 86 Scroll.tagName = 'DIV'; 87 Scroll.defaultChild = 'block'; 88 Scroll.allowedChildren = [Block, BlockEmbed, Container]; 89 90 export default Scroll;
Scroll 類上定義的靜態屬性 blotName 和 tagName 是必須的,前者用於唯一標識該 Blot 格式,後者對應於一個具體的 DOM 標籤,一般還會定義一個 className,如果該 Blot 是一個父級 Blot,一般還會定義 allowedChildren 用來限制允許的子級 Blot 白名單,不在白名單之內的子級 Blot 對應的 DOM 將無法插入父類 Blot 對應的 DOM 結構裡。
Scroll 類中除了定義了插入 / 格式化 / 刪除內容的方法之外,定義了一些很實用的用於獲取當前位置 Blot 路徑和 Blot 物件的方法,以及觸發編輯器內容更新的事件。
相應方法的解析都在以上原始碼的註釋裡,其中 optimize 和 update 方法涉及 Quill 中的事件和狀態變更相關邏輯,放在後續單獨進行解析。
關於 Blot 格式的規格定義文件可以參閱以下文章:
https://github.com/quilljs/parchment#blots
我也是初次使用Quill進行富文字編輯器的開發,難免有理解不到位的地方,歡迎大家提意見和建議。
加入我們
我們是DevUI團隊,歡迎來這裡和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:[email protected]。
文/DevUIKagol
往期文章推薦
《Webpack入門——使用Webpack打包Angular專案的一個例子》