第二章:Document 模块与 DOM 树详解
2.1 引言
在 flutter-quill
富文本编辑器中,Document
模块是整个框架的核心,负责管理文档的结构、内容和状态。Document
不仅是一个数据模型,还通过树状结构(类似浏览器的 DOM 树)实现了高效的内容操作和渲染支持。本章将深入分析 Document
类的底层原理,详细解析其源码实现,全面覆盖 nodes
目录下的所有文件类,探讨它们与 Document
的关系,并通过图示和案例详细说明 DOM 树的组成和原理。
2.2 Document
类的底层原理
2.2.1 概述
Document
类定义在 document.dart
文件中,是 flutter-quill
中富文本内容的核心表示。它通过树状结构组织内容,结合 Delta
格式实现内容的动态更新和序列化。以下是 Document
类的核心功能:
- 内容存储:以节点树的形式存储富文本内容。
- 操作接口:提供插入、删除、格式化等编辑操作。
- 历史管理:支持撤销和重做功能。
- 序列化 :将文档内容序列化为
Delta
格式,便于存储和传输。
2.2.2 源码分析:核心属性与初始化
以下是 Document
类的核心定义和初始化逻辑:
scss
class Document {
final Container _root;
final History _history;
Document({Delta? delta})
: _root = Container()..insert('\n'),
_history = History() {
if (delta != null) {
compose(delta, ChangeSource.local);
}
}
Document.fromDelta(Delta delta) : this(delta: delta);
Document.fromJson(List<dynamic> json) : this(delta: Delta.fromJson(json));
}
核心属性解析
-
_root
:- 类型 :
Container
- 作用 :文档的根节点,是整个节点树的起点。
Container
是一种特殊的节点类型,可以包含多个子节点(如Line
和Leaf
)。 - 初始化逻辑 :在构造函数中,
_root
被初始化为一个空的Container
,并插入一个换行符\n
,确保文档至少包含一行。这是为了避免空文档导致的异常,同时为后续插入操作提供一个起点。 - 底层实现 :
_root
是一个链表结构,通过next
和previous
属性连接子节点。每个子节点都有一个parent
属性指向其父节点,从而形成树状结构。
- 类型 :
-
_history
:- 类型 :
History
- 作用:管理文档的变更历史,支持撤销(undo)和重做(redo)功能。
- 初始化逻辑 :
_history
在构造函数中被初始化为一个空的History
实例。它维护了一个操作栈,每次文档发生变化时(如通过compose
方法),都会将变更记录到栈中。 - 底层实现 :
History
使用两个栈(undoStack
和redoStack
)分别存储撤销和重做的操作。每个操作以DocChange
的形式存储,包含变更的Delta
和来源信息。
- 类型 :
初始化逻辑
- 默认构造函数 :
Document({Delta? delta})
创建一个空文档,并插入一个换行符。如果提供了delta
参数,则通过compose
方法将初始内容应用到文档中。 - 从
Delta
构造 :Document.fromDelta(Delta delta)
是工厂构造函数,调用默认构造函数并传入delta
。 - 从 JSON 构造 :
Document.fromJson(List<dynamic> json)
将 JSON 数据反序列化为Delta
,然后构造文档。
示例:初始化文档
ini
final doc = Document();
print(doc.toDelta()); // 输出: [{insert: "\n"}]
final delta = Delta()..insert('Hello');
final doc2 = Document.fromDelta(delta);
print(doc2.toDelta()); // 输出: [{insert: "Hello\n"}]
2.2.3 底层实现:节点树与 Delta
的交互
Document
的底层核心在于节点树(DOM 树)与 Delta
格式的协同工作。以下是交互的详细流程:
-
节点树的构建:
- 节点树以
_root
为起点,包含多层嵌套的节点。每个节点要么是Container
(如Line
),要么是Leaf
(如TextNode
或EmbedNode
)。 - 插入操作会动态创建节点并添加到树中。例如,插入文本 "Hello" 会创建一个
Line
节点和一个TextNode
。
- 节点树以
-
通过
Delta
更新节点树:Delta
是一种操作序列格式,包含insert
、delete
和retain
操作。Document
通过compose
方法将Delta
应用到节点树。- 例如,
Delta()..insert('Hello')
会创建一个新的TextNode
,并将其添加到适当的Line
节点中。
-
从节点树生成
Delta
:toDelta
方法遍历节点树,将每个节点的内容和样式序列化为Delta
格式。
2.2.4 重要方法的底层实现逻辑
compose(Delta delta, ChangeSource source)
- 功能 :将
Delta
操作应用到文档的节点树上。 - 源码:
scss
void compose(Delta delta, ChangeSource source) {
delta.trim();
final change = _root.compose(delta, source);
if (change.isNotEmpty) {
_history.handleDocChange(change);
notifyListeners(change, source);
}
}
-
实现逻辑:
-
修剪
Delta
:delta.trim()
移除末尾无意义的retain
操作,优化性能。 -
应用变更 :调用
_root.compose(delta, source)
,将Delta
传播到节点树。Container.compose
方法会:- 遍历
delta
中的每个操作(Operation
)。 - 根据操作类型(
insert
、delete
、retain
),更新或创建节点。 - 例如,
insert
操作会创建一个新的TextNode
或EmbedNode
。
- 遍历
-
记录历史 :如果生成了有效的
DocChange
(变更记录),则通过_history.handleDocChange
记录到历史栈。 -
通知监听器 :调用
notifyListeners
通知外部(如 UI 层)文档已更新。
-
-
示例:
-
初始文档:
"Hello"
-
Delta
:[{retain: 5}, {insert: " World"}]
-
执行过程:
retain 5
定位到 "Hello" 末尾。insert " World"
创建TextNode(" World")
,插入到节点树。- 结果:
"Hello World"
-
toDelta()
- 功能 :将节点树序列化为
Delta
格式。 - 源码:
scss
Delta toDelta() => _root.toDelta();
-
实现逻辑:
-
从
_root
开始,调用Container.toDelta()
。 -
Container.toDelta()
递归遍历所有子节点:- 对于
Line
节点,收集其Leaf
节点的Delta
表示。 - 对于
TextNode
,返回{insert: text, attributes: {...}}
。 - 对于
EmbedNode
,返回{insert: {type: data}}
。
- 对于
-
合并所有节点的
Delta
,生成完整的文档快照。
-
-
示例:
-
文档结构:
scssContainer (root) └── Line ├── TextNode("Hello", {bold: true}) └── TextNode(" World")
-
输出:
[{insert: "Hello", attributes: {bold: true}}, {insert: " World"}]
-
insert(int index, dynamic data)
- 功能:在指定位置插入内容。
- 源码:
scss
void insert(int index, dynamic data, {Attribute? attribute}) {
final delta = Delta()
..retain(index)
..insert(data, attribute?.toJson());
compose(delta, ChangeSource.local);
}
-
实现逻辑:
-
构造
Delta
:retain(index)
:定位到插入位置。insert(data, attributes)
:插入内容和样式。
-
调用
compose
方法,将Delta
应用到节点树。 -
在节点树中,
Container.compose
会:- 找到插入位置对应的
Line
和Leaf
。 - 创建新的
TextNode
或EmbedNode
,插入到适当位置。
- 找到插入位置对应的
-
-
示例:
- 初始文档:
"Hello"
- 调用:
insert(5, " World")
- 生成
Delta
:[{retain: 5}, {insert: " World"}]
- 结果:
"Hello World"
- 初始文档:
delete(int index, int length)
- 功能:从指定位置删除指定长度的内容。
- 源码:
scss
void delete(int index, int len) {
final delta = Delta()
..retain(index)
..delete(len);
compose(delta, ChangeSource.local);
}
-
实现逻辑:
-
构造
Delta
:retain(index)
:定位到删除起点。delete(len)
:删除指定长度。
-
调用
compose
方法。 -
在节点树中,
Container.compose
会:- 找到删除范围对应的
Leaf
节点。 - 移除或调整节点内容。
- 找到删除范围对应的
-
-
示例:
- 初始文档:
"Hello World"
- 调用:
delete(5, 6)
- 生成
Delta
:[{retain: 5}, {delete: 6}]
- 结果:
"Hello"
- 初始文档:
format(int index, int length, Attribute attribute)
- 功能:为指定范围应用样式。
- 源码:
scss
void format(int index, int len, Attribute attribute) {
final delta = Delta()
..retain(index)
..retain(len, attribute.toJson());
compose(delta, ChangeSource.local);
}
-
实现逻辑:
-
构造
Delta
:retain(index)
:定位到格式化起点。retain(len, attributes)
:为指定范围应用样式。
-
调用
compose
方法。 -
Container.compose
会更新对应Leaf
节点的样式。
-
-
示例:
- 初始文档:
"Hello World"
- 调用:
format(0, 5, Attribute.bold)
- 生成
Delta
:[{retain: 5, attributes: {bold: true}}]
- 结果:
"Hello"
变为粗体。
- 初始文档:
2.2.5 性能优化与设计考量
- 链表结构:节点树使用链表而非数组,便于动态插入和删除操作。
- 增量更新 :通过
Delta
实现增量更新,避免全量重建节点树。 - 历史管理 :
History
的栈式设计支持高效的撤销/重做。 - 惰性计算 :如
length
属性通过递归计算,避免频繁更新。
2.3 nodes
目录下的文件类详解及其与 Document
的关系
nodes
目录包含文档节点系统的核心实现,共有以下文件:node.dart
、line.dart
、leaf.dart
和 embed.dart
。这些类共同构成了 Document
的节点树。
2.3.1 node.dart
-
作用:定义节点系统的基类和通用接口。
-
核心类:
-
Node
:-
定义:抽象基类,定义了节点的通用方法和属性。
-
属性:
parent
:父节点,指向Container
。next
:下一个兄弟节点。previous
:上一个兄弟节点。
-
方法:
insert(int index, dynamic data)
:插入内容。delete(int index, int length)
:删除内容。toDelta()
:序列化为Delta
。
-
与
Document
的关系 :Node
是所有节点的基类,Document
的_root
及其子节点都是Node
的实例。
-
-
Container
:-
定义 :继承自
Node
,表示可包含子节点的容器。 -
属性:
children
:子节点列表。
-
方法:
appendChild(Node child)
:添加子节点。removeChild(Node child)
:移除子节点。compose(Delta delta, ChangeSource source)
:应用Delta
操作。
-
与
Document
的关系 :_root
是Container
的实例,作为节点树的根节点,负责管理所有子节点。
-
-
Leaf
:-
定义 :抽象类,继承自
Node
,表示叶子节点。 -
方法:
applyAttribute(Attribute attr)
:应用样式。
-
与
Document
的关系 :Leaf
是文档内容的最小单元,直接存储文本或嵌入对象。
-
-
示例:构建节点关系
ini
final container = Container();
final leaf = TextNode('Text');
container.appendChild(leaf);
assert(leaf.parent == container);
2.3.2 line.dart
-
作用 :定义
Line
类,表示文档中的一行。 -
实现:
- 继承自
Container
,管理该行内的Leaf
节点。 - 支持行级样式(如对齐、缩进)。
- 继承自
-
核心方法:
-
applyAttribute(Attribute attr)
:- 功能:应用行级样式。
- 实现:将样式存储在
Line
节点的attributes
中,并通知子节点更新。
-
toDelta()
:- 功能:序列化该行的内容和样式。
- 实现:遍历所有
Leaf
节点,收集它们的Delta
表示。
-
-
与
Document
的关系:Line
是_root
的直接子节点,表示文档的一行内容。- 插入换行符时,
Document
会创建一个新的Line
节点。
示例:设置行样式
scss
final line = Line();
line.insert(0, 'Centered Text');
line.applyAttribute(Attribute.center);
print(line.toDelta());
// 输出: [{insert: "Centered Text\n", attributes: {align: "center"}}]
2.3.3 leaf.dart
-
作用 :定义
Leaf
类的具体实现,作为内容的最小单元。 -
子类:
-
TextNode
:-
功能:存储文本内容及其样式。
-
属性:
value
:文本内容。attributes
:样式属性(如粗体、下划线)。
-
方法:
applyAttribute(Attribute attr)
:应用样式。toDelta()
:序列化为Delta
。
-
-
EmbedNode
:- 功能 :存储嵌入对象(详见
embed.dart
)。
- 功能 :存储嵌入对象(详见
-
-
与
Document
的关系:Leaf
节点是Line
的子节点,直接承载Document
的内容。- 例如,
insert
操作会在适当位置创建TextNode
。
示例:文本样式
ini
final textNode = TextNode('Bold Text');
textNode.applyAttribute(Attribute.bold);
print(textNode.toDelta());
// 输出: [{insert: "Bold Text", attributes: {bold: true}}]
2.3.4 embed.dart
-
作用 :定义
EmbedNode
类,用于处理嵌入内容(如图片、视频)。 -
实现:
-
继承自
Leaf
,通过Embeddable
接口支持扩展。 -
属性:
type
:嵌入类型(如 "image")。data
:嵌入数据(如图片 URL)。
-
方法:
toDelta()
:序列化为Delta
。
-
-
与
Document
的关系:EmbedNode
是Leaf
的子类,与TextNode
并列存储在Line
中。- 插入嵌入对象时,
Document
会创建EmbedNode
。
示例:插入图片
ini
final imageEmbed = EmbedNode('image', {'source': 'https://example.com/image.jpg'});
print(imageEmbed.toDelta());
// 输出: [{insert: {"image": "https://example.com/image.jpg"}}]
2.4 DOM 树的概念与组成
2.4.1 什么是 DOM 树?
DOM 树(Document Object Model Tree)是一种树状数据结构,用于表示文档的层次结构。在 flutter-quill
中,Document
的节点树就是一种 DOM 树,类似于浏览器中的 DOM,但专门为富文本编辑设计。DOM 树的核心特点是:
- 层次结构:节点以父子关系组织,根节点是整个文档的起点。
- 动态性:支持动态插入、删除和修改节点。
- 内容表示:叶子节点存储实际内容(如文本或嵌入对象),容器节点定义结构(如行)。
2.4.2 DOM 树的组成
在 flutter-quill
中,DOM 树由以下节点类型组成:
-
根节点 (
Container
) :- 表示整个文档,通常是一个空的
Container
,包含所有子节点。 - 由
Document
的_root
属性管理。
- 表示整个文档,通常是一个空的
-
行节点 (
Line
) :- 表示文档中的一行,继承自
Container
,包含多个Leaf
节点。 - 支持行级样式,如对齐、缩进。
- 表示文档中的一行,继承自
-
叶子节点 (
Leaf
) :TextNode
:存储文本内容及其样式。EmbedNode
:存储嵌入对象,如图片或视频。
2.4.3 DOM 树图示
以下是一个示例文档的 DOM 树结构:
scss
Document
└── Container (root)
├── Line
│ ├── TextNode("Hello", attributes: {bold: true})
│ └── TextNode(" World")
├── Line
│ └── EmbedNode("image", data: {"source": "https://example.com/image.jpg"})
└── Line
└── TextNode("End")
-
解释:
- 根节点 :
Container (root)
是_root
,包含三个Line
节点。 - 第一行 :包含两个
TextNode
,表示带粗体样式的 "Hello" 和普通文本 " World"。 - 第二行 :包含一个
EmbedNode
,表示嵌入的图片。 - 第三行 :包含一个
TextNode
,表示文本 "End"。
- 根节点 :
2.4.4 DOM 树的动态变化
DOM 树是动态的,可以通过编辑操作实时更新。以下是几个常见操作及其对 DOM 树的影响:
-
插入文本:
-
示例:
insert(5, " New")
-
初始 DOM 树:
scssContainer (root) └── Line ├── TextNode("Hello") └── TextNode(" World")
-
操作后:
scssContainer (root) └── Line ├── TextNode("Hello") ├── TextNode(" New") └── TextNode(" World")
-
-
删除内容:
-
示例:
delete(5, 6)
-
初始 DOM 树:
scssContainer (root) └── Line ├── TextNode("Hello") └── TextNode(" World")
-
操作后:
scssContainer (root) └── Line └── TextNode("Hello")
-
-
插入嵌入对象:
-
示例:
insert(11, "", {"image": "url"})
-
初始 DOM 树:
scssContainer (root) └── Line ├── TextNode("Hello") └── TextNode(" World")
-
操作后:
scssContainer (root) ├── Line │ ├── TextNode("Hello") │ └── TextNode(" World") └── Line └── EmbedNode("image", data: {"source": "url"})
-
2.4.5 DOM 树与渲染的关系
DOM 树不仅是数据模型,也是渲染的基础。在 flutter-quill
中,QuillEditor
会遍历 DOM 树,将每个 Line
和 Leaf
渲染为 Flutter 的 RichText
组件:
Line
节点对应一个段落。TextNode
渲染为TextSpan
,应用其样式。EmbedNode
渲染为自定义 Widget(如图片)。
2.5 综合案例
以下是一个综合案例,展示 Document
和节点系统的完整功能:
css
// 创建文档
final doc = Document();
// 插入文本并应用样式
doc.insert(0, 'Hello World');
doc.format(0, 5, Attribute.bold);
doc.format(6, 5, Attribute.italic);
// 插入换行和嵌入对象
doc.insert(11, '\n');
doc.insert(12, '', {'image': 'https://example.com/pic.jpg'});
// 插入新行
doc.insert(13, '\nEnd');
// 检查 DOM 树
print(doc.toDelta());
// 输出: [{insert: "Hello", attributes: {bold: true}}, {insert: " World", attributes: {italic: true}}, {insert: "\n"}, {insert: {"image": "https://example.com/pic.jpg"}}, {insert: "\nEnd\n"}]
// 撤销操作
doc.undo();
print(doc.toDelta());
// 输出: [{insert: "Hello", attributes: {bold: true}}, {insert: " World", attributes: {italic: true}}, {insert: "\n"}, {insert: {"image": "https://example.com/pic.jpg"}}, {insert: "\n"}]
// 重做操作
doc.redo();
print(doc.toDelta());
// 输出: [{insert: "Hello", attributes: {bold: true}}, {insert: " World", attributes: {italic: true}}, {insert: "\n"}, {insert: {"image": "https://example.com/pic.jpg"}}, {insert: "\nEnd\n"}]
-
步骤解析:
-
创建文档 :
Document()
初始化一个空文档,DOM 树为:scssContainer (root) └── Line └── TextNode("\n")
-
插入文本 :
insert(0, 'Hello World')
更新 DOM 树为:scssContainer (root) └── Line └── TextNode("Hello World\n")
-
应用样式:
-
format(0, 5, Attribute.bold)
将 "Hello" 设置为粗体。 -
format(6, 5, Attribute.italic)
将 "World" 设置为斜体。 -
DOM 树变为:
phpContainer (root) └── Line ├── TextNode("Hello", attributes: {bold: true}) └── TextNode(" World\n", attributes: {italic: true})
-
-
插入嵌入对象:
-
insert(11, '\n')
添加新行。 -
insert(12, '', {'image': 'url'})
插入图片。 -
DOM 树变为:
phpContainer (root) ├── Line │ ├── TextNode("Hello", attributes: {bold: true}) │ └── TextNode(" World", attributes: {italic: true}) ├── Line │ └── EmbedNode("image", data: {"source": "url"}) └── Line └── TextNode("\n")
-
-
插入新行 :
insert(13, '\nEnd')
更新 DOM 树为:phpContainer (root) ├── Line │ ├── TextNode("Hello", attributes: {bold: true}) │ └── TextNode(" World", attributes: {italic: true}) ├── Line │ └── EmbedNode("image", data: {"source": "url"}) └── Line └── TextNode("End\n")
-
历史管理:
undo()
撤销最后一次操作(插入 "End")。redo()
重做该操作。
-
2.6 后续
关注后续的文章