【quill.js】深入理解quilljs

soccerloway發表於2019-05-06

前言

這篇文章中的很多內容都來自Adam Charron《Getting to know QuillJS - Part 1》中,文中結合了我自己的一些理解和經驗,內容也做了些調整,希望能幫到準備使用quilljs的你。

quilljs是什麼?

quilljs是一個現代富文字編輯器,它具備良好的相容性及強大的可擴充套件性。使用者可以非常方便地實現自定義功能。另一特點是,quilljs自帶一套資料系統來支撐內容生產,Parchment 和 Delta

Parchment

Parchment是抽象的文件模型,是與DOM樹相對應的樹形結構。Parchment樹由 Blot組成,Blot即是DOM Node的對應物,Blot可能包含結構、樣式、內容等。打個比方:使用者在編輯器中輸入了文字“你”,在Parchment樹中就會產生一個TextBlot與之對應。

Delta

Delta是一個扁平的JSON陣列,用於儲存(描述)編輯器中的內容資料。Delta中的每一項代表了一次操作,它的變化會直接影響到編輯器中內容的變化。下面這個delta就表示:

const delta = new Delta().retain(12)
	.delete(4)
	.insert('White', { color: '#fff' });
複製程式碼
  • retain(12) 表示保留編輯器中索引為0 - 12之間的blots;
  • delete(4) 表示接上一個操作後,刪除4個blots;
  • insert('white', { color: '#fff' }) 表示接上一次操作後,插入文字'white', 並對其應用format 'color', #fff'

通過編輯器的方法getContents可以獲取當前編輯器中的內容的delta資料:

image.png

Blot

Blot是Parchment文件的組成部分,它是quilljs中最重要的抽象。有了Blot,可以讓使用者對編輯器中的內容進行操作,而無需對DOM進行直接操作。每一種Blot都需要實現blot介面規範,quill中的內建Blot都繼承自ShadowBlot
為了方便查詢與blot相關的其他blot,所以每個blot都擁有以下這些引用屬性:

  • .parent—父級blot,包含當前blot。若當前blot是頂級blot,則為null
  • .prev—上一個同級blot, 與當前blot擁有同一個parent, 若當前blot為第一個child,則為null
  • .next—下一個同級blot, 與當前blot擁有同一個parent, 若當前blot為最後一個child,則為null
  • .scroll—頂級blot,後面會提供更多關於scroll blot的資訊。
  • .domNode—當前blot的DOM結構,該blot在DOM樹中的實際結構。

Blot生命週期

Blot主要通過呼叫Patchment.create()建立。Blot擁有幾個生命週期方法,你可以通過使用同名方法去覆蓋它們,並根據具體情況在你的邏輯程式碼中通過super去呼叫被你覆蓋的方法,以保證blot的預設行為不被破壞。下面繼續介紹這些生命週期方法:

Blot.create()

每一個Blot都有static create()函式,用於根據初始值建立DOM Node。這裡也非常適合在node上設定一些與Blot例項無關的初始屬性。該函式會返回新建立的DOM Node,但並未插入文件中。此時,Blot也還未例項化成功,因為Blot例項化需要依賴DOM Node。需要注意的是,create()並不是任何時候都會在blot例項化前執行,例如:當使用者通過 複製/貼上 建立blot時,blot的建立會直接接受來自剪下板的HTML結構,從而跳過create方法。

import Block from "quill/blots/block";

class ClickableSpan extends Inline {

    // ...

    static create(initialValue) {
        // Allow the parent create function to give us a DOM Node
        // The DOM Node will be based on the provided tagName and className.
        // E.G. the Node is currently <code class="ClickableSpan">{initialValue}</code>
        const node = super.create();

        // Set an attribute on the DOM Node.
        node.setAttribute("spellcheck", false);

        // Add an additional class
        node.classList.add("otherClass")

        // Returning <code class="ClickableSpan otherClass">{initialValue}</code>
        return node;
    }

    // ...
}
複製程式碼

constructor(domNode)

Blot類的建構函式,通過domNode例項化blot。在這裡可以做一些通常在class的建構函式中做的事情,比如:事件繫結,快取引用等。

class ClickableSpan extends Inline {

    // ...

    constructor(domNode) {
        super(domNode);

        // Bind our click handler to the class.
        this.clickHandler = this.clickHandler.bind(this);
        domNode.addEventListener(this.clickHandler);
    }

    clickHandler(event) {
        console.log("ClickableSpan was clicked. Blot: ", this);
    }

    // ...
}
複製程式碼

Blot註冊

上面兩段程式碼中,我們定義了一個簡單的Blot,但此時還無法在quilljs中使用它,還需要進行註冊,讓Parchment認識我們的Blot。

