DevUI是一支兼具設計視角和工程視角的團隊,服務於華爲雲 DevCloud平臺和華爲內部數箇中後臺系統,服務於設計師和前端工程師。
官方網站: devui.design
Ng組件庫: ng-devui(歡迎Star)
在 Web 開發領域,富文本編輯器( Rich Text Editor )是一個使用場景很是廣,又很是複雜的組件。前端
要從0開始作一款好用、功能強大的富文本編輯器並不容易,基於現有的開源庫進行開發能節省很多成本。node
Quill 是一個很不錯的選擇。git
本文主要介紹Quill內容渲染相關的基本原理,主要包括:github
Quill 是一款API驅動、易於擴展和跨平臺的現代 Web 富文本編輯器。目前在 Github 的 star 數已經超過25k。segmentfault
Quill 使用起來也很是方便,簡單幾行代碼就能夠建立一個基本的編輯器:數組
<script> var quill = new Quill('#editor', { theme: 'snow' }); </script>
當咱們在編輯器裏面插入一些格式化的內容時,傳統的作法是直接往編輯器裏面插入相應的 DOM,經過比較 DOM 樹來記錄內容的改變。前端工程師
直接操做 DOM 的方式有不少不便,好比很難知道編輯器裏面某些字符或者內容究竟是什麼格式,特別是對於自定義的富文本格式。數據結構
Quill 在 DOM 之上作了一層抽象,使用一種很是簡潔的數據結構來描述編輯器的內容及其變化:Delta。app
Delta 是JSON的一個子集,只包含一個 ops 屬性,它的值是一個對象數組,每一個數組項表明對編輯器的一個操做(以編輯器初始狀態爲空爲基準)。dom
好比編輯器裏面有"HelloWorld":
用 Delta 進行描述以下:
{ "ops": [ { "insert": "Hello " }, { "insert": "World", "attributes": { "bold": true } }, { "insert": "\n" } ] }
意思很明顯,在空的編輯器裏面插入"Hello ",在上一個操做後面插入加粗的"World",最後插入一個換行"n"。
Delta 很是簡潔,但卻極富表現力。
它只有3種動做和1種屬性,卻足以描述任何富文本內容和任意內容的變化。
3種動做:
1種屬性:
好比咱們把加粗的"World"改爲紅色的文字"World",這個動做用 Delta 描述以下:
{ "ops": [ { "retain": 6 }, { "retain": 5, "attributes": { "color": "#ff0000" } } ] }
意思是:保留編輯器最前面的6個字符,即保留"Hello "不動,保留以後的5個字符"World",並將這些字符設置爲字體顏色爲"#ff0000"。
若是要刪除"World",相信聰明的你也能猜到怎麼用 Delta 描述,沒錯就是你猜到的:
{ "ops": [ { "retain": 6 }, { "delete": 5 } ] }
最多見的富文本內容就是圖片,Quill 怎麼用 Delta 描述圖片呢?
insert 屬性除了能夠是用於描述普通字符的字符串格式以外,還能夠是描述富文本內容的對象格式,好比圖片:
{ "ops": [ { "insert": { "image": "https://quilljs.com/assets/images/logo.svg" } }, { "insert": "\n" } ] }
好比公式:
{ "ops": [ { "insert": { "formula": "e=mc^2" } }, { "insert": "\n" } ] }
Quill 提供了極大的靈活性和可擴展性,能夠自由定製富文本內容和格式,好比幻燈片、思惟導圖,甚至是3D模型。
上一節咱們介紹了 Quill 如何使用 Delta 描述編輯器內容及其變化,咱們瞭解到 Delta 只是普通的 JSON 結構,只有3種動做和1種屬性,卻極富表現力。
那麼 Quill 是如何應用 Delta 數據,並將其渲染到編輯器中的呢?
Quill 中有一個 API 叫 setContents,能夠將 Delta 數據渲染到編輯器中,本期將重點解析這個 API 的實現原理。
仍是用上一期的 Delta 數據做爲例子:
const delta = { "ops": [ { "insert": "Hello " }, { "insert": "World", "attributes": { "bold": true } }, { "insert": "\n" } ] }
當使用 new Quill() 建立好 Quill 的實例以後,咱們就能夠調用它的 API 啦。
const quill = new Quill('#editor', { theme: 'snow' });
咱們試着調用下 setContents 方法,傳入剛纔的 Delta 數據:
quill.setContents(delta);
編輯器中就出現了咱們預期的格式化文本:
經過查看 setContents 的源碼,發現就調用了 modify 方法,主要傳入了一個函數:
setContents(delta, source = Emitter.sources.API) { return modify.call( this, () => { delta = new Delta(delta); const length = this.getLength(); const deleted = this.editor.deleteText(0, length); const applied = this.editor.applyDelta(delta); ... // 爲了方便閱讀,省略了非核心代碼 return deleted.compose(applied); }, source, ); }
使用 call 方法調用 modify 是爲了改變其內部的 this 指向,這裏指向的是當前的 Quill 實例,由於 modify 方法並非定義在 Quill 類中的,因此須要這麼作。
咱們先不看 modify 方法,來看下傳入 modify 方法的匿名函數。
該函數主要作了三件事:
咱們重點看第2步,這裏涉及到 Editor 類的 applyDelta 方法。
根據名字大概能猜到該方法的目的是:把傳入的 Delta 數據應用和渲染到編輯器中。
它的實現咱們大概也能夠猜想就是:循環 Delta 裏的 ops 數組,一個一個地應用到編輯器中。
它的源碼一共54行,大體以下:
applyDelta(delta) { let consumeNextNewline = false; this.scroll.update(); let scrollLength = this.scroll.length(); this.scroll.batchStart(); const normalizedDelta = normalizeDelta(delta); normalizedDelta.reduce((index, op) => { const length = op.retain || op.delete || op.insert.length || 1; let attributes = op.attributes || {}; // 1.插入文本 if (op.insert != null) { if (typeof op.insert === 'string') { // 普通文本內容 let text = op.insert; ... // 爲了閱讀方便,省略非核心代碼 this.scroll.insertAt(index, text); ... // 爲了閱讀方便,省略非核心代碼 } else if (typeof op.insert === 'object') { // 富文本內容 const key = Object.keys(op.insert)[0]; // There should only be one key if (key == null) return index; this.scroll.insertAt(index, key, op.insert[key]); } scrollLength += length; } // 2.對文本進行格式化 Object.keys(attributes).forEach(name => { this.scroll.formatAt(index, length, name, attributes[name]); }); return index + length; }, 0); ... // 爲了閱讀方便,省略非核心代碼 this.scroll.batchEnd(); this.scroll.optimize(); return this.update(normalizedDelta); }
和咱們猜想的同樣,該方法就是用 Delta 的 reduce 方法對傳入的 Delta 數據進行迭代,將插入內容和刪除內容的邏輯分開了,插入內容的迭代裏主要作了兩件事:
至此,將 Delta 數據應用和渲染到編輯器中的邏輯,咱們已經解析完畢。
下面作一個總結:
上一節咱們介紹了 Quill 將 Delta 數據應用和渲染到編輯器中的原理:經過迭代 Delta 中的 ops 數據,將 Delta 行一個一個渲染到編輯器中。
瞭解到最終內容的插入和格式化都是經過調用 Scroll 對象的方法實現的,Scroll 對象究竟是何方神聖?在編輯器的操做中發揮了什麼做用?
上一節的解析終止於 applyDelta 方法,該方法最終調用了 this.scroll.insertAt 將 Delta 內容插入到編輯器中。
applyDelta 方法定義在 Editor 類中,在 Quill 類的 setContents 方法中被調用,經過查看源碼,發現 this.scroll 最初是在 Quill 的構造函數中被賦值的。
this.scroll = Parchment.create(this.root, { emitter: this.emitter, whitelist: this.options.formats });
Scroll 對象是經過調用 Parchment 的 create 方法建立的。
前面兩期咱們簡單介紹了 Quill 的數據模型 Delta,那麼 Parchment 又是什麼呢?它跟 Quill 和 Delta 是什麼關係?這些疑問咱們先不解答,留着後續詳細講解。
先來簡單看下 create 方法是怎麼建立 Scroll 對象的,create 方法最終是定義在 parchment 庫源碼中的 registry.ts 文件中的,就是一個普通的方法:
export function create(input: Node | string | Scope, value?: any): Blot { // 傳入的 input 就是編輯器主體 DOM 元素(.ql-editor),裏面包含了編輯器裏全部可編輯的實際內容 // match 是經過 query 方法查詢到的 Blot 類,這裏就是 Scroll 類 let match = query(input); if (match == null) { throw new ParchmentError(`Unable to create ${input} blot`); } let BlotClass = <BlotConstructor>match; let node = input instanceof Node || input['nodeType'] === Node.TEXT_NODE ? input : BlotClass.create(value); // 最後返回 Scroll 對象 return new BlotClass(<Node>node, value); }
create 方法的入參是編輯器主體 DOM 元素 .ql-editor,經過調用同文件中的 query 普通方法,查詢到 Blot 類是 Scroll 類,查詢的大體邏輯就是在一個 map 表裏查,最後經過 new Scroll() 返回 Scroll 對象實例,賦值給 this.scroll。
{ ql-cursor: ƒ Cursor(domNode, selection), ql-editor: ƒ Scroll(domNode, config), // 這個就是 Scroll 類 ql-formula: ƒ FormulaBlot(), ql-syntax: ƒ SyntaxCodeBlock(), ql-video: ƒ Video(), }
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,負責總的調度)。
<div class="ql-editor" contenteditable="true"> <p> Hello <strong>World</strong> </p> ... // 其餘編輯器內容 </div>
Scroll 類定義在 Quill 源碼中的 blots/scroll.js 文件中,以前 applyDelta 方法中經過 this.scroll 調用的 insertAt / formatAt / deleteAt / update / batchStart / batchEnd / optimize 等方法都在 Scroll 類中。
如下是 Scroll 類的定義:
class Scroll extends ScrollBlot { constructor(domNode, config) { super(domNode); ... } // 標識批量更新的開始,此時執行 update / optimize 都不會進行實際的更新 batchStart() { this.batch = true; } // 標識批量更新的結束 batchEnd() { this.batch = false; this.optimize(); } // 在制定位置刪除制定長度的內容 // 好比:deleteAt(6, 5) 將刪除 "World" // 在 Quill 的 API 中對應 deleteText(index, length, source) 方法 deleteAt(index, length) {} // 設置編輯器的可編輯狀態 enable(enabled = true) { this.domNode.setAttribute('contenteditable', enabled); } // 在制定位置用制定格式格式化制定長度的內容 // 好比:formatAt(6, 5, 'bold', false) 將取消 "World" 的粗體格式 // 在 Quill 的 API 中對應 formatText(index, length, name, value, source) 方法 formatAt(index, length, format, value) { if (this.whitelist != null && !this.whitelist[format]) return; super.formatAt(index, length, format, value); this.optimize(); } // 在制定位置插入內容 // 好比:insertAt(11, '\n你好,世界'); // 在 Quill 的 API 中對應 insertText(index, text, name, value, source) // Quill 中的 insertText 實際上是 Scroll 的 insertAt 和 formatAt 的複合方法 insertAt(index, value, def) {} // 在某個 Blot 前面插入 Blot insertBefore(blot, ref) {} // 彈出當前位置 Blot 路徑最外面的葉子 Blot(會改變原數組) leaf(index) { return this.path(index).pop() || [null, -1]; } // 實際上調用的是父類 ContainerBlot 的 descendant 方法 // 目的是獲得當前位置所在的 Blot 對象 line(index) { if (index === this.length()) { return this.line(index - 1); } return this.descendant(isLine, index); } // 獲取某一範圍的 Blot 對象 lines(index = 0, length = Number.MAX_VALUE) {} // TODO optimize(mutations = [], context = {}) { if (this.batch === true) return; super.optimize(mutations, context); if (mutations.length > 0) { this.emitter.emit(Emitter.events.SCROLL_OPTIMIZE, mutations, context); } } // 實際上調用的是父類 ContainerBlot 的 path 方法 // 目的是獲得當前位置的 Blot 路徑,並排除 Scroll 本身 // Blot 路徑就和 DOM 節點路徑是對應的 // 好比:DOM 節點路徑 div.ql-editor -> p -> strong, // 對應 Blot 路徑就是 [[Scroll div.ql-editor, 0], [Block p, 0], [Bold strong, 6]] path(index) { return super.path(index).slice(1); // Exclude self } // TODO update(mutations) { if (this.batch === true) return; ... } } Scroll.blotName = 'scroll'; Scroll.className = 'ql-editor'; Scroll.tagName = 'DIV'; Scroll.defaultChild = 'block'; Scroll.allowedChildren = [Block, BlockEmbed, Container]; 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團隊,歡迎來這裏和咱們一塊兒打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。
文/DevUI Kagol