4.1 引言
本章将深入解析 vsc_quill_delta_to_html 包中的 QuillDeltaToHtmlConverter 类源码,结合测试数据对应的 Delta 操作,逐行分析核心方法 convert 的实现逻辑,展示数据流向和输出结构,
4.2 QuillDeltaToHtmlConverter 类的源码解析
QuillDeltaToHtmlConverter 类是 Delta 到 HTML 转换的核心入口,负责将 Delta 操作序列转换为 HTML 字符串。以下是源码及逐行中文注释,结合测试数据展示执行过程和数据流向。
4.2.1 核心方法 convert 的源码解析
convert 方法是 Delta 到 HTML 转换的入口,负责将 Delta 操作序列转换为 HTML 字符串。以下是源码及逐行注释:
/// 将 Delta 操作转换为 HTML 字符串。
String convert() {
final groups = getGroupedOps(); // 获取分组后的操作列表。
return groups.map((group) {
if (group is ListGroup) { // 如果是列表组,渲染为 HTML 列表。
return _renderWithCallbacks(GroupType.list, group, () => _renderList(group));
}
if (group is TableGroup) { // 如果是表格组,渲染为 HTML 表格。
return _renderWithCallbacks(GroupType.table, group, () => _renderTable(group));
}
if (group is BlockGroup) { // 如果是块组(如标题、段落),渲染为块级 HTML。
return _renderWithCallbacks(GroupType.block, group, () => renderBlock(group.op, group.ops));
}
if (group is BlotBlock) { // 如果是嵌入块,调用自定义渲染。
return _renderCustom(group.op, null);
}
if (group is VideoItem) { // 如果是视频项,渲染为视频 HTML。
return _renderWithCallbacks(GroupType.video, group, () {
var converter = OpToHtmlConverter(group.op, _converterOptions);
return converter.getHtml();
});
}
// 内联组,渲染为内联 HTML。
return _renderWithCallbacks(
GroupType.inlineGroup, group, () => renderInlines((group as InlineGroup).ops, true));
}).join(''); // 将所有组的 HTML 拼接为最终字符串。
}
执行过程与数据流向
-
输入:测试数据的 Delta 操作:
[ {"insert": "作"}, {"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}}, {"insert": "手"}, {"insert": "\n", "attributes": {"header": 1}}, {"insert": "大"}, {"insert": "前端", "attributes": {"background": "var(--warning1-25)"}}, {"insert": "开发"}, {"insert": "\n", "attributes": {"header": 2}}, {"insert": "王金山"}, {"insert": "\n", "attributes": {"header": 3}}, {"insert": "欢迎各位大佬"}, {"insert": "\n"} ] -
步骤 1:调用
getGroupedOps-
作用:将原始 Delta 操作分组为不同类型的组(如
ListGroup、TableGroup、BlockGroup、InlineGroup)。 -
源码:
List<TDataGroup> getGroupedOps() { var deltaOps = InsertOpsConverter.convert(_rawDeltaOps, _options.sanitizerOptions); // 将原始 Delta 操作转换为 DeltaInsertOp 对象。 var pairedOps = Grouper.pairOpsWithTheirBlock(deltaOps); // 将操作与其块上下文配对。 var groupedSameStyleBlocks = Grouper.groupConsecutiveSameStyleBlocks( pairedOps, blockquotes: _options.multiLineBlockquote ?? false, header: _options.multiLineHeader ?? false, codeBlocks: _options.multiLineCodeblock ?? false, customBlocks: _options.multiLineCustomBlock ?? false, ); // 将连续的相同样式块分组。 var groupedOps = Grouper.reduceConsecutiveSameStyleBlocksToOne(groupedSameStyleBlocks); // 合并连续的相同样式块。 groupedOps = TableGrouper().group(groupedOps); // 处理表格分组。 return ListNester().nest(groupedOps); // 处理列表嵌套。 } -
执行过程:
-
转换操作:
InsertOpsConverter.convert将_rawDeltaOps转换为DeltaInsertOp对象,仅保留insert操作。- 输入:
[{"insert": "作"}, ...] - 输出:
List<DeltaInsertOp>,每个操作包含insert值和attributes。
- 输入:
-
配对块:
Grouper.pairOpsWithTheirBlock将操作与其块上下文配对。- 例如,
[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}, {"insert": "\n", "attributes": {"header": 1}}]配对为一个块,"\n"是块终止符。 - 输出:
List包含块配对,如[Block(op4, [op1, op2, op3])].
- 例如,
-
分组相同样式块:
Grouper.groupConsecutiveSameStyleBlocks将连续的相同样式块分组。- 测试数据中,每个块(header 1、header 2、header 3、paragraph)样式不同,无需合并。
- 输出:
List包含[BlockGroup(header 1), BlockGroup(header 2), BlockGroup(header 3), BlockGroup(paragraph)]。
-
合并块:
Grouper.reduceConsecutiveSameStyleBlocksToOne合并连续块(测试数据无连续块)。 -
表格分组:
TableGrouper().group处理表格操作(测试数据无表格)。 -
列表嵌套:
ListNester().nest处理列表嵌套(测试数据无列表)。
-
-
输出:
groups包含四个BlockGroup:- BlockGroup(header 1):
[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}, {"insert": "\n", "attributes": {"header": 1}}] - BlockGroup(header 2):
[{"insert": "大"}, {"insert": "前端", ...}, {"insert": "开发"}, {"insert": "\n", "attributes": {"header": 2}}] - BlockGroup(header 3):
[{"insert": "王金山"}, {"insert": "\n", "attributes": {"header": 3}}] - BlockGroup(paragraph):
[{"insert": "欢迎各位大佬"}, {"insert": "\n"}]
- BlockGroup(header 1):
-
-
步骤 2:遍历组并渲染
-
逻辑:对每个组调用
_renderWithCallbacks,根据组类型选择渲染方法。 -
测试数据应用:
-
BlockGroup(header 1) :
- 调用
_renderWithCallbacks(GroupType.block, group, () => renderBlock(group.op, group.ops))。 group.op是{"insert": "\n", "attributes": {"header": 1}}。group.ops是[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}].
- 调用
-
BlockGroup(header 2) :
- 类似处理,
group.op是{"insert": "\n", "attributes": {"header": 2}}。
- 类似处理,
-
BlockGroup(header 3) :
group.op是{"insert": "\n", "attributes": {"header": 3}}。
-
BlockGroup(paragraph) :
group.op是{"insert": "\n"}。
-
-
-
步骤 3:渲染块
-
源码:
String renderBlock(DeltaInsertOp bop, List<DeltaInsertOp> ops) { final converter = OpToHtmlConverter(bop, _converterOptions); // 创建操作转换器。 final htmlParts = converter.getHtmlParts(); // 获取 HTML 标签部分。 if (bop.isCodeBlock()) { return htmlParts.openingTag + encodeHtml(ops .map((iop) => iop.isCustomEmbed() ? _renderCustom(iop, bop) : iop.insert.value) .join('')) + htmlParts.closingTag; // 处理代码块。 } final inlines = ops.map((op) => _renderInline(op, bop)).join(''); // 渲染内联操作。 return htmlParts.openingTag + (inlines.isEmpty ? brTag : inlines) + htmlParts.closingTag; // 拼接块 HTML。 } -
执行过程:
-
Header 1:
-
bop是{"insert": "\n", "attributes": {"header": 1}}。 -
converter.getHtmlParts()返回{openingTag: "<h1>", closingTag: "</h1>"}。 -
ops是[{"insert": "作"}, {"insert": "家助", ...}, {"insert": "手"}]. -
调用
_renderInline:{"insert": "作"}→"作"。{"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}}→<span class="foreshadows" data-uuid="">家助</span>(假设renderCustomWithCallback定义)。{"insert": "手"}→"手"。
-
输出:
<h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1>。
-
-
Paragraph:
bop是{"insert": "\n"}。converter.getHtmlParts()返回{openingTag: "<p>", closingTag: "</p>"}。ops是[{"insert": "欢迎各位大佬"}].- 输出:
<p>欢迎各位大佬</p>。
-
-
-
步骤 4:拼接 HTML
-
所有组的 HTML 拼接为:
<h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1> <h2>大<span style="background-color: var(--warning1-25);">前端</span>开发</h2> <h3>王金山</h3> <p>欢迎各位大佬</p>
-
数据流向
- 输入:
_rawDeltaOps→getGroupedOps→List<TDataGroup>。 - 处理:遍历组 → 调用渲染方法 → 生成 HTML 片段。
- 输出:拼接后的 HTML 字符串。
4.2.2 其他关键方法解析
_renderWithCallbacks
-
作用:包装渲染函数,允许在渲染前后调用回调。
-
源码:
_renderWithCallbacks( GroupType groupType, TDataGroup group, String Function() myRenderFn, ) { var html = _beforeRenderCallback?.call(groupType, group) ?? ''; // 调用渲染前回调。 if (html.isEmpty) { html = myRenderFn(); // 执行渲染函数。 } html = _afterRenderCallback?.call(groupType, html) ?? html; // 调用渲染后回调。 return html; // 返回最终 HTML。 } -
测试数据应用:测试数据无自定义回调,
myRenderFn直接执行(如renderBlock)。
renderInlines
-
作用:渲染内联操作。
-
源码:
String renderInlines(List<DeltaInsertOp> ops, [bool isInlineGroup = true]) { final opsLen = ops.length - 1; final html = ops.mapIndexed((i, op) { if (i > 0 && i == opsLen && op.isJustNewline()) { return ''; // 忽略最后一个换行符。 } return _renderInline(op, null); // 渲染内联操作。 }).join(''); if (!isInlineGroup) { return html; // 非内联组直接返回。 } final startParaTag = makeStartTag(_converterOptions.paragraphTag); // 获取段落开始标签。 final endParaTag = makeEndTag(_converterOptions.paragraphTag); // 获取段落结束标签。 if (html == brTag || _options.multiLineParagraph == true) { return startParaTag + html + endParaTag; // 多行段落处理。 } return startParaTag + html.split(brTag).map((v) => v.isEmpty ? brTag : v).join(endParaTag + startParaTag) + endParaTag; // 处理多段落。 } -
测试数据应用:
- 对于 header 1 的内联:
作<span class="foreshadows" data-uuid="">家助</span>手。 - 对于 paragraph:
欢迎各位大佬。
- 对于 header 1 的内联:
_renderInline
-
作用:渲染单个内联操作。
-
源码:
String _renderInline(DeltaInsertOp op, DeltaInsertOp? contextOp) { if (op.isCustomEmbed() || YwQuillFormatUtil.isYwCustomSpan(op)) { return _renderCustom(op, contextOp); // 处理自定义嵌入或自定义 span。 } final converter = OpToHtmlConverter(op, _converterOptions); // 创建操作转换器。 return converter.getHtml().replaceAll('\n', brTag); // 将换行符替换为 <br>。 } -
测试数据应用:
{"insert": "家助", "attributes": {"custom": "foreshadows", "uuid": ""}}→<span class="foreshadows" data-uuid="">家助</span>。
4.2.3 异步解析的补充
当前源码是同步操作,但在实际应用中可能涉及异步场景:
-
外部资源加载:如
<img>或<video>的src属性可能需要异步获取。 -
大型 Delta 处理:可通过
Future包装convert方法:Future<String> convertAsync() async { return await Future(() => convert()); }
4.2.4 数据流向与输出结构
-
输入:Delta 操作列表。
-
处理:
- 分组:
getGroupedOps→List<TDataGroup>。 - 渲染:遍历组 → 调用渲染方法 → 生成 HTML。
- 分组:
-
输出:HTML 字符串。
示例输出
-
输入 Delta:如上。
-
输出 HTML:
<h1>作<span class="foreshadows" data-uuid="">家助</span>手</h1> <h2>大<span style="background-color: var(--warning1-25);">前端</span>开发</h2> <h3>王金山</h3> <p>欢迎各位大佬</p>
4.2.5 优化与扩展建议
- 性能优化:缓存分组结果,减少重复计算。
- 样式扩展:增强
OpToHtmlConverter支持更多 CSS 属性。 - 异步优化:支持异步渲染外部资源。
4.3 总结
本章通过源码解析,详细讲解了 QuillDeltaToHtmlConverter 类及其 convert 方法的 Delta 到 HTML 转换逻辑,结合测试数据展示了数据流向和输出结构。欢迎高级开发者交流优化方案!
更加详细的流程
我将基于提供的源码一步步解析 Delta 到 HTML 的转换过程,整个过程分为 6 个关键步骤:
转换流程概览
graph TD
A[输入原始 Delta 操作] --> B[操作反规范化和转换]
B --> C[操作分组]
C --> D[块级结构构建]
D --> E[渲染处理]
E --> F[HTML 输出]
步骤 1: 输入处理和操作转换 (InsertOpsConverter.convert)
文件: insert_ops_converter.dart
目的: 将原始 Delta 操作转换为内部操作对象
static List<DeltaInsertOp> convert(
List<Map<String, dynamic>>? deltaOps,
OpAttributeSanitizerOptions options,
) {
// 1. 反规范化操作
final denormalizedOps = deltaOps.map(InsertOpDenormalizer.denormalize).flattened.toList();
// 2. 转换操作
for (final op in denormalizedOps) {
final rawInsertValue = op['insert'];
// 3. 转换插入值
final insertVal = convertInsertVal(rawInsertValue, options);
// 4. 清理属性
final attributes = OpAttributeSanitizer.sanitize(
OpAttributes()..attrs.addAll(rawAttributes), options);
// 5. 创建操作对象
results.add(DeltaInsertOp(insertVal, attributes));
}
}
关键处理:
- 反规范化: 将复杂操作拆分为基本操作单元
- 值转换:
- 文本:
InsertDataQuill(DataType.text, value) - 图片:
InsertDataQuill(DataType.image, sanitizedUrl) - 视频:
InsertDataQuill(DataType.video, sanitizedUrl) - 公式:
InsertDataQuill(DataType.formula, value) - 自定义:
InsertDataCustom(type, value)
- 文本:
- 属性清理: 确保属性值安全可用
步骤 2: 操作分组 (Grouper.pairOpsWithTheirBlock)
文件: grouper.dart
目的: 将操作分组为逻辑块
static List<TDataGroup> pairOpsWithTheirBlock(List<DeltaInsertOp> ops) {
// 创建结果列表
final result = <TDataGroup>[];
// 从后向前遍历所有操作
var lastInd = ops.length - 1;
for (var i = lastInd; i >= 0; i--) {
final op = ops[i];
// 根据当前操作的类型,进行不同的处理
if (op.isVideo()) {
// 视频是独立的块
result.add(VideoItem(op));
} else if (op.isCustomEmbedBlock()) {
// 自定义嵌入块也是独立的
result.add(BlotBlock(op));
} else if (op.isContainerBlock()) {
// 如果是容器块(如标题、引用、列表等)
// 查找前面所有能属于这个块的内容操作
final opsSlice = sliceFromReverseWhile(ops, i - 1, canBeInBlock);
// 创建一个块组,op是块本身,opsSlice.elements是块内的内容
result.add(BlockGroup(op, opsSlice.elements));
// 调整索引,跳过已处理的操作
i = opsSlice.sliceStartsAt > -1 ? opsSlice.sliceStartsAt : i;
} else {
// 处理内联内容
final opsSlice = sliceFromReverseWhile(ops, i - 1, isInlineData);
// 创建内联组,包含当前操作和前面的所有内联操作
result.add(InlineGroup(opsSlice.elements..add(op)));
// 调整索引,跳过已处理的操作
i = opsSlice.sliceStartsAt > -1 ? opsSlice.sliceStartsAt : i;
}
}
// 反转结果,因为我们是从后向前处理的
return result.reversed.toList();
}
分组类型:
VideoItem: 视频内容BlotBlock: 自定义块BlockGroup: 块级元素(段落、标题等)InlineGroup: 内联元素(文本、样式等)
步骤 3: 块级结构构建
3.1 相同样式块分组 (Grouper.groupConsecutiveSameStyleBlocks)
static List<dynamic> groupConsecutiveSameStyleBlocks(
List<TDataGroup> groups, {
bool header = true,
bool codeBlocks = true,
bool blockquotes = true,
bool customBlocks = true,
}) {
return groupConsecutiveElementsWhile(groups, (g, gPrev) {
return ((codeBlocks && areBothCodeblocksWithSameLang(g, gPrev)) ||
(blockquotes && areBothBlockquotesWithSameAdi(g, gPrev)) ||
(header && areBothSameHeadersWithSameAdi(g, gPrev)) ||
(customBlocks && areBothCustomBlockWithSameAttr(g, gPrev)));
});
}
合并规则:
- 相同语言的代码块
- 相同属性的引用块
- 相同级别的标题
- 相同属性的自定义块
3.2 表格分组 (TableGrouper.group)
List<TDataGroup> group(List<TDataGroup> groups) {
var tableBlocked = _convertTableBlocksToTableGroups(groups);
return tableBlocked;
}
List<TDataGroup> _convertTableBlocksToTableGroups(List<TDataGroup> items) {
var grouped = groupConsecutiveElementsWhile(items, (g, gPrev) {
return g is BlockGroup && gPrev is BlockGroup &&
g.op.isTable() && gPrev.op.isTable();
});
}
表格结构:
TableGroup: 整个表格TableRow: 表格行TableCell: 表格单元格
3.3 列表嵌套 (ListNester.nest)
List<TDataGroup> nest(List<TDataGroup> groups) {
// 处理列表嵌套逻辑
for (final block in blocks) {
if (block.isList()) {
// 处理列表层级嵌套
while (stack.isNotEmpty && stack.last.listLevel >= block.listLevel) {
final last = stack.removeLast();
result.add(last..closeTags());
}
stack.add(block);
}
}
}
嵌套逻辑:
- 维护堆栈跟踪列表层级
- 根据缩进级别嵌套列表
- 自动闭合父级列表标签
步骤 4: 渲染处理 (QuillDeltaToHtmlConverter.convert)
文件: vsc_quill_delta_to_html.dart
目的: 将分组后的操作渲染为 HTML
String convert() {
final groups = getGroupedOps();
return groups.map((group) {
if (group is ListGroup) {
return _renderList(group); // 列表渲染
}
if (group is TableGroup) {
return _renderTable(group); // 表格渲染
}
if (group is BlockGroup) {
return renderBlock(group.op, group.ops); // 块级渲染
}
if (group is BlotBlock) {
return _renderCustom(group.op, null); // 自定义块渲染
}
if (group is VideoItem) {
return _renderVideo(group); // 视频渲染
}
return renderInlines((group as InlineGroup).ops, true); // 内联渲染
}).join('');
}
步骤 5: 具体渲染实现
5.1 列表渲染 (_renderList)
String _renderList(ListGroup list) {
return makeStartTag(getListTag(firstItem.item.op)) +
list.items.map((li) => _renderListItem(li)).join('') +
makeEndTag(getListTag(firstItem.item.op));
}
String _renderListItem(ListItem li) {
final converter = OpToHtmlConverter(li.item.op, _converterOptions);
final parts = converter.getHtmlParts();
return parts.openingTag +
renderInlines(li.item.ops, false) + // 渲染列表项内容
(li.innerList != null ? _renderList(li.innerList!) : '') + // 嵌套列表
parts.closingTag;
}
5.2 表格渲染 (_renderTable)
String _renderTable(TableGroup table) {
return makeStartTag('table') +
makeStartTag('tbody') +
table.rows.map((row) => _renderTableRow(row)).join('') +
makeEndTag('tbody') +
makeEndTag('table');
}
String _renderTableRow(TableRow row) {
return makeStartTag('tr') +
row.cells.map((cell) => _renderTableCell(cell)).join('') +
makeEndTag('tr');
}
String _renderTableCell(TableCell cell) {
final converter = OpToHtmlConverter(cell.item.op, _converterOptions);
final parts = converter.getHtmlParts();
return makeStartTag('td') +
parts.openingTag +
renderInlines(cell.item.ops, false) + // 单元格内容
parts.closingTag +
makeEndTag('td');
}
5.3 块级渲染 (renderBlock)
@visibleForTesting
String renderBlock(DeltaInsertOp bop, List<DeltaInsertOp> ops) {
// 1. 创建块级转换器 - 核心转换逻辑
final converter = OpToHtmlConverter(bop, _converterOptions);
// 2. 获取块级标签的开闭部分
final htmlParts = converter.getHtmlParts();
// 3. 特殊处理:代码块
if (bop.isCodeBlock()) {
return htmlParts.openingTag + // <pre> 开始标签
encodeHtml(ops.map((iop) => // HTML 实体转义
iop.isCustomEmbed()
? _renderCustom(iop, bop) // 自定义嵌入处理
: iop.insert.value) // 普通文本内容
.join('')) + // 拼接所有内容
htmlParts.closingTag; // </pre> 结束标签
}
// 4. 处理非代码块的块级元素
final inlines = ops.map((op) => _renderInline(op, bop)).join('');
// 5. 组合块级标签和内联内容
return htmlParts.openingTag + // 块级开始标签(如 <h1>)
(inlines.isEmpty
? brTag // 空内容时添加换行符防止折叠
: inlines) + // 内联内容
htmlParts.closingTag; // 块级结束标签(如 </h1>)
}
5.4 内联渲染 (renderInlines)
String renderInlines(List<DeltaInsertOp> ops, [bool isInlineGroup = true]) {
final html = ops.mapIndexed((i, op) {
if (i > 0 && i == opsLen && op.isJustNewline()) return '';
return _renderInline(op, null);
}).join('');
if (!isInlineGroup) return html;
final startParaTag = makeStartTag(_converterOptions.paragraphTag);
final endParaTag = makeEndTag(_converterOptions.paragraphTag);
return startParaTag +
html.split(brTag).map((v) => v.isEmpty ? brTag : v).join(endParaTag + startParaTag) +
endParaTag;
}
步骤 6: HTML 最终输出
处理流程:
- 遍历所有分组
- 根据分组类型调用相应渲染方法
- 拼接所有渲染结果
- 应用全局 HTML 优化
String convert() {
final groups = getGroupedOps();
return groups.map((group) {
// 根据不同类型渲染
return ...;
}).join(''); // 拼接最终HTML
}
高级特性实现
自定义渲染回调
// 设置自定义渲染回调
converter.renderCustomWith = (op, contextOp) {
if (op.insert.value is Map && op.insert.value['tweet'] != null) {
return '<div class="tweet">${op.insert.value['id']}</div>';
}
return '';
};
// 渲染过程中调用
String _renderCustom(DeltaInsertOp op, DeltaInsertOp? contextOp) {
return _renderCustomWithCallback?.call(op, contextOp) ?? '';
}
样式转换
// 在 OpToHtmlConverter 中
String _getCssStyles() {
final styles = <String>[];
if (op.isBold()) styles.add('font-weight:bold');
if (op.isItalic()) styles.add('font-style:italic');
if (op.attributes.color != null) styles.add('color:${op.attributes.color}');
// 自定义样式转换
if (_converterOptions.customCssStyles != null) {
final customStyles = _converterOptions.customCssStyles!(op);
if (customStyles != null) styles.addAll(customStyles);
}
return styles.join(';');
}
转换过程关键点
- 逆序处理: 操作从后向前处理,便于构建嵌套结构
- 分组策略: 将相关操作分组,保持结构完整性
- 类型分发: 根据操作类型分发到不同渲染器
- 递归渲染: 列表和表格使用递归渲染处理嵌套
- 上下文感知: 渲染时考虑父级元素的上下文
- 安全处理: 所有属性和链接都经过清理
这个转换过程通过精心设计的步骤将线性的 Delta 操作序列转换为结构化的 HTML,完整保留了原始内容的语义和样式信息。