DevUI是一支兼具設計視角和工程視角的團隊,服務於華為雲 DevCloud平台和華為內部數個中后台系統,服務於設計師和前端工程師。
官方網站: devui.design
Ng組件庫: ng-devui(歡迎Star)
引言
本文基於DevUI的富文本編輯器開發實踐
和Quill源碼
寫成。
EditorX是DevUI開發的一款好用、易用、功能強大的富文本編輯器,它的底層基於Quill,並對其做了大量擴展,以增強編輯器的能力。
Quill是一款API驅動
、支持格式和模塊定制
的開源Web富文本編輯器,目前在Github的Star數超過25k
。
如果還沒有接觸過Quill,建議先去Quill官網了解下它的基本概念。
通過閱讀本文,你將收獲:
- 了解Quill模塊是什么,怎么配置Quill模塊
- 為什么要創建Quill模塊,怎么創建自定義Quill模塊
- Quill模塊如何與Quill進行通信
- 深入了解Quill的模塊化機制
Quill模塊初探
使用Quill開發過富文本應用的人,應該都對Quill的模塊有所了解。
比如,當我們需要定制自己的工具欄按鈕時,會配置工具欄模塊:
1 var quill = new Quill('#editor', { 2 theme: 'snow', 3 modules: { 4 toolbar: [['bold', 'italic'], ['link', 'image']] 5 } 6 });
其中的modules
參數就是用來配置模塊的。
toolbar
參數用來配置工具欄模塊,這里傳入一個二維數組,表示分組后的工具欄按鈕。
渲染出來的編輯器將包含4個工具欄按鈕:
要看以上Demo,請怒戳配置工具欄模塊。
Quill模塊是一個普通的JS類
那么Quill模塊是什么呢?我們為什么要了解和使用Quill模塊呢?
Quill模塊其實就是一個普通的JavaScript類
,有構造函數,有成員變量,有方法。
以下是工具欄模塊的大致源碼結構:
1 class Toolbar { 2 constructor(quill, options) { 3 // 解析傳入模塊的工具欄配置(就是前面介紹的二維數組),並渲染工具欄 4 } 5 6 addHandler(format, handler) { 7 this.handlers[format] = handler; 8 } 9 ... 10 }
可以看到工具欄模塊就是一個普通的JS類。在構造函數中傳入了quill的實例和options配置,模塊類拿到quill實例就可以對編輯器進行控制和操作。
比如:工具欄模塊會根據options配置構造工具欄容器,將按鈕/下拉框等元素填充到該容器中,並綁定按鈕/下拉框的處理事件。最終的結果就是在編輯器主體上方渲染了一個工具欄,可以通過工具欄按鈕/下拉框給編輯器內的元素設置格式,或者在編輯器中插入新元素。
Quill模塊的功能很強大,我們可以利用它來擴展編輯器的能力
,實現我們想要的功能。
除了工具欄模塊之外,Quill還內置了一些很實用的模塊,我們一起來看看吧。
Quill內置模塊
Quill一共內置6個模塊:
- Clipboard 粘貼版
- History 操作歷史
- Keyboard 鍵盤事件
- Syntax 語法高亮
- Toolbar 工具欄
- Uploader 文件上傳
Clipboard、History、Keyboard是Quill必需的內置模塊,會自動開啟,可以配置但不能取消。其中:
Clipboard模塊用於處理復制/粘貼事件、HTML元素節點的匹配以及HTML到Delta的轉換。
History模塊維護了一個操作的堆棧,記錄了每一次的編輯器操作,比如插入/刪除內容、格式化內容等,可以方便地實現撤銷/重做等功能。
Keyboard模塊用於配置鍵盤事件,為實現快捷鍵提供便利。
Syntax模塊用於代碼語法高亮,它依賴外部庫highlight.js,默認關閉,要使用語法高亮功能,必須安裝highlight.js,並手動開啟該功能。
其他模塊不多做介紹,想了解可以參考Quill的模塊文檔。
Quill模塊的配置
剛才提到Keyboard鍵盤事件模塊,我們再舉一個例子,加深對Quill模塊配置的理解。
Keyboard模塊默認支持很多快捷鍵,比如:
- 加粗的快捷鍵是Ctrl+B;
- 超鏈接的快捷鍵是Ctrl+K;
- 撤銷/回退的快捷鍵是Ctrl+Z/Y。
但它不支持刪除線的快捷鍵,如果我們想定制刪除線的快捷鍵,假設是Ctrl+Shift+S
,我們可以這樣配置:
1 modules: { 2 keyboard: { 3 bindings: { 4 strike: { 5 key: 'S', 6 ctrlKey: true, 7 shiftKey: true, 8 handler: function(range, context) { 9 const format = this.quill.getFormat(range); 10 this.quill.format('strike', !format.strike); 11 } 12 }, 13 } 14 }, 15 toolbar: [['bold', 'italic', 'strike'], ['link', 'image']] 16 }
要看以上Demo,請怒戳配置鍵盤模塊。
在使用Quill開發富文本編輯器的過程中,我們會遇到各種模塊,也會創建很多自定義模塊,所有模塊都是通過modules參數進行配置的。
接下來我們將嘗試創建一個自定義模塊,加深對Quill模塊和模塊配置的理解。
創建自定義模塊
通過上一節的介紹,我們了解到其實Quill模塊就是一個普通的JS類,並沒有什么特殊的,在該類的初始化參數中會傳入Quill實例和該模塊的options配置參數,然后就可以控制並增強編輯器的功能。
當Quill內置模塊無法滿足我們的需求時,就需要創建自定義模塊來實現我們想要的功能。
比如:在EditorX富文本組件中有一個統計編輯器當前字數的功能,該功能就是通過自定義模塊來實現的,下面我們將一步一步介紹如何將改該功能封裝成獨立的Counter
模塊。
創建一個Quill模塊分三步:
第一步:創建模塊類
新建一個JS文件,里面是一個普通的JavaScript類。
1 class Counter { 2 constructor(quill, options) { 3 console.log('quill:', quill); 4 console.log('options:', options); 5 } 6 } 7 8 export default Counter;
這是一個空類,什么都沒有,只是在初始化方法中打印了Quill實例和模塊的options配置信息。
第二步:配置模塊參數
1 modules: { 2 toolbar: [ 3 ['bold', 'italic'], 4 ['link', 'image'] 5 ], 6 counter: true 7 }
我們先不傳配置數據,只是簡單地將該模塊啟用起來,結果發現並沒有打印信息。
第三步:注冊模塊
要使用一個模塊,需要在Quill初始化之前先調用Quill.register方法注冊該模塊類(后面我們詳細介紹其中的原理),並且由於我們需要擴展的是模塊(module),所以前綴需要以modules開頭:
1 import Quill from 'quill'; 2 import Counter from './counter'; 3 Quill.register('modules/counter', Counter);
這時我們能看到信息已經打印出來。
添加模塊的邏輯
這時我們在Counter模塊中加點邏輯,用於統計當前編輯器內容的字數:
1 constructor(quill, options) { 2 this.container = quill.addContainer('ql-counter'); 3 quill.on(Quill.events.TEXT_CHANGE, () => { 4 const text = quill.getText(); // 獲取編輯器中的純文本內容 5 const char = text.replace(/\s/g, ''); // 使用正則表達式將空白字符去掉 6 this.container.innerHTML = `當前字數:${char.length}`; 7 }); 8 }
在Counter模塊的初始化方法中,我們調用Quill提供的addContainer方法,為編輯器增加一個空的容器,用於存放字數統計模塊的內容,然后綁定編輯器的內容變更事件,這樣當我們在編輯器中輸入內容時,字數能實時統計。
在Text Change事件中,我們調用Quill實例的getText方法獲取編輯器里的純文本內容,然后用正則表達式將其中的空白字符去掉,最后將字數信息插入到字符統計的容器中。
展示的大致效果如下:
要看以上Demo,請怒戳自定義字符統計模塊。
模塊加載機制
對Quill模塊有了初步的理解之后,我們就會想知道Quill模塊是如何運作的,下面將從Quill的初始化過程切入,通過工具欄模塊的例子,深入探討Quill的模塊加載機制。(本小結涉及Quill源碼的解析,有不懂的地方歡迎留言討論)
Quill類的初始化
當我們執行new Quill()的時候,會執行Quill類的constructor方法,該方法位於Quill源碼的core/quill.js
文件中。
初始化方法的大致源碼結構如下(移除模塊加載無關的代碼):
1 constructor(container, options = {}) { 2 this.options = expandConfig(container, options); // 擴展配置數據,包括增加主題類等 3 ... 4 this.theme = new this.options.theme(this, this.options); // 1.使用options中的主題類初始化主題實例 5 6 // 2.增加必需模塊 7 this.keyboard = this.theme.addModule('keyboard'); 8 this.clipboard = this.theme.addModule('clipboard'); 9 this.history = this.theme.addModule('history'); 10 11 this.theme.init(); // 3.初始化主題,這個方法是模塊渲染的核心(實際的核心是其中調用的addModule方法),會遍歷配置的所有模塊類,並將它們渲染到DOM中 12 ... 13 }
Quill在初始化時,會使用expandConfig
方法對傳入的options進行擴展,加入主題類等元素,用於初始化主題。(不配置主題也會有默認的BaseTheme主題)
之后調用主題實例的addModule
方法將內置必需模塊掛載到主題實例中。
最后調用主題實例的init
方法將所有模塊渲染到DOM。(后面會詳細介紹其中的原理)
如果是snow主題,此時將會看到編輯器上方出現工具欄:
如果是bubble主題,那么當選中一段文本時,會出現工具欄浮框:
接下來我們以工具欄模塊為例,詳細介紹Quill模塊的加載和渲染原理。
工具欄模塊的加載
以snow主題為例,當初始化Quill實例時配置以下參數:
1 { 2 theme: 'snow', 3 modules: { 4 toolbar: [['bold', 'italic', 'strike'], ['link', 'image']] 5 } 6 }
Quill的constructor方法中獲取到的this.theme是SnowTheme類的實例,執行this.theme.init()
方法時調用的是其父類Theme的init方法,該方法位於core/theme.js
文件。
1 init() { 2 // 遍歷Quill options中的modules參數,將所有用戶配置的modules掛載到主題類中 3 Object.keys(this.options.modules).forEach(name => { 4 if (this.modules[name] == null) { 5 this.addModule(name); 6 } 7 }); 8 }
它會遍歷options.modules參數中的所有模塊,調用BaseTheme的addModule方法,該方法位於themes/base.js
文件。
1 addModule(name) { 2 const module = super.addModule(name); 3 if (name === 'toolbar') { 4 this.extendToolbar(module); 5 } 6 return module; 7 }
該方法會先執行其父類的addModule方法,將所有模塊初始化,如果是工具欄模塊,則會在工具欄模塊初始化之后對工具欄模塊進行額外的處理,主要是構建icons和綁定超鏈接快捷鍵。
我們再回過頭來看下BaseTheme的addModule
方法,該方法是模塊加載的核心
。
該方法前面我們介紹Quill的初始化時已經見過,加載三個內置必需模塊時調用過。其實所有模塊的加載都會經過該方法,因此有必要研究下這個方法,該方法位於core/theme.js
。
1 addModule(name) { 2 const ModuleClass = this.quill.constructor.import(`modules/${name}`); // 導入模塊類,創建自定義模塊的時候需要通過Quill.register方法將類注冊到Quill,才能導入 3 // 初始化模塊類 4 this.modules[name] = new ModuleClass( 5 this.quill, 6 this.options.modules[name] || {}, 7 ); 8 return this.modules[name]; 9 }
addModule方法會先調用Quill.import方法導入模塊類
(通過Quill.register方法注冊過的才能導入)。
然后初始化該類
,將其實例掛載到主題類的modules成員變量中(此時該成員變量已有內置必須模塊的實例)。
以工具欄模塊為例,在addModule方法中初始化的是Toolbar類,該類位於modules/toolbar.js
文件。
1 class Toolbar { 2 constructor(quill, options) { 3 super(quill, options); 4 5 // 解析modules.toolbar參數,生成工具欄結構 6 if (Array.isArray(this.options.container)) { 7 const container = document.createElement('div'); 8 addControls(container, this.options.container); 9 quill.container.parentNode.insertBefore(container, quill.container); 10 this.container = container; 11 } else { 12 ... 13 } 14 15 this.container.classList.add('ql-toolbar'); 16 17 // 綁定工具欄事件 18 this.controls = []; 19 this.handlers = {}; 20 Object.keys(this.options.handlers).forEach(format => { 21 this.addHandler(format, this.options.handlers[format]); 22 }); 23 Array.from(this.container.querySelectorAll('button, select')).forEach( 24 input => { 25 this.attach(input); 26 }, 27 ); 28 ... 29 } 30 }
工具欄模塊初始化時會先解析modules.toolbar參數,調用addControls
方法生成工具欄按鈕和下拉框(基本原理就是遍歷一個二維數組,將它們以按鈕/下拉框形式插入到工具欄中),並為它們綁定事件。
1 function addControls(container, groups) { 2 if (!Array.isArray(groups[0])) { 3 groups = [groups]; 4 } 5 groups.forEach(controls => { 6 const group = document.createElement('span'); 7 group.classList.add('ql-formats'); 8 controls.forEach(control => { 9 if (typeof control === 'string') { 10 addButton(group, control); 11 } else { 12 const format = Object.keys(control)[0]; 13 const value = control[format]; 14 if (Array.isArray(value)) { 15 addSelect(group, format, value); 16 } else { 17 addButton(group, format, value); 18 } 19 } 20 }); 21 container.appendChild(group); 22 }); 23 }
工具欄模塊就這樣被加載並渲染到富文本編輯器中,為編輯器操作提供便利。
現在對模塊的加載過程做一個小結:
- 模塊加載的起點是Theme類的init方法,該方法將option.modules參數里配置的所有模塊加載到主題類的成員變量modules中,並與內置必需模塊合並;
- addModule方法會先通過import方法導入模塊類,然后通過new關鍵字創建模塊實例;
- 創建模塊實例時會執行模塊的初始化方法,執行模塊的具體邏輯。
以下是模塊與編輯器實例的關系圖:
總結
本文先通過2個例子簡單介紹了Quill模塊的配置方法,讓大家對Quill模塊有個直觀初步的印象。
然后通過字符統計模塊這個簡單的例子介紹如何開發自定義Quill模塊,對富文本編輯器的功能進行擴展。
最后通過剖析Quill的初始化過程,逐步切入Quill模塊的加載機制,並詳細闡述了工具欄模塊的加載過程。
加入我們
我們是DevUI團隊,歡迎來這里和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。
文/DevUI Kagol