了解 QuillJS - 第 1 部分(Parchment、Blots 和生命周期)
这是关于 QuillJS 及其数据库 Parchment 的系列博客文章的第一篇。后续文章已计划中,完成后会在此处提供链接。
- 羊皮纸、污点和生命周期
- 容器 - 创建多行块
- 内联嵌入 - 创建 @mention 标记
- 块嵌入 - 不使用 iFrame 创建自定义视频块
注意:本系列文章面向希望深入了解 Quill 和 Parchment 的读者。如果您只是想尝试一款简单易用、功能齐全的编辑器,不妨先阅读 Quill快速入门指南或Parchment 克隆介质指南。
Quill 是什么?
QuillJS是一款专为兼容性和可扩展性而打造的现代富文本编辑器。它由Jason Chen和Byron Milligan创建,并由 Salesforce 开源。自那时起,数百家公司和个人已开始使用它,在浏览器中构建快速、可靠且丰富的编辑体验。
Quill 是一个功能强大的库,支持大多数常见的格式选项,例如粗体、斜体、
罢工、下划线、自定义字体和颜色、分隔线、标题inline code
、、代码块、块引用、列表(项目符号、编号、复选框)、公式、图像以及嵌入式视频。
您还想要什么?
几个月前,我所在的公司Vanilla Forums开始为我们的产品规划一款新的编辑器。我们目前的编辑器支持多种不同的文本输入格式,包括
- Markdown
- BB代码
- HTML
- WYSIWYG HTML(使用 iFrame 呈现内容)
对于所有这些格式,我们都有不同的解析器、渲染器和前端 JavaScript,因此我们着手创建新的编辑器,用单一的、新的、统一的、丰富的编辑体验来取代它们。
我们选择 Quill 作为新编辑器的基础,是因为它的浏览器兼容性和可扩展性,但很快意识到它并不能提供我们所需的所有开箱即用功能。尤其需要注意的是,它缺少像块引用这样的多行块类型结构(缺少嵌套和多行支持)。我们还有一些其他格式项目,例如剧透,也有类似的需求。
我们还添加了一些扩展功能,例如丰富的链接嵌入以及图像和视频的特殊格式选项和功能。
因此,我开始彻底学习Quill及其底层数据库Parchment。本系列文章代表了我对 Parchment 和 QuillJS 的理解。我不是该项目的维护者,因此如果文中有任何错误,欢迎指出。
数据格式
Quill 有两种数据格式:Parchment (Blots) 和Delta。
Parchment 是一种内存数据结构,主要由树形结构的 LinkedList 组成。其 Blot 树应与浏览器的 DOM 节点树 1:1 映射。
Delta 用于存储来自编辑器的持久数据,其形式为相对扁平的 JSON 数组。数组中的每一项代表一个操作,该操作可能影响或代表多个 DOM 节点或 Blot。这种数据通常存储在数据库或持久化存储中。它也用于表示不同状态之间的差异。
什么是 Blot?
Blot 是 Parchment 文档的基石。它们是 Quill 最强大的抽象之一,因为它允许编辑器和 API 用户使用和修改文档内容,而无需直接接触 DOM。Blot 的接口比 DOM 节点更简洁、更具表现力,这使得它们的使用和创建更容易理解。
每个 Blot 都必须实现该接口Blot
,并且 Quill 和 Parchment 中每个现有的 Blot 都是从 继承的类ShadowBlot
。
为了能够从 Blot 的角度浏览文档,每个 Blot 都有以下引用
.parent
- 包含此 Blot 的 Blot。如果此 Blot 是顶级 Blot,parent
则为null
。.prev
- 此 Blot 父 Blot 在树中的上一个同级 Blot。如果此 iBlot 是其 下的第一个子 Blotparent
,prev
则为null
。.next
- 树中的下一个兄弟 Blot 构成此 Blot 的父级。如果此 Blot 是其 下的最后一个子级parent
,next
则为null
。.scroll
- 卷轴是 Parchment 数据结构中顶层的 Blot。更多关于卷轴 Blot 的信息将在稍后提供。.domNode
- 由于 Parchment 树与 DOM 树 1:1 映射,每个 Blot 都可以访问Node
其所代表的 。此外,这些 DOM 节点将拥有对其 Blot 的引用(带有.__blot
)。
Blot 生命周期
每个 Blot 都有多个“生命周期方法”,您可以重写这些方法,以便在流程中的特定时间运行代码。不过,您通常仍需要在super.<OVERRIDEN_METHOD>
插入自定义代码之前或之后调用它们。此组件的生命周期分为多个部分。
创建
正确创建 Blot 有多个步骤,但这些步骤都可以用调用来代替Parchment.create()
Blot.create()
每个 Blot 都有一个static create()
函数,用于根据初始值创建 DOM 节点。这也是为 DOM 节点设置与实际 Blot 实例无关的初始值的好地方。
返回的 DOM 节点实际上并未附加到任何地方,并且 Blot 也尚未创建。这是因为 Blot 是基于DOM 节点创建的,因此此函数会在尚未创建 DOM 节点的情况下将其组合起来。Blot 并非总是使用其 create 函数构建。例如,当用户复制/粘贴文本(无论是从 Quill 还是其他来源)时,复制的 HTML 结构都会传递给Parchment.create()
。Parchment 将跳过调用 create() 并使用传递的 DOM 节点,跳至下一步。
import Block from "quill/blots/block";
class ClickableSpan extends Inline {
// ...
static tagName = "span";
static className = "ClickableSpan";
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)
获取一个 DOM 节点(通常在static create()
函数中创建,但并非总是如此)并从中创建一个 Blot。
这里可以用来实例化任何你想在 Blot 中保留引用的内容。这里也适合注册事件监听器,或者执行任何通常在类构造函数中执行的操作。
调用构造函数后,我们的 Blot 仍然不在 DOM 树中或我们的 Parchment 文档中。
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);
}
// ...
}
登记
Parchment 维护着所有 Blot 的注册表,以简化它们的创建。通过这个注册表,Parchment 暴露了一个函数,Parchment.create()
该函数可以通过 Blot 的名称(使用 Blot 的static create()
函数)或现有的 DOM 节点来创建 Blot。
要使用此注册表,您需要使用 注册您的 Blots Parchment.register()
。使用 Quill 时,最好使用Quill.register()
,它会在内部调用Parchment.register()
。有关 Quillregister
功能的更多详细信息,请参阅Quill 的优秀文档。
import Quill from "quill";
// Our Blot from earlier
class ClickableSpan extends Inline { /* ... */ }
Quill.register(ClickableSpan);
确保 Blot 具有唯一标识符
使用 创建一个 Blot 并Parchment.create(blotName)
传入一个与寄存器对应的字符串时blotName
,您始终会实例化正确的类。您可以拥有两个完全相同、但拥有不同 blotName 的 Blot,并且它们Parchment.create(blotName)
都能正常工作。然而,使用该方法的其他形式时,可能会出现未定义的行为Parchment.create(domNode)
。
虽然你可能知道blotName
手动实例化 Blot 的方法,但在某些情况下,Quill 需要从 DOM 节点创建 Blot,例如复制/粘贴。在这种情况下,你的 Blot 需要通过以下两种方式之一来区分。
按标签名称
import Inline from "quill/blots/inline";
// Matches to <strong ...>...</strong>
class Bold extends Inline {
static tagName = "strong";
static blotName = "bold";
}
// Matches to <em ...>...</em>
class Italic extends Inline {
static tagName = "em";
static blotName = "italic";
}
// Matches to <em ...>...</em>
class AltItalic extends Inline {
static tagName = "em";
static blotName = "alt-italic";
// Returns <em class="alt-italic">...</em>
static create() {
const node = super.create();
node.classList.add("Italic--alt");
}
}
// ... Registration here
在这种情况下,当传递带有标签或 的DOM 节点时, Parchment 可以轻松区分Bold
和Blots ,但无法区分和。Italic
em
strong
Italic
AltItalic
目前,Parchment 区分这些 HTML 结构的唯一其他方法是设置一个static className
与传入的 DOM 节点上预期 CSS 类匹配的 。如果没有提供,您可能会发现自己手动通过它创建了一个自定义 Blot 实例,结果却发现撤消/重做或复制/粘贴操作会将您的 Blot 更改为其他类型。这种情况在使用常见的或 等类型blotName
时尤其常见。tagName
span
div
按类别名称
// ... Bold and Italic Blot from the previous example.
// Matches to <em class="alt-italic">...</em>
class AltItalic extends Inline {
static tagName = "em";
static blotName = "alt-italic";
static className = "Italic--alt";
// Returns <em class="alt-italic">...</em>
}
在这种情况下,static className
已设置。这意味着父级ShadowBlot
将在函数中自动将 应用于className
元素的 DOM 节点static create()
,并且 Parchment 将能够区分这两个 Blot。
插入和连接
现在 Blot 已经创建完毕,我们需要将它附加到 Quill 的文档树和 DOM 树中。有多种方法可以将 Blot 插入到文档中。
insertInto(parentBlot, refBlot)
const newBlot = Parchment.create("someBlotName", initialBlotValue);
const parentBlot = /* Get a reference to the desired parent Blot in some way */;
newBlot.insertInto(parentBlot);
这是主要的插入方法。其他插入方法都调用此方法。它负责将一个 Blot 插入到父 Blot 中。默认情况下,此方法会将 插入到子元素newBlot
的末尾parentBlot
。它的 DOM 节点也会被附加到parentBlot.domNode
。
如果refBlot
也传递了,那么将会插入到父级中,只不过, BlotnewBlot
不会插入到的末尾,而是会插入到之前,并且会插入到之前。parentBlot
refBlot
newBlot.domNode
refBlot.domNode
newBlot.scroll
本次调用结束时,将使用该方法进行额外设置attach()
。详细信息请参阅本文后面部分。
insertAt(index, name, value)
此方法仅适用于继承自 的 Blot ContainerBlot
。后续文章会ContainerBlot
更详细地介绍,但最常见的 Blot 是BlockBlot
、InlineBlot
和ScrollBlot
。EmbedBlot
并且TextBlot
不继承自ContainerBlot
。
此方法将Parchment.create()
使用传递的name
、 和为您value
调用。新创建的 Blot 将插入到给定的 处index
。如果给定索引处有嵌套容器,则调用将传递给树中最深的容器并插入到那里。
insertBefore(childBlot, refBlot)
此方法类似于insertInto()
except reversed。不是子级将自身插入到父级中,而是父级将子级插入到自身中。此处insertInto()
调用 internally 并refBlot
实现相同的目的。
attach()
attach()
将调用 Blot 的父级的 属性附加ScrollBlot
到自身.scroll
。如果调用 Blot 是一个容器,则在设置其自身的 属性后,它还会在其所有子级上调用 附加ScrollBlot
。
更新和优化
注意:我对 Parchment 这部分的理解尚不完整。等我理解得更深入后,我会更新。如果有人能帮忙补充一些信息,尤其是关于在子函数中调用 Optimize() 的次数,我将不胜感激。
是ScrollBlot
顶层ContainerBlot
。它包含所有其他 Blot,并负责管理contenteditable内部的更改。为了控制编辑器的内容,它ScrollBlot
设置了一个MutationObserver。
跟踪ScrollBlot
MutationRecords ,并在每个 DOM 节点为 的 Blot 上调用 的方法。相关的 MutationRecords 将作为参数传递。此外,每次调用都会传递一个共享上下文。update()
target
MutationRecord
update
然后,它ScrollBlot
会获取相同的 MutationRecords,并optimize()
在每个受影响的 Blot及其子 Blot 上递归调用该方法,直至树的底部。相关的 MutationRecords 以及相同的共享上下文都会被传入。
update(mutations: MutationRecord[], sharedContext: Object)
使用 MutationRecords 调用 Blot 的更新方法,该 MutationRecords 指向其 DOM 节点。在单个更新周期内,每个 Blot 共享同一个上下文。
在不同的核心 Blot 中,此方法有 3 种主要实现。
容器印迹法
检查ContainerBlot
修改其直接子代的更改,并将执行以下操作之一:
- 从 DOM 节点已被删除的文档中移除 Blots。
- 为已添加的 DOM 节点添加 Blot。
如果添加了与任何已注册的 Blot 都不匹配的新 DOM 节点,则容器将删除该 DOM 节点,并用InlineBlot
与最初插入的 DOM 节点的文本内容对应的 DOM 节点(基本上是纯文本 Blot)替换它。
文本打印
将用 DOM 树中现有的 DOM 节点的新内容TextBlot
替换它。value
EmbedBlot
parchmentEmbedBlot
中的 没有实现update()
。ParchmentEmbedBlot
及其在 Quill 中的后代类BlockEmbed
都无法控制其子 DOM 节点的突变。
Quill 的另一个EmbedBlot
子类Embed
用 0 宽度空格字符包裹其内容,并将其设置contenteditable=false
为内部子类。在其update()
方法内部,它会检查 MutationRecord 是否会影响characterData
这些空格字符。如果会,Blot 会恢复受影响节点的原始字符数据,并将更改作为文本插入到自身之前或之后。
optimize(context)
该optimize()
方法在更新过程完成后调用。需要注意的是,optimize
调用此方法时切勿更改文档的长度或值。然而,这可以有效降低文档的复杂度。
为了简化,Delta
文档的在优化过程之前或之后应该始终相同。
默认情况下,Blots 仅清理更新过程中剩余的数据,尽管一些 Blots 在此处进行了一些额外的更改。
容器
空元素Containers
要么移除自身,要么重新添加其默认子元素。由于文档的长度在更改前后必须相同,因此默认子元素 Blot 必须是长度为 0 的元素。在 Quill 的Block
Blot 中,该子元素是一个断点。
内联和列表
QuillInline
和List
Blots 都使用优化来简化并使得 DOM 树更加一致。
举个例子,同样的 Delta
[
{
"insert": "bold",
"attributes": {
"bold": true
}
},
{
"insert": "bold italic",
"attributes": {
"bold": true,
"italic": true
}
}
]
可以以 3 种不同的方式呈现。
<strong>bold</strong><strong><em>bold italic</em></strong>
<!-- or -->
<strong>bold</strong><em><strong>bold italic</strong></em>
<!-- or -->
<strong>bold<em>bold italic</em></strong>
Delta 是相同的,并且通常会以相同的方式呈现,但FormatBlot 中的优化实现可确保这些项目始终一致地呈现。
删除和分离
remove()
该方法通常是彻底删除 Blot 及其 DOM 节点的最简单方法。它从 DOM 树中remove()
删除 Blot ,然后调用。.domNode
detach()
removeChild(blot)
此方法仅在 及其子类中可用ContainerBlot
。从调用者的 Blot 中移除传入的 Blot .children
。
deleteAt()
删除指定索引处的 Blot 或内容。remove()
内部调用。
detach()
移除 Quill 对 Blot 的所有引用。这包括使用 移除 Blot 的父级removeChild()
。detach()
如果适用,还会调用任何子 Blot。
总结
主要的生命周期到此结束。其他 Blot 方法,例如replace()
、replaceWith()
、wrap()
和unwrap()
将在本系列的下一篇文章“容器 - 创建多行块”中介绍。
如果您喜欢这篇文章,请保持联系!
- 加入我的 LinkedIn 专业网络
- 在 Twitter 上关注我
- 在 Dev.to 上关注我