import Quill from "quill";

// Our Blot from earlier
class ClickableSpan extends Inline { /* ... */ }
ClickableSpan.className = "ClickableSpan";
ClickableSpan.blotName = "ClickableSpan";
ClickableSpan.tagName = "span";

Quill.register(ClickableSpan);
複製程式碼

Blots需要通過唯一標識區分

一般來說,有兩種方式來呼叫:
Parchment.create(blotName)
該方式為Blot例項的主要建立方式,通過傳入已經註冊的blotName來正確建立blot例項。通過quill.update(Delta)或者在邏輯中手動呼叫Patchment.create(blotName)均是此方式。

Parchment.create(domNode)
有時候我們需要通過傳入domNode來建立blot例項,比如:貼上/複製的時候,在這種情況中,Blots就需要用到classNametagName來區分。

在定義一個Blot時,需要為它指定blotNameclassNametagName

// Matches to <strong ...>...</strong>
class Bold extends Inline {}
Bold.tagName = "strong";
Bold.blotName = "bold";

// Matches to <em ...>...</em>
class Italic extends Inline {
    static tagName = "em";
    static blotName = "italic";
}
Bold.tagName = "em";
Bold.blotName = "italic";

// Matches to <em class="italic-alt" ...>...</em>
class AltItalic extends Inline {}
AltItalic.tagName = "em";
AltItalic.blotName = "alt-italic";
AltItalic.className = "italic-alt"
複製程式碼

上面例子中,HTML結構中的<strong></strong><em></em>通過tagName區分生成對應的blot,<em></em><em class="italic-alt"></em>通過className區分可以生成正確的blot。

Blot插入及掛載

經過了Blot的定義和建立的過程,我們還需要將建立好的blot例項插入到quill編輯器的文件樹和HTML DOM樹中。下面介紹兩個api來完成Blot的插入及掛載:

newBlot.insertInto(parentBlot, refBlot)
這是最主要的插入方法,其他幾個插入方法都是基於這個方式實現,該方法就是將newBlot插入到parentBlot的children中,預設作為最後一個元素插入,如果refBlot也正確傳入了,就插入到refBlot前面。

parentBlot.insertBefore(newBlot, refBlot)
這個方法很常用,類似於parentNode.appendChild(domNode),預設作為最後一個元素插入,如果refBlot正確傳入,就插入refBlot之前。

注意:本文更關注quilljs底層的Patchment相關知識,在實際應用quilljs時,經常會通過構造Delta例項呼叫quill.updateContents(Delta)來改變編輯器內容。

Updates 和 Optimization

ScrollBlot是最頂層的ContainerBlot,它包裹其餘所有的blots,並且管理編輯器內的內容變化。ScrollBlot會建立一個MutationObserver,用於掌控編輯器的內容。
ScrollBlot會追蹤MutationRecords,然後呼叫MutationRecordtargetdomNode對應的blot的update方法。相關的MutationRecords會被作為引數傳入。接下來,ScrollBlot會呼叫所有受影響的blot的optimize方法(包括這些blot的child blot)。

update(mutation: MutationRecord[], sharedContext: Object)
Blot發生變化時會被呼叫,引數mutation的target是blot.domNode。在同一次更新迴圈中,所有blots收到的sharedContext是相同的。

optimize(context: Object)
更新迴圈完成後會被呼叫,避免在optimize方法中改變document的length和value。該方法中很適合做一些降低document複雜度的事。
簡單來說,文件的deltaoptimize執行前後應該是一樣的,沒發生變化。否則,將引起效能損耗。

Delection 和 Detachment

remove()
該方法是最常用也最簡單的完全移除blot及其domNode的方法。remove主要是將blot的domNode從DOM樹中移除,並呼叫detach()

removeChild(blot)
該方法只有containerBlot及繼承自containerBlot的類具備,作用是從該containerBlot.children中移除傳入的blot

deleteAt(index, length)
該方法會根據給定的indexlength來移除呼叫者的children中對應的blot及內容,若index為0且length為呼叫者的children的length, 則移除自身。

detach()
解除一切blot與quill相關的引用關係,從blot的parent上移除自身,同時對children blot呼叫detach()

結束語

到這裡,Patchment中Blot的主要生命週期已介紹完畢。quilljs的擴充套件性及其強大,幾乎可以在quill編輯器中實現任何的定製化功能。通常的Block/Embed等Blot的定義都比較簡單,容易理解,而相對複雜的應該是ContainerBlot如何應用。
後面講專門寫一篇文章,介紹**“如何使用Container建立巢狀結構的內容”**,有興趣的朋友可以關注一下。