現代富文字編輯器Quill的模組化機制
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的模組有所瞭解。
比如,當我們需要定製自己的工具欄按鈕時,會配置工具欄模組:
var quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [['bold', 'italic'], ['link', 'image']]
}
});複製程式碼
其中的modules
引數就是用來配置模組的。
toolbar
引數用來配置工具欄模組,這裡傳入一個二維陣列,表示分組後的工具欄按鈕。
渲染出來的編輯器將包含4個工具欄按鈕:
要看以上Demo,請怒戳配置工具欄模組。
Quill模組是一個普通的JS類
那麼Quill模組是什麼呢?我們為什麼要了解和使用Quill模組呢?
Quill模組其實就是一個普通的JavaScript類
,有建構函式,有成員變數,有方法。
以下是工具欄模組的大致原始碼結構:
class Toolbar {
constructor(quill, options) {
// 解析傳入模組的工具欄配置(就是前面介紹的二維陣列),並渲染工具欄
}
addHandler(format, handler) {
this.handlers[format] = handler;
}
...
}複製程式碼
可以看到工具欄模組就是一個普通的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
,我們可以這樣配置:
modules: {
keyboard: {
bindings: {
strike: {
key: 'S',
ctrlKey: true,
shiftKey: true,
handler: function(range, context) {
const format = this.quill.getFormat(range);
this.quill.format('strike', !format.strike);
}
},
}
},
toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]
}複製程式碼
要看以上Demo,請怒戳配置鍵盤模組。
在使用Quill開發富文字編輯器的過程中,我們會遇到各種模組,也會建立很多自定義模組,所有模組都是通過modules引數進行配置的。
接下來我們將嘗試建立一個自定義模組,加深對Quill模組和模組配置的理解。
建立自定義模組
通過上一節的介紹,我們瞭解到其實Quill模組就是一個普通的JS類,並沒有什麼特殊的,在該類的初始化引數中會傳入Quill例項和該模組的options配置引數,然後就可以控制並增強編輯器的功能。
當Quill內建模組無法滿足我們的需求時,就需要建立自定義模組來實現我們想要的功能。
比如:在EditorX富文字元件中有一個統計編輯器當前字數的功能,該功能就是通過自定義模組來實現的,下面我們將一步一步介紹如何將改該功能封裝成獨立的Counter
模組。
建立一個Quill模組分三步:
第一步:建立模組類
新建一個JS檔案,裡面是一個普通的JavaScript類。
class Counter {
constructor(quill, options) {
console.log('quill:', quill);
console.log('options:', options);
}
}
export default Counter;複製程式碼
這是一個空類,什麼都沒有,只是在初始化方法中列印了Quill例項和模組的options配置資訊。
第二步:配置模組引數
modules: {
toolbar: [
['bold', 'italic'],
['link', 'image']
],
counter: true
}複製程式碼
我們先不傳配置資料,只是簡單地將該模組啟用起來,結果發現並沒有列印資訊。
第三步:註冊模組
要使用一個模組,需要在Quill初始化之前先呼叫Quill.register方法註冊該模組類(後面我們詳細介紹其中的原理),並且由於我們需要擴充套件的是模組(module),所以字首需要以modules開頭:
import Quill from 'quill';
import Counter from './counter';
Quill.register('modules/counter', Counter);複製程式碼
這時我們能看到資訊已經打印出來。
新增模組的邏輯
這時我們在Counter模組中加點邏輯,用於統計當前編輯器內容的字數:
constructor(quill, options) {
this.container = quill.addContainer('ql-counter');
quill.on(Quill.events.TEXT_CHANGE, () => {
const text = quill.getText(); // 獲取編輯器中的純文字內容
const char = text.replace(/\s/g, ''); // 使用正則表示式將空白字元去掉
this.container.innerHTML = `當前字數:${char.length}`;
});
}複製程式碼
在Counter模組的初始化方法中,我們呼叫Quill提供的addContainer方法,為編輯器增加一個空的容器,用於存放字數統計模組的內容,然後繫結編輯器的內容變更事件,這樣當我們在編輯器中輸入內容時,字數能實時統計。
在Text Change事件中,我們呼叫Quill例項的getText方法獲取編輯器裡的純文字內容,然後用正則表示式將其中的空白字元去掉,最後將字數資訊插入到字元統計的容器中。
展示的大致效果如下:
要看以上Demo,請怒戳自定義字元統計模組。
模組載入機制
對Quill模組有了初步的理解之後,我們就會想知道Quill模組是如何運作的,下面將從Quill的初始化過程切入,通過工具欄模組的例子,深入探討Quill的模組載入機制。(本小結涉及Quill原始碼的解析,有不懂的地方歡迎留言討論)
Quill類的初始化
當我們執行new Quill()的時候,會執行Quill類的constructor方法,該方法位於Quill原始碼的core/quill.js
檔案中。
初始化方法的大致原始碼結構如下(移除模組載入無關的程式碼):
constructor(container, options = {}) {
this.options = expandConfig(container, options); // 擴充套件配置資料,包括增加主題類等
...
this.theme = new this.options.theme(this, this.options); // 1.使用options中的主題類初始化主題例項
// 2.增加必需模組
this.keyboard = this.theme.addModule('keyboard');
this.clipboard = this.theme.addModule('clipboard');
this.history = this.theme.addModule('history');
this.theme.init(); // 3.初始化主題,這個方法是模組渲染的核心(實際的核心是其中呼叫的addModule方法),會遍歷配置的所有模組類,並將它們渲染到DOM中
...
}複製程式碼
Quill在初始化時,會使用expandConfig
方法對傳入的options進行擴充套件,加入主題類等元素,用於初始化主題。(不配置主題也會有預設的BaseTheme主題)
之後呼叫主題例項的addModule
方法將內建必需模組掛載到主題例項中。
最後呼叫主題例項的init
方法將所有模組渲染到DOM。(後面會詳細介紹其中的原理)
如果是snow主題,此時將會看到編輯器上方出現工具欄:
如果是bubble主題,那麼當選中一段文字時,會出現工具欄浮框:
接下來我們以工具欄模組為例,詳細介紹Quill模組的載入和渲染原理。
工具欄模組的載入
以snow主題為例,當初始化Quill例項時配置以下引數:
{
theme: 'snow',
modules: {
toolbar: [['bold', 'italic', 'strike'], ['link', 'image']]
}
}複製程式碼
Quill的constructor方法中獲取到的this.theme是SnowTheme類的例項,執行this.theme.init()
方法時呼叫的是其父類Theme的init方法,該方法位於core/theme.js
檔案。
init() {
// 遍歷Quill options中的modules引數,將所有使用者配置的modules掛載到主題類中
Object.keys(this.options.modules).forEach(name => {
if (this.modules[name] == null) {
this.addModule(name);
}
});
}複製程式碼
它會遍歷options.modules引數中的所有模組,呼叫BaseTheme的addModule方法,該方法位於themes/base.js
檔案。
addModule(name) {
const module = super.addModule(name);
if (name === 'toolbar') {
this.extendToolbar(module);
}
return module;
}複製程式碼
該方法會先執行其父類的addModule方法,將所有模組初始化,如果是工具欄模組,則會在工具欄模組初始化之後對工具欄模組進行額外的處理,主要是構建icons和繫結超連結快捷鍵。
我們再回過頭來看下BaseTheme的addModule
方法,該方法是模組載入的核心
。
該方法前面我們介紹Quill的初始化時已經見過,載入三個內建必需模組時呼叫過。其實所有模組的載入都會經過該方法,因此有必要研究下這個方法,該方法位於core/theme.js
。
addModule(name) {
const ModuleClass = this.quill.constructor.import(`modules/${name}`); // 匯入模組類,建立自定義模組的時候需要通過Quill.register方法將類註冊到Quill,才能匯入
// 初始化模組類
this.modules[name] = new ModuleClass(
this.quill,
this.options.modules[name] || {},
);
return this.modules[name];
}複製程式碼
addModule方法會先呼叫Quill.import方法匯入模組類
(通過Quill.register方法註冊過的才能匯入)。
然後初始化該類
,將其例項掛載到主題類的modules成員變數中(此時該成員變數已有內建必須模組的例項)。
以工具欄模組為例,在addModule方法中初始化的是Toolbar類,該類位於modules/toolbar.js
檔案。
class Toolbar {
constructor(quill, options) {
super(quill, options);
// 解析modules.toolbar引數,生成工具欄結構
if (Array.isArray(this.options.container)) {
const container = document.createElement('div');
addControls(container, this.options.container);
quill.container.parentNode.insertBefore(container, quill.container);
this.container = container;
} else {
...
}
this.container.classList.add('ql-toolbar');
// 繫結工具欄事件
this.controls = [];
this.handlers = {};
Object.keys(this.options.handlers).forEach(format => {
this.addHandler(format, this.options.handlers[format]);
});
Array.from(this.container.querySelectorAll('button, select')).forEach(
input => {
this.attach(input);
},
);
...
}
}複製程式碼
工具欄模組初始化時會先解析modules.toolbar引數,呼叫addControls
方法生成工具欄按鈕和下拉框(基本原理就是遍歷一個二維陣列,將它們以按鈕/下拉框形式插入到工具欄中),併為它們繫結事件。
function addControls(container, groups) {
if (!Array.isArray(groups[0])) {
groups = [groups];
}
groups.forEach(controls => {
const group = document.createElement('span');
group.classList.add('ql-formats');
controls.forEach(control => {
if (typeof control === 'string') {
addButton(group, control);
} else {
const format = Object.keys(control)[0];
const value = control[format];
if (Array.isArray(value)) {
addSelect(group, format, value);
} else {
addButton(group, format, value);
}
}
});
container.appendChild(group);
});
}複製程式碼
工具欄模組就這樣被載入並渲染到富文字編輯器中,為編輯器操作提供便利。
現在對模組的載入過程做一個小結:
- 模組載入的起點是Theme類的init方法,該方法將option.modules引數裡配置的所有模組載入到主題類的成員變數modules中,並與內建必需模組合併;
- addModule方法會先通過import方法匯入模組類,然後通過new關鍵字建立模組例項;
- 建立模組例項時會執行模組的初始化方法,執行模組的具體邏輯。
以下是模組與編輯器例項的關係圖:
總結
本文先通過2個例子簡單介紹了Quill模組的配置方法,讓大家對Quill模組有個直觀初步的印象。
然後通過字元統計模組這個簡單的例子介紹如何開發自定義Quill模組,對富文字編輯器的功能進行擴充套件。
最後通過剖析Quill的初始化過程,逐步切入Quill模組的載入機制,並詳細闡述了工具欄模組的載入過程。
加入我們
我們是DevUI團隊,歡迎來這裡和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:[email protected]。
文/DevUI Kagol