從零實(shí)現(xiàn)富文本編輯器#2-基于MVC模式的編輯器架構(gòu)設(shè)計(jì)
在先前的規(guī)劃中我們是需要實(shí)現(xiàn)MVC架構(gòu)的編輯器,將應(yīng)用程序分為控制器、模型、視圖三個(gè)核心組件,通過控制器執(zhí)行命令時(shí)會(huì)修改當(dāng)前的數(shù)據(jù)模型,進(jìn)而表現(xiàn)到視圖的渲染上。簡單來說就是構(gòu)建一個(gè)描述文檔結(jié)構(gòu)與內(nèi)容的數(shù)據(jù)模型,并且使用自定義的execCommand對數(shù)據(jù)描述模型進(jìn)行修改。以此實(shí)現(xiàn)的L1級(jí)富文本編輯器,通過抽離數(shù)據(jù)模型,解決了富文本中臟數(shù)據(jù)、復(fù)雜功能難以實(shí)現(xiàn)的問題。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項(xiàng)目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實(shí)現(xiàn)富文本編輯器項(xiàng)目的相關(guān)文章:
- 深感一無所長,準(zhǔn)備試著從零開始寫個(gè)富文本編輯器
- 從零實(shí)現(xiàn)富文本編輯器#2-基于MVC模式的編輯器架構(gòu)設(shè)計(jì)
- 從零實(shí)現(xiàn)富文本編輯器#3-基于Delta的線性數(shù)據(jù)結(jié)構(gòu)模型
精簡的編輯器
在整套系統(tǒng)架構(gòu)的設(shè)計(jì)中,最重要的核心理念便是狀態(tài)同步,如果以狀態(tài)模型為基準(zhǔn),那么我們需要維護(hù)的狀態(tài)同步就可以歸納為下面的兩方面:
- 將用戶操作狀態(tài)同步到狀態(tài)模型中,當(dāng)用戶操作文本狀態(tài)時(shí),例如用戶的選區(qū)操作、輸入操作、刪除操作等,需要將變更操作到狀態(tài)模型中。
- 將狀態(tài)模型狀態(tài)同步到視圖層中,當(dāng)在控制層中執(zhí)行命令時(shí),需要將經(jīng)過變更后生成的新狀態(tài)模型同步到視圖層中,保證數(shù)據(jù)狀態(tài)與視圖的一致。
其實(shí)這兩個(gè)狀態(tài)同步是個(gè)正向依賴的過程,用戶操作形成的狀態(tài)同步到狀態(tài)模型,狀態(tài)模型的變更同步到視圖層,視圖層則又是用戶操作的基礎(chǔ)。舉個(gè)例子,當(dāng)用戶通過拖拽選擇部分文本時(shí),需要將其選中的范圍同步到狀態(tài)模型。當(dāng)此時(shí)執(zhí)行刪除操作時(shí),需要將數(shù)據(jù)中的這部分文本刪除,之后再刷新視圖的到新的DOM結(jié)構(gòu)。下次循環(huán)就需要繼續(xù)保證狀態(tài)的同步,然后執(zhí)行輸入、刷新視圖等操作。
由此我們的目標(biāo)主要是狀態(tài)同步,雖然看起來僅有簡單的兩個(gè)原則,但是這件事做起來并沒有那么簡單。當(dāng)我們執(zhí)行狀態(tài)同步時(shí),是非常依賴瀏覽器的相關(guān)API的,例如選區(qū)、輸入、鍵盤等事件。然而此時(shí)我們必須要處理瀏覽器的相關(guān)問題,例如截止目前ContentEditable無法真正阻止IME的輸入,EditContext的兼容性也還有待提升,這些都是我們需要處理的問題。
實(shí)際上當(dāng)我們用到了越多的瀏覽器API實(shí)現(xiàn),我們就需要考慮越多的瀏覽器兼容性問題。因此富文本編輯器的實(shí)現(xiàn)才會(huì)出現(xiàn)很多非ContentEditable的實(shí)現(xiàn),例如如釘釘文檔的自繪選區(qū)、Google Doc的Canvas文檔繪制等。但是這樣雖然能夠降低部分瀏覽器API的依賴,但是也無法真正完全脫離瀏覽器的實(shí)現(xiàn),因此即使是Canvas繪制的文檔,也必須要依賴瀏覽器的API來實(shí)現(xiàn)輸入、位置計(jì)算等等。
回到我們的精簡編輯器模型,先前的文章已經(jīng)提到了ContentEditable屬性以及execCommand命令,通過document.execCommand來執(zhí)行命令修改HTML的方案雖然簡單,但是很明顯其可控性比較差。execCommand命令的行為在各個(gè)瀏覽器的表現(xiàn)是不一致的,這也是之前我們提到的瀏覽器兼容行為的一種,然而這些行為我們也沒有任何辦法去控制,這都是其默認(rèn)的行為。
<div>
<button id="$1">加粗</button>
<div style="border: 1px solid #eee; outline: none" contenteditable>123123</div>
</div>
<script>
$1.onclick = () => {
document.execCommand("bold");
};
</script>
因此為了更強(qiáng)的擴(kuò)展以及可控性,也解決數(shù)據(jù)與視圖無法對應(yīng)的問題,L1的富文本編輯器使用了自定義數(shù)據(jù)模型的概念。即在DOM樹的基礎(chǔ)上抽離出來的數(shù)據(jù)結(jié)構(gòu),相同的數(shù)據(jù)結(jié)構(gòu)可以保證渲染的HTML也是相同的,配合自定義的命令直接控制數(shù)據(jù)模型,最終保證渲染的HTML文檔的一致性。對于選區(qū)的表達(dá),則需要根據(jù)DOM選區(qū)來不斷normalize選區(qū)Model。
這也就是我們今天要聊的MVC模型架構(gòu),我們組織編輯器項(xiàng)目是通過monorepo的形式來管理的相關(guān)包,這樣就自然而然地可以形成分層的架構(gòu)。不過在此之前,我們可以在HTML文件中實(shí)現(xiàn)最基準(zhǔn)的編輯器 simple-mvc.html,當(dāng)然我們還是實(shí)現(xiàn)最基本的加粗能力,主要關(guān)注點(diǎn)在于整個(gè)流程的控制。而針對輸入的能力則是更加復(fù)雜的問題,我們暫時(shí)就不處理了,這部分需要單獨(dú)開章節(jié)來敘述。
數(shù)據(jù)模型
首先我們需要定義數(shù)據(jù)模型,這里的數(shù)據(jù)模型需要有兩部分,一部分是描述文檔內(nèi)容的節(jié)點(diǎn),另一部分是針對數(shù)據(jù)結(jié)構(gòu)的操作。首先來看描述文檔的內(nèi)容,我們?nèi)匀灰员馄降臄?shù)據(jù)結(jié)構(gòu)來描述內(nèi)容,此外為了簡單描述DOM結(jié)構(gòu),此處不會(huì)存在多級(jí)的DOM嵌套。
let model = [
{ type: "strong", text: "123", start: 0, len: 3 },
{ type: "span", text: "123123", start: 3, len: 6 },
];
在上述的數(shù)據(jù)中,type即為節(jié)點(diǎn)類型,text則為文本內(nèi)容。而數(shù)據(jù)模型僅是描述數(shù)據(jù)結(jié)構(gòu)還不夠,我們還需要額外增加狀態(tài)來描述位置信息,也就是上述數(shù)據(jù)中的start和len,這部分?jǐn)?shù)據(jù)對于我們計(jì)算選區(qū)變換很有用。
因此數(shù)據(jù)模型這部分不僅僅是數(shù)據(jù),更應(yīng)該被稱作為狀態(tài)。接下來則是針對數(shù)據(jù)結(jié)構(gòu)的操作,也就是說針對數(shù)據(jù)模型的插入、刪除、修改等操作。在這里我們簡單定義了數(shù)據(jù)截取的操作,而完整的compose操作則可以參考 delta.ts。
截取數(shù)據(jù)的操作是執(zhí)行compose操作的基礎(chǔ),當(dāng)我們存在原文和變更描述時(shí),需要分別將其轉(zhuǎn)換為迭代器對象來截取數(shù)據(jù),以此來構(gòu)造新的數(shù)據(jù)模型。這里的迭代器部分先定義了peekLength和hasNext兩個(gè)方法,用于判斷當(dāng)前數(shù)據(jù)是否存在剩余可取得的部分,以及是否可繼續(xù)迭代。
peekLength() {
if (this.data[this.index]) {
return this.data[this.index].text.length - this.offset;
} else {
return Infinity;
}
}
hasNext() {
return this.peekLength() < Infinity;
}
next方法的處理方式要復(fù)雜一些,這里我們的目標(biāo)主要就是取text的部分內(nèi)容。注意我們每次調(diào)用next是不會(huì)跨節(jié)點(diǎn)的,也就是說每次next最多取當(dāng)前index的節(jié)點(diǎn)所存儲(chǔ)的insert長度。因?yàn)槿绻〉膬?nèi)容超過了單個(gè)op的長度,理論上其對應(yīng)屬性是不一致的,所以不能直接合并。
調(diào)用next方法時(shí),如果不存在length參數(shù),則默認(rèn)為Infinity。然后我們?nèi)‘?dāng)前index的節(jié)點(diǎn),計(jì)算出當(dāng)前節(jié)點(diǎn)的剩余長度,如果取length大于剩余長度,則取剩余長度,否則取希望取得的length長度。然后根據(jù)offset和length來截取text內(nèi)容。
next(length) {
if (!length) length = Infinity;
const nextOp = this.data[this.index];
if (nextOp) {
const offset = this.offset;
const opLength = nextOp.text.length;
const restLength = opLength - offset;
if (length >= restLength) {
length = restLength;
this.index = this.index + 1;
this.offset = 0;
} else {
this.offset = this.offset + length;
}
const newOp = { ...nextOp };
newOp.text = newOp.text.slice(offset, offset + length);
return newOp;
}
return null;
}
以此我們簡單定義了描述數(shù)據(jù)模型的狀態(tài),以及可以用來截取數(shù)據(jù)結(jié)構(gòu)的迭代器。這部分是描述數(shù)據(jù)結(jié)構(gòu)內(nèi)容以及變更的基礎(chǔ),當(dāng)然在這里我們精簡了非常多的內(nèi)容,因此看起來比較簡單。實(shí)際上這里還有非常復(fù)雜的實(shí)現(xiàn),例如如何實(shí)現(xiàn)immutable來減少重復(fù)渲染保證性能。
視圖層
視圖層主要負(fù)責(zé)渲染數(shù)據(jù)模型,這部分我們是可以使用React來渲染的,只不過在這個(gè)簡單例子中,我們可以直接全量創(chuàng)建DOM即可。因此在這里我們直接遍歷數(shù)據(jù)模型,根據(jù)節(jié)點(diǎn)類型來創(chuàng)建對應(yīng)的DOM節(jié)點(diǎn),然后將其插入到contenteditable的div中。
const render = () => {
container.innerHTML = "";
for (const data of model) {
const node = document.createElement(data.type);
node.setAttribute("data-leaf", "true");
node.textContent = data.text;
container.appendChild(node);
MODEL_TO_DOM.set(data, node);
DOM_TO_MODEL.set(node, data);
}
editor.updateDOMselection();
};
這里我們還額外增加了data-leaf屬性,以便于標(biāo)記葉子結(jié)點(diǎn)。我們的選區(qū)更新是需要標(biāo)記葉子結(jié)點(diǎn),以便于能夠正確計(jì)算選區(qū)需要落在某個(gè)DOM節(jié)點(diǎn)上。而MODEL_TO_DOM和DOM_TO_MODEL則是用來維護(hù)Model與DOM的映射關(guān)系,因?yàn)槲覀冃枰鶕?jù)DOM和MODEL來相互獲取對應(yīng)值。
以此我們定義了非常簡單的視圖層,示例中我們不需要考慮太多的性能問題。但是在React真正完成視圖層的時(shí)候,由于非受控的ContentEditable的表現(xiàn),我們就需要考慮非常多的問題,例如key值的維護(hù)、臟DOM的檢查、減少重復(fù)渲染、批量調(diào)度刷新、選區(qū)修正等等。
控制器
控制器則是我們的架構(gòu)中最復(fù)雜的部分,這里存在了大量的邏輯處理。我們的編輯器控制器模型需要在數(shù)據(jù)結(jié)構(gòu)和視圖層的基礎(chǔ)上實(shí)現(xiàn),因此我們就在最后將其敘述,恰好在這里的MVC模型順序的最后即是Controller。在控制器層,總結(jié)起來最主要的功能就是同步,即同步數(shù)據(jù)模型和視圖層的狀態(tài)。
舉個(gè)例子,我們的視圖層是基于數(shù)據(jù)模型來渲染的,假如此時(shí)我們在某個(gè)節(jié)點(diǎn)上輸入了內(nèi)容,那么我們需要將輸入的內(nèi)容同步到數(shù)據(jù)模型中。而如果此時(shí)我們沒有正確同步數(shù)據(jù)模型,那么選區(qū)的長度計(jì)算就會(huì)出現(xiàn)問題,這種情況下自然還會(huì)導(dǎo)致選區(qū)的索引同步出現(xiàn)問題,這里還要區(qū)分受控和非受控問題。
那么首先我們需要關(guān)注選區(qū)的同步,選區(qū)是編輯器操作的基礎(chǔ),選中的狀態(tài)則是操作的基準(zhǔn)位置。同步的本質(zhì)實(shí)現(xiàn)則是需要用瀏覽器的API來同步到數(shù)據(jù)模型中,瀏覽器的選區(qū)存在selectionchange事件,通過這個(gè)事件我們可以關(guān)注到選區(qū)的變化,此時(shí)便可以獲取最新的選區(qū)信息。
通過window.getSelection方法我們可以獲取到當(dāng)前選區(qū)的信息,然后通過getRangeAt就可以拿到選區(qū)的Range對象,我們自然就可以通過Range對象來獲取選區(qū)的開始和結(jié)束位置。有了選區(qū)的起始和結(jié)束位置,我們就可以通過先前設(shè)置的映射關(guān)系來取的對應(yīng)的位置。
document.addEventListener("selectionchange", () => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const { startContainer, endContainer, startOffset, endOffset } = range;
const startLeaf = startContainer.parentElement.closest("[data-leaf]");
const endLeaf = endContainer.parentElement.closest("[data-leaf]");
const startModel = DOM_TO_MODEL.get(startLeaf);
const endModel = DOM_TO_MODEL.get(endLeaf);
const start = startModel.start + startOffset;
const end = endModel.start + endOffset;
editor.setSelection({ start, len: end - start });
editor.updateDOMselection();
});
這里通過選區(qū)節(jié)點(diǎn)獲取對應(yīng)的DOM節(jié)點(diǎn)并不一定是我們需要的節(jié)點(diǎn),瀏覽器的選區(qū)位置規(guī)則對我們的模型來說是不確定的,因此我們需要根據(jù)選區(qū)節(jié)點(diǎn)來查找目標(biāo)的葉子節(jié)點(diǎn)。舉個(gè)例子,普通的文本選中情況下選區(qū)是在文本節(jié)點(diǎn)上的,三擊選中則是在整個(gè)行DOM節(jié)點(diǎn)上的。
因此這里的closest只是處理最普通的文本節(jié)點(diǎn)選區(qū),復(fù)雜的情況還需要進(jìn)行normalize操作。而DOM_TO_MODEL則是狀態(tài)映射,獲取到最近的[data-leaf]節(jié)點(diǎn)就是為了拿到對應(yīng)的狀態(tài),當(dāng)獲取到最新選區(qū)位置之后,是需要更新DOM的實(shí)際選區(qū)位置的,相當(dāng)于校正了瀏覽器本身的選區(qū)狀態(tài)。
updateDOMselection方法則是完全相反的操作,上述的事件處理是通過DOM選區(qū)更新Model選區(qū),而updateDOMselection則是通過Model選區(qū)更新DOM選區(qū)。那么此時(shí)我們是只有start/len,基于這兩個(gè)數(shù)字的到對應(yīng)的DOM并不是簡單的事情,此時(shí)我們需要查找DOM節(jié)點(diǎn)。
const leaves = Array.from(container.querySelectorAll("[data-leaf]"));
這里同樣會(huì)存在不少的DOM查找,因此實(shí)際的操作中也需要盡可能地減少選擇的范圍,在我們實(shí)際的設(shè)計(jì)中,則是以行為基準(zhǔn)查找span類型的節(jié)點(diǎn)。緊接著就需要遍歷整個(gè)leaves數(shù)組,然后繼續(xù)通過DOM_TO_MODEL來獲取DOM對應(yīng)的狀態(tài),然后來獲取構(gòu)造range需要的節(jié)點(diǎn)和偏移。
const { start, len } = editor.selection;
const end = start + len;
for (const leaf of leaves) {
const data = DOM_TO_MODEL.get(leaf);
const leafStart = data.start;
const leafLen = data.text.length;
if (start >= leafStart && start <= leafStart + leafLen) {
startLeaf = leaf;
startLeafOffset = start - leafStart;
// 折疊選區(qū)狀態(tài)下可以 start 與 end 一致
if (windowSelection.isCollapsed) {
endLeaf = startLeaf;
endLeafOffset = startLeafOffset;
break;
}
}
if (end >= leafStart && end <= leafStart + leafLen) {
endLeaf = leaf;
endLeafOffset = end - leafStart;
break;
}
}
當(dāng)查找到目標(biāo)的DOM節(jié)點(diǎn)之后,我們那就可以構(gòu)造出modelRange,并且將其設(shè)置為瀏覽器選區(qū)。但是需要注意的是,我們需要在此處檢查當(dāng)前選區(qū)是否與原本的選區(qū)相同,設(shè)想一下如果再次設(shè)置選區(qū),那么就會(huì)觸發(fā)SelectionChange事件,這樣就會(huì)導(dǎo)致無限循環(huán),自然是需要避免此問題。
if (windowSelection.rangeCount > 0) {
range = windowSelection.getRangeAt(0);
// 當(dāng)前選區(qū)與 Model 選區(qū)相同, 則不需要更新
if (
range.startContainer === modelRange.startContainer &&
range.startOffset === modelRange.startOffset &&
range.endContainer === modelRange.endContainer &&
range.endOffset === modelRange.endOffset
) {
return void 0;
}
}
windowSelection.setBaseAndExtent(
startLeaf.firstChild,
startLeafOffset,
endLeaf.firstChild,
endLeafOffset
);
實(shí)際上選區(qū)的問題不比輸入法的問題少,在這里我們就是非常簡單地實(shí)現(xiàn)了瀏覽器選區(qū)與我們模型選區(qū)的同步,核心仍然是狀態(tài)的同步。接下來就可以實(shí)現(xiàn)數(shù)據(jù)模型的同步,在這里也就是我們實(shí)際執(zhí)行命令的實(shí)現(xiàn),而不是直接使用document.execCommand。
此時(shí)我們先前定義的數(shù)據(jù)迭代器就派上用場了,我們操作的目標(biāo)也是需要使用range來實(shí)現(xiàn),例如123123這段文本在start: 3, len: 2的選區(qū),以及strong的類型,在這區(qū)間內(nèi)的數(shù)據(jù)類型就會(huì)變成123[12 strong]3,這也就是將長數(shù)據(jù)進(jìn)行裁剪的操作。
我們首先根據(jù)需要操作的選區(qū)來構(gòu)造retain數(shù)組,雖然這部分描述本身應(yīng)該構(gòu)造ops來操作,然而這里就需要更多的補(bǔ)充compose的實(shí)現(xiàn),因此這里我們只使用一個(gè)數(shù)組和索引來標(biāo)識(shí)了。
let retain = [start, len, Infinity];
let retainIndex = 0;
然后則需要定義迭代器和retain來合并數(shù)據(jù),這里我們的操作是0索引來移動(dòng)指針以及截取索引內(nèi)的數(shù)據(jù),1索引來實(shí)際變化類型的type,2索引我們將其固定為Infinity,在這種情況下我們是取剩余的所有數(shù)據(jù)。這里重要的length則是取兩者較短的值,以此來實(shí)現(xiàn)數(shù)據(jù)的截取。
const iterator = new Iterator(model);
while (iterator.hasNext()) {
const length = Math.min(iterator.peekLength(), retain[retainIndex]);
const isApplyAttrs = retainIndex === 1;
const thisOp = iterator.next(length);
const nextRetain = retain[retainIndex] - length;
retain[retainIndex] = nextRetain;
if (retain[retainIndex] === 0) {
retainIndex = retainIndex + 1;
}
if (!thisOp) break;
isApplyAttrs && (thisOp.type = type);
newModel.push(thisOp);
}
在最后,還記得我們維護(hù)的數(shù)據(jù)不僅是數(shù)據(jù)表達(dá),更是描述整個(gè)數(shù)據(jù)的狀態(tài)。因此最后我們還需要將所有的數(shù)據(jù)刷新一遍,以此來保證最后的數(shù)據(jù)模型正確,此時(shí)還需要調(diào)用render來重新渲染視圖層,然后重新刷新瀏覽器選區(qū)。
let index = 0;
for (const data of newModel) {
data.start = index;
data.len = data.text.length;
index = index + data.text.length;
}
render();
editor.updateDOMselection();
以此我們定義了相對復(fù)雜的控制器層,這里的控制器層主要是同步數(shù)據(jù)模型和視圖層的狀態(tài),以及實(shí)現(xiàn)了最基本的命令操作,當(dāng)然沒有處理很多復(fù)雜的邊界情況。在實(shí)際的編輯器實(shí)現(xiàn)中,這部分邏輯會(huì)非常復(fù)雜,因?yàn)槲覀冃枰幚矸浅6嗟膯栴},例如輸入法、選區(qū)模型、剪貼板等等。
項(xiàng)目架構(gòu)設(shè)計(jì)
那么我們基本編輯器MVC模型已經(jīng)實(shí)現(xiàn),因此自然而然就可以將其抽象為獨(dú)立的package,恰好我們也是通過monorepo的形式來管理項(xiàng)目的。因此在這里就可以將其抽象為core、delta、react、utils四個(gè)核心包,分別對應(yīng)編輯器的核心邏輯、數(shù)據(jù)模型、視圖層、工具函數(shù)。而具體的編輯器模塊實(shí)現(xiàn),則全部以插件的形式定義在plugin包中。
Core
Core模塊封裝了編輯器的核心邏輯,包括剪貼板模塊、歷史操作模塊、輸入模塊、選區(qū)模塊、狀態(tài)模塊等等,所有的模塊通過實(shí)例化的editor對象引用。這里除了本身分層的邏輯實(shí)現(xiàn)外,還希望能夠?qū)崿F(xiàn)模塊的擴(kuò)展能力,可以通過引用編輯器模塊并且擴(kuò)展能力后,可以重新裝載到編輯器上。
Core
├── clipboard
├── collect
├── editor
├── event
├── history
├── input
├── model
├── perform
├── plugin
├── rect
├── ref
├── schema
├── selection
├── state
└── ...
實(shí)際上Core模塊中存在本身的依賴關(guān)系,例如選區(qū)模塊依賴于事件模塊的事件分發(fā),這主要是由于模塊在構(gòu)造時(shí)需要依賴其他模塊的實(shí)例,以此來初始化本身的數(shù)據(jù)和事件等。因此事件實(shí)例化的順序會(huì)比較重要,但是我們在實(shí)際聊下來的時(shí)候則直接按上述定義順序,并未按照直接依賴的有向圖順序。
clipboard模塊主要負(fù)責(zé)數(shù)據(jù)的序列化與反序列化,以及剪貼板的操作。通常來說,富文本編輯器的DOM結(jié)構(gòu)并沒有那么的規(guī)范,舉個(gè)例子,在slate中我們可以看到諸如data-slate-node、data-slate-leaf等節(jié)點(diǎn)屬性,我們可以將其理解為模版結(jié)構(gòu)。
<p data-slate-node="element">
<span data-slate-node="text">
<span data-slate-leaf="true">
<span data-slate-string="true">text</span>
</span>
</span>
</p>
那么我們通過react來構(gòu)建視圖層自然也會(huì)存在這樣的模版結(jié)構(gòu),因此在序列化的過程中就是需要將這部分復(fù)雜的結(jié)構(gòu)序列化為相對規(guī)范的HTML。特別是很多樣式我們并不是使用規(guī)范的語義標(biāo)簽,而是通過style屬性來實(shí)現(xiàn)的,因此將其規(guī)整化是非常重要的。
反序列化則將HTML轉(zhuǎn)換為編輯器的數(shù)據(jù)模型,這部分實(shí)現(xiàn)則是為了跨編輯器的內(nèi)容粘貼。編輯器內(nèi)建的數(shù)據(jù)結(jié)構(gòu)通常都不一致,因此跨編輯器就需要較為規(guī)范的中間結(jié)構(gòu)。這其實(shí)也是編輯器中不成文的規(guī)定,A編輯器序列化的時(shí)候盡可能規(guī)范,B編輯器反序列化才可以更好地處理。
collect模塊是可以根據(jù)選區(qū)數(shù)據(jù)來得到相關(guān)的數(shù)據(jù),舉個(gè)例子,當(dāng)用戶選中了一段文本,執(zhí)行復(fù)制的時(shí)候就需要將選中的這部分?jǐn)?shù)據(jù)內(nèi)容取出來,然后才能進(jìn)行序列化操作。此外,collect模塊還可以取得某個(gè)位置的op節(jié)點(diǎn)、marks繼承處理等等。
editor模塊是編輯器的模塊聚合類,其本身主要是管理整個(gè)編輯器的生命周期,例如實(shí)例化、掛載DOM、銷毀等狀態(tài)。此模塊需要組合所有的模塊,并且還需要關(guān)注模塊的有向圖組織依賴關(guān)系,主要的編輯器API都應(yīng)該從此模塊暴露出來。
event模塊是事件分發(fā)模塊,原生事件的綁定都是在該模塊中實(shí)現(xiàn),編輯器內(nèi)所有的事件都應(yīng)該從該模塊來分發(fā)。這種方式可以有更高度的自定義空間,例如擴(kuò)展插件級(jí)別的事件執(zhí)行,并且可以減少內(nèi)存泄漏的概率,畢竟只要我們能夠保證編輯器的銷毀方法調(diào)用,那么所有的事件都可以被正確卸載。
history模塊是維護(hù)歷史操作的模塊,在編輯器中實(shí)現(xiàn)undo和redo是比較復(fù)雜的,我們需要基于原子化的操作執(zhí)行,而不是存儲(chǔ)編輯器的全量數(shù)據(jù)快照,并且需要維護(hù)兩個(gè)棧來處理數(shù)據(jù)轉(zhuǎn)移。此外我們還需要在此基礎(chǔ)上實(shí)現(xiàn)擴(kuò)展,例如自動(dòng)組合、操作合并、協(xié)同處理等。
這里的自動(dòng)組合指的是用戶進(jìn)行高頻連續(xù)操作時(shí),我們需要將其合并為一個(gè)操作。操作合并則是指我們可以通過API來實(shí)現(xiàn)合并,例如用戶上傳圖片后,執(zhí)行了其他輸入操作,然后上傳成功后產(chǎn)生的操作,最后這個(gè)操作應(yīng)該合并到上傳圖片的這個(gè)操作上。協(xié)同處理則是需要遵循一個(gè)原則,即我們僅能撤銷屬于自己的操作,而不能撤銷其他人協(xié)同過來的操作。
input模塊是處理輸入的模塊,輸入是編輯器的核心操作之一,我們需要處理輸入法、鍵盤、鼠標(biāo)等輸入操作。輸入法的交互處理是需要非常多的兼容處理,例如輸入法還存在候選詞、聯(lián)想詞、快捷輸入、重音等等。甚至是移動(dòng)端的輸入法兼容更麻煩,在draft中還單獨(dú)列出了移動(dòng)端輸入法的兼容問題。
舉個(gè)目前比較常見的例子,ContentEditable無法真正阻止IME的輸入,這就導(dǎo)致了我們無法真正阻止中文的輸入。在下面的這個(gè)例子中,輸入英文和數(shù)字是不會(huì)有響應(yīng)的,但是中文卻是可以正常輸入的,這也是很多編輯器選擇自繪選區(qū)和輸入的原因之一,例如VSCode、釘釘文檔等。
<div contenteditable id="$1"></div>
<script>
const stop = (e) => {
e.preventDefault();
e.stopPropagation();
};
$1.addEventListener('beforeinput', stop);
$1.addEventListener('input', stop);
$1.addEventListener('keydown', stop);
$1.addEventListener('keypress', stop);
$1.addEventListener('keyup', stop);
$1.addEventListener('compositionstart', stop);
$1.addEventListener('compositionupdate', stop);
$1.addEventListener('compositionend', stop);
</script>
model模塊是用來映射DOM視圖和狀態(tài)模型的關(guān)系,這部分是視圖層和數(shù)據(jù)模型的橋梁,在很多時(shí)候我們需要通過DOM來獲取狀態(tài)模型,同樣也會(huì)需要通過狀態(tài)模型在獲取對應(yīng)的DOM視圖。這部分就是利用WeakMap來維護(hù)映射,以此來實(shí)現(xiàn)狀態(tài)的同步。
perform模塊是封裝了針對數(shù)據(jù)模型執(zhí)行變更的基礎(chǔ)模塊,由于構(gòu)造基本的delta操作會(huì)比較復(fù)雜,例如執(zhí)行屬性marks的變更,是需要過濾掉\n的這個(gè)op,反過來對行屬性的操作則是需要過濾掉普通文本op。因此需要封裝這部分操作,來簡化執(zhí)行變更的成本。
plugin模塊實(shí)現(xiàn)了編輯器的插件化機(jī)制,插件化是非常有必要的,理論上而言普通文本外的所有格式都應(yīng)該由插件來實(shí)現(xiàn)。那么這里的插件化主要是提供了基礎(chǔ)的插件定義和類型,管理了插件的生命周期,以及諸如按方法調(diào)用分組、方法調(diào)度優(yōu)先級(jí)等能力。
rect模塊是用來處理編輯器的位置信息,在很多時(shí)候我們需要根據(jù)DOM節(jié)點(diǎn)來計(jì)算位置,并且需要提供節(jié)點(diǎn)在編輯器的相對位置,特別是很多附加能力中,例如虛擬滾動(dòng)的視口鎖定、對比視圖的虛擬圖層、評(píng)論能力的高度定位等等。此外,選區(qū)的位置信息也是很重要的,例如浮動(dòng)工具欄的彈出位置。
ref模塊是實(shí)現(xiàn)了編輯器的位置轉(zhuǎn)移引用,這部門其實(shí)是利用了協(xié)同的transform來處理的索引信息,類似于slate的PathRef。舉個(gè)例子,當(dāng)用戶上傳圖片后,此時(shí)可能會(huì)進(jìn)行其他的內(nèi)容插入操作,此時(shí)圖片的索引值會(huì)發(fā)生變化,而使用ref模塊則可以拿到最新的索引值。
schema模塊是用來定義編輯器的數(shù)據(jù)應(yīng)用規(guī)則,我們需要在此處定義數(shù)據(jù)屬性需要處理的方法,例如加粗的屬性marks需要在輸入后繼續(xù)繼承加粗屬性,而行內(nèi)代碼inline類型則不需要繼續(xù)繼承,類似于圖片、分割線則需要被定義為獨(dú)占整行的Void類型,Mention、Emoji等則需要被定義為Embed類型。
selection模塊是用來處理選區(qū)的模塊,選區(qū)是編輯器的核心操作基準(zhǔn),我們需要處理選區(qū)同步、選區(qū)校正等等。實(shí)際上選區(qū)的同步是非常復(fù)雜的事情,從瀏覽器的DOM映射到選區(qū)模型本身就是需要精心設(shè)計(jì)的事情,而選區(qū)的校正則是需要處理非常多的邊界情況。
在先前我們也提到了相關(guān)的問題,以下面的DOM結(jié)構(gòu)為例,如果我們要表達(dá)選區(qū)折疊在4這個(gè)字符左側(cè)時(shí),同樣會(huì)出現(xiàn)多種表達(dá)可以實(shí)現(xiàn)這個(gè)位置,這實(shí)際上就會(huì)很依賴瀏覽器的默認(rèn)行為。因此這樣就需要我們自己來保證這個(gè)選區(qū)的映射,以及在非常規(guī)狀態(tài)下的校正邏輯。
// <span>123</span><b><em>456</em></b><span>789</span>
{ node: 123, offset: 3 }
{ node: <em></em>, offset: 0 }
{ node: <b></b>, offset: 0 }
state模塊維護(hù)了編輯器的核心狀態(tài),在實(shí)例化編輯器時(shí)傳遞基本數(shù)據(jù)后,我們后續(xù)維護(hù)的內(nèi)容就變成了狀態(tài),而不是最開始傳遞的數(shù)據(jù)內(nèi)容。我們的狀態(tài)變更方法同樣會(huì)在此處實(shí)現(xiàn),特別是Immutable/Key的狀態(tài)維護(hù),我們需要保證狀態(tài)的不可變性,以此來減少重復(fù)渲染。
|-- LeafState
|-- LineState --|-- LeafState
| |-- LeafState
BlockState --|
| |-- LeafState
|-- LineState --|-- LeafState
|-- LeafState
Delta
Delta模塊封裝了編輯器的數(shù)據(jù)模型,我們需要基于數(shù)據(jù)模型來描述編輯器的內(nèi)容,以及編輯器內(nèi)容的變更。除此之外,還封裝了數(shù)據(jù)模型的諸多操作,例如compose、transform、invert、diff、Iterator等等。
Delta
├── attributes
├── delta
├── mutate
├── utils
└── ...
這里的Delta實(shí)現(xiàn)是基于Quill的數(shù)據(jù)模型改造的,Quill的數(shù)據(jù)模型設(shè)計(jì)非常優(yōu)秀,特別是封裝了基于OT的操作變換等方法。但是設(shè)計(jì)上還是存在不方便的地方,因此參考了EtherPad的數(shù)據(jù)實(shí)現(xiàn),在此基礎(chǔ)上改造了部分實(shí)現(xiàn),我們后續(xù)會(huì)詳細(xì)講述數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)。
此外需要注意的是,我們的Delta實(shí)現(xiàn)最主要的是用來描述文檔以及變更,相當(dāng)于一種序列化和反序列化的實(shí)現(xiàn)。上邊也提到了在初始化編輯器之后,我們維護(hù)的數(shù)據(jù)就變成了內(nèi)建的狀態(tài),而非最初初始化的數(shù)據(jù)內(nèi)容。因此很多方法在控制器層面上,都會(huì)有單獨(dú)的設(shè)計(jì),例如immutable的狀態(tài)維護(hù)。
attributes模塊維護(hù)了針對文本描述屬性的操作,我們在這里簡化了屬性的實(shí)現(xiàn),即AttributeMap類型定義了為<string, string>的類型。而具體的模塊中則定義了compose、invert、transform、diff等方法,以此來實(shí)現(xiàn)屬性的合并、反轉(zhuǎn)、變換、差異等操作。
delta模塊實(shí)現(xiàn)了整個(gè)編輯器的數(shù)據(jù)模型,delta通過ops實(shí)現(xiàn)了線形結(jié)構(gòu)的數(shù)據(jù)模型。ops的結(jié)構(gòu)主要包括三種操作,insert用來描述插入文本、delete用來描述刪除文本、retain用來描述保留文本/移動(dòng)指針,以及在此基礎(chǔ)上的compose、transform等等方法。
mutate模塊則實(shí)現(xiàn)了immutable的delta模塊實(shí)現(xiàn),并且獨(dú)立了\n作為獨(dú)立的op。最初的控制器設(shè)計(jì)實(shí)現(xiàn)是基于數(shù)據(jù)變更實(shí)現(xiàn)的,后續(xù)將其改造為原始狀態(tài)的維護(hù),因此這部分實(shí)現(xiàn)移動(dòng)到了delta模塊中,因此這部分可以直接對應(yīng)編輯器的狀態(tài)維護(hù),可以用于單元測試等等。
utils模塊則封裝了對于op以及delta的輔助方法,clone的相關(guān)方法實(shí)現(xiàn)了諸如op、delta等深拷貝以及對等方法,當(dāng)然由于我們新的設(shè)計(jì)則無需引入lodash的相關(guān)方法。此外還實(shí)現(xiàn)了一些數(shù)據(jù)的判斷以及格式化方法,例如數(shù)據(jù)的起始/結(jié)束字符串判斷、分割\n的方法等等。
React
React模塊實(shí)現(xiàn)了視圖層的適配器,提供了基本的Text、Void、Embed等類型的節(jié)點(diǎn),以及相關(guān)的渲染模式,相當(dāng)于封裝了符合Core模式的調(diào)度范式。并且還提供了相關(guān)包裝的HOC節(jié)點(diǎn),以及Hooks等方法,以此來實(shí)現(xiàn)插件的視圖層擴(kuò)展。
React
├── hooks
├── model
├── plugin
├── preset
└── ...
hooks模塊實(shí)現(xiàn)了獲取編輯器實(shí)例的方法,方便在React組件中獲取編輯器實(shí)例,當(dāng)然這依賴于我們的Provider組件。此外還實(shí)現(xiàn)了readonly的狀態(tài)方法,這里的只讀狀態(tài)維護(hù)本身是維護(hù)在插件中的,但是后來將其提取到了React組件中,這樣能更容易切換編輯/只讀狀態(tài)。
model模塊實(shí)現(xiàn)了編輯器內(nèi)建的數(shù)據(jù)模型,實(shí)際上是對應(yīng)了Core層的State,即Block/Line/Leaf的數(shù)據(jù)模型,這其中除了DOM節(jié)點(diǎn)需要遵循的模式外,還實(shí)現(xiàn)了諸如臟DOM檢測的方式等。此外這里還存在了特殊的EOL節(jié)點(diǎn),是個(gè)特殊的LeafModel,會(huì)根據(jù)策略調(diào)度行尾節(jié)點(diǎn)的渲染。
plugin模塊實(shí)現(xiàn)了編輯器的插件化機(jī)制,這里的插件化主要是擴(kuò)展了基礎(chǔ)的插件定義和類型,例如在Core中定義的插件方法類型返回值是any,在這里我們需要將其定義為具體的ReactNode類型。此外,這里還實(shí)現(xiàn)了渲染時(shí)的插件,即沒有在核心層維護(hù)狀態(tài)的類型,主要是Wrap類型的節(jié)點(diǎn)插件化。
preset模塊預(yù)設(shè)了編輯器對外暴露的API組件,諸如編輯器的Context、Void、Embed、Editable組件等等,主要是提供構(gòu)建編輯器視圖的基礎(chǔ)組件,以及插件層的組件擴(kuò)展等。當(dāng)然還封裝了很多交互實(shí)現(xiàn),例如自動(dòng)聚焦、選區(qū)同步、視圖刷新適配器等等。
Utils
Utils模塊實(shí)現(xiàn)了諸多通用的工具函數(shù),主要是處理編輯器內(nèi)的通用邏輯,例如防抖、節(jié)流等等,也有處理DOM結(jié)構(gòu)的輔助方法,還有事件分發(fā)的處理方法、事件綁定的裝飾器等,以及諸如列表的操作、國際化、剪貼板操作等等。
Utils
├── debounce.ts
├── decorator.ts
├── dom.ts
└── ...
總結(jié)
在這里我們實(shí)現(xiàn)了簡單的編輯器MVC架構(gòu)示例,然后在此基礎(chǔ)上自然而然地抽象出了編輯器的核心模塊、數(shù)據(jù)模型、視圖層、工具函數(shù)等,并且將其做了簡單的敘述。在后續(xù)我們會(huì)描述編輯器的數(shù)據(jù)模型設(shè)計(jì),介紹我們的Delta數(shù)據(jù)結(jié)構(gòu)方法,以及在編輯器中的相關(guān)應(yīng)用場景。數(shù)據(jù)結(jié)構(gòu)是非常重要的設(shè)計(jì),因?yàn)榫庉嬈鞯暮诵牟僮鞫际腔跀?shù)據(jù)模型的,若不能夠理解數(shù)據(jù)結(jié)構(gòu)的設(shè)計(jì),則會(huì)導(dǎo)致難以理解編輯器的很多操作模型。

浙公網(wǎng)安備 33010602011771號(hào)