富文本解析终极指南:从Quill到小程序,我如何用正则摆平所有坑?😎
嘿,各位奋斗在一线的码农兄弟们,大家壕!我是你们的老朋友,一个热爱咖啡和代码的前端老兵。
今天不聊高大上的架构,也不谈玄乎的源码,咱们来聊一个每个前端都绕不开,又爱又恨的话题——富文本。
最近接手一个项目,需求很常见:运营同学在Web后台用大名鼎鼎的 Quill.js 富文本编辑器生产文章,文章里图文并茂,甚至还有视频。然后,这些内容需要完美地展示在我们的**小程序(Uni-app)**里。
听起来很简单,对吧?我当初也是这么想的,直到我拿到了Quill生成的HTML字符串...那一刻,我知道,事情不简单。😫
我遇到了什么问题?一场由HTML引发的“血案”
当我把从后端接口拿到的HTML字符串,一股脑塞进小程序的 rich-text
组件时,灾难发生了:
1. 视频不见了! 😱
Quill生成的视频用的是 <iframe>
标签。但小程序 rich-text
组件根本不认这玩意儿!视频需要用原生的 <video>
组件才能播放。
2. 部分样式丢失,部分样式错乱! 😠
Quill很喜欢用CSS类名来定义样式,比如字号用 class="ql-size-large"
。但小程序 rich-text
对外部类名的支持非常有限,最稳妥的方式是内联样式(inline style)。
3. 自定义功能无法渲染! 🤔
我们给运营同学做了一个很贴心的功能:按 Tab
键可以实现文本缩进。在HTML里,这表现为一个或多个 \t
制表符。然而,rich-text
组件直接把 \t
当成一个普通空格处理了,缩进效果完全没出来。
看着设计稿和实际渲染效果之间那巨大的鸿沟,我意识到,简单粗暴地直接渲染是行不通的。我需要一个“翻译官”,一个能将Quill的“方言”HTML,翻译成小程序能听懂的“普通话”节点数组。
我是如何解决的?从踩坑到“顿悟”
我的目标很明确:写一个解析函数,输入是Quill的HTML字符串,输出是一个结构化的数组,像这样:
[
{ type: 'text', content: '<p>处理后的文本</p>' },
{ type: 'img', src: '...' },
{ type: 'video', src: '...' }
]
这样,我在页面上就可以用 v-for
循环,根据 type
来决定是渲染 <rich-text>
、<image>
还是 <video>
。
第一步尝试:天真的 split()
方法(巨坑预警!🚨)
我最初的想法很简单:“既然要分离图片和视频,那我用 split()
按 <img>
和 <iframe>
标签来分割字符串不就行了?”
说干就干!结果...我踩进了第一个大坑。
比如对于这样一段HTML:<p>一些文字</p><p><img src="a.jpg"></p><p>更多文字</p>
split('<img ...>')
之后,我得到的是:
* ['<p>一些文字</p><p>', '</p><p>更多文字</p>']
看到了吗?HTML结构被破坏了! 分割后的文本片段都是些残缺不全的标签,比如 <p>
没有闭合,或者 </p>
没有开头。把这些“残肢断臂”丢给 rich-text
组件,它会尽力去修复,但结果往往是各种诡异的样式错乱。我之前遇到的“所有段落都缩进”,就是这个原因导致的!
恍然大悟的瞬间!💡
我意识到,我不能用“剪刀”(split
)去粗暴地裁剪,而应该用“扫描仪”去智能地识别。
什么是“扫描仪”?就是正则表达式的 exec()
方法配合 while
循环!
这种方法不会破坏原始字符串,而是像一个指针,在字符串上不断向后移动,依次识别出“这是一段文本”、“哦,这是一个图片”、“接下来又是一段文本”... 这样处理,能保证我提取出来的每一个文本块都是结构完整的!
终极解决方案:parseRichTextToNodes
闪亮登场 ✨
经过反复打磨和调试,我封装出了下面这个“终极解析器”。它完美地解决了前面提到的所有问题。
/**
* @description 富文本HTML解析器(包含清理、转换和分割)
* 这是一个高度健壮的解析器,它将从富文本编辑器(如Quill.js)生成的、可能带有预设样式的HTML字符串,
* 转换为一个结构清晰、样式纯净、适用于小程序 rich-text 组件或其他自定义渲染环境的节点数组。
*
* @strategy 核心策略是“先清理,后添加”:
* 1. 清理(Cleanup): 主动移除源HTML中所有不需要的预设样式(如 text-indent)。
* 2. 添加(Augment): 根据我们自己的规则(如 \t 制表符),精确地添加我们需要的样式(如 margin-left)。
* 这种策略确保了输出样式的绝对可控性,避免了被源HTML的“脏样式”污染。
*
* @workflow 工作流程:
* 1. 【样式预转换】: 通过正则表达式查找特定的CSS类名(如 'ql-size-*'),并将其转换为对应的内联 `font-size` 样式。
* 2. 【迭代分割节点】: 采用最健壮的“迭代匹配”策略(while + regex.exec),以媒体标签(img, iframe)为锚点,
* 精准地将HTML分割成连续的文本节点和独立的媒体节点,此方法避免了 `split()` 函数会破坏HTML结构的问题。
* 3. 【文本节点处理】: 对每个提取出的文本块,执行“先清理,后添加”策略。
* a. 全局清理: 移除所有 `text-indent` 样式。
* b. 精确添加: 只为内容以 `\t` 开头的段落添加 `margin-left` 缩进。
* 4. 【结构化封装】: 将所有处理好的节点封装成 `{ type: '...', ... }` 格式的对象,并存入数组中返回。
*
* @param {string} htmlString - 带有预设样式的原始HTML字符串。
* @returns {Array<Object>} 一个样式纯净、结构正确的节点数组。
* 例如: [
* { type: 'text', content: '<p>处理后的文本</p>' },
* { type: 'img', src: '...' },
* { type: 'video', src: '...' }
* ]
*/
export function parseRichTextToNodes(htmlString) {
// --- 输入验证 (Robustness Check) ---
// 确保输入是有效的非空字符串,防止后续操作因null或undefined等无效输入而崩溃。
if (!htmlString || typeof htmlString !== 'string') {
return [];
}
// --- 阶段一: 样式预转换 ---
// 此阶段的目标是将富文本编辑器生成的特定CSS类名,转换为小程序等环境更易于支持的内联style。
let processedHtml = htmlString;
// 定义一个从CSS类名到具体style值的映射表,便于管理和扩展。
const FONT_SIZE_MAP = {
'ql-size-small': '10px',
'ql-size-large': '18px',
'ql-size-huge': '32px',
};
// 正则表达式,用于匹配所有 <span> 和 <h1>-<h6> 标签,并捕获其标签名和所有属性。
// 捕获组1: (span|h[1-6]) -> 标签名
// 捕获组2: ([^>]*) -> 标签内的所有属性字符串
const classRegex = /<(span|h[1-6])([^>]*)>/gi;
processedHtml = processedHtml.replace(classRegex, (match, tagName, attributes) => {
let targetFontSize = null;
// 遍历映射表,检查当前标签的属性中是否包含我们需要转换的类名。
for (const className in FONT_SIZE_MAP) {
if (attributes.includes(className)) {
targetFontSize = FONT_SIZE_MAP[className];
break; // 找到后立即退出循环
}
}
// 如果没有找到任何匹配的字体类名,则不进行任何修改,返回原始标签。
if (!targetFontSize) return match;
// 如果找到了,我们将构建新的属性字符串。
let finalAttributes = attributes;
const styleRegex = /style="([^"]*)"/i; // 用于查找已存在的style属性
// 检查标签是否已经有style属性
if (styleRegex.test(attributes)) {
// 如果有,就在现有样式的前面追加新的字体大小样式。
// 这样做可以避免覆盖掉用户可能已经设置的其他内联样式(如 color)。
finalAttributes = attributes.replace(styleRegex, (styleMatch, existingStyles) => {
return `style="font-size: ${targetFontSize}; ${existingStyles}"`;
});
} else {
// 如果没有,就直接添加一个新的style属性。
finalAttributes += ` style="font-size: ${targetFontSize};"`;
}
// 返回带有新内联样式的、重新构建好的标签。
return `<${tagName}${finalAttributes}>`;
});
// --- 阶段二: 节点分割与解析 ---
// 这是整个函数最核心的部分,采用“迭代匹配”策略来安全地分割HTML。
const nodes = [];
// 正则表达式,用于查找被<p>包裹或独立的媒体标签(iframe, img)。
// (?:<p>)? : 一个非捕获组,匹配可选的<p>标签,因为有些编辑器会自动包裹媒体。
// \s* : 匹配可选的空白字符。
// (<iframe...|...>) : 核心捕获组1,匹配iframe或img标签本身。
const mediaRegex = /(?:<p>)?\s*(<iframe[\s\S]*?<\/iframe>|<img[^>]*>)\s*(?:<\/p>)?/gi;
let lastIndex = 0; // 记录上一次匹配结束的位置,作为下一次文本截取的起点。
let match; // 存储每次匹配的结果。
// 内部辅助函数:从HTML标签字符串中安全地提取指定属性的值。
const getAttribute = (tagString, attributeName) => {
// 这个正则表达式可以处理双引号、单引号和无引号的属性值。
const regex = new RegExp(`${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
const match = tagString.match(regex);
return match ? (match[1] || match[2] || match[3]) : null;
};
// 内部辅助函数:处理所有文本节点的总入口,包含“清理”和“添加”两步。
const processTextNode = (text) => {
// 忽略空的或只包含空白的文本块。
if (!text || !text.trim()) {
return;
}
let textContent = text.trim();
// --- 步骤 3a: 全局清理 (Cleanup) ---
// 这个正则表达式查找并移除所有 `text-indent` 样式声明。
// `text-indent:` : 匹配字面量
// `\s*` : 匹配任意数量的空白
// `[^;]+` : 匹配一个或多个非分号的字符(即样式值)
// `;?` : 匹配一个可选的分号
// `g` : 全局匹配,确保清理所有实例
const cleanupIndentRegex = /text-indent:\s*[^;]+;?\s*/g;
textContent = textContent.replace(cleanupIndentRegex, '');
// (可选) 额外的清理步骤:如果清理后 style 属性变为空 (如 style=" "),也把它彻底移除,保持HTML整洁。
textContent = textContent.replace(/style="\s*"/g, '');
// --- 步骤 3b: 精确添加 (Augment) ---
// 在清理干净的HTML上,执行我们自定义的 `\t` 缩进逻辑。
// 捕获组1: (p|li) -> 块级标签名
// 捕获组2: ([^>]*) -> 标签属性
// 捕获组3: (\t+) -> 一个或多个制表符
// 捕获组4: ([\s\S]*?) -> 标签内容 (非贪婪匹配)
// \1: 反向引用,确保闭合标签与起始标签一致
const addIndentRegex = /<(p|li)([^>]*)>(\t+)([\s\S]*?)<\/\1>/gi;
textContent = textContent.replace(addIndentRegex, (match, tagName, attributes, tabs, content) => {
const indentLevel = tabs.length; // 根据制表符数量计算缩进级别
const indentSize = indentLevel * 2; // 每级缩进2em
let newAttributes = attributes;
const styleRegex = /style="([^"]*)"/i;
if (styleRegex.test(attributes)) {
// 如果已存在style,注入margin-left
newAttributes = attributes.replace(styleRegex, (styleMatch, existingStyles) => {
return `style="margin-left: ${indentSize}em; ${existingStyles}"`;
});
} else {
// 否则,创建新的style属性
newAttributes += ` style="margin-left: ${indentSize}em;"`;
}
// 返回重新构建的、带有缩进样式的HTML标签
return `<${tagName}${newAttributes}>${content.trim()}</${tagName}>`;
});
// 将处理完毕的文本块作为一个节点推入结果数组
nodes.push({
type: 'text',
content: textContent
});
};
// --- 阶段三: 循环匹配与分割 ---
// 使用 while 循环和 exec 方法,这是处理此类解析任务最健壮的方式。
while ((match = mediaRegex.exec(processedHtml)) !== null) {
// 1. 添加从上一个媒体到当前媒体之间的所有内容,作为文本节点。
const textBefore = processedHtml.substring(lastIndex, match.index);
processTextNode(textBefore);
// 2. 处理当前匹配到的媒体节点。
const mediaTag = match[1]; // match[1] 是我们正则中定义的干净的媒体标签捕获组
if (mediaTag.toLowerCase().startsWith('<iframe')) {
const src = getAttribute(mediaTag, 'src');
if (src) nodes.push({
type: 'video',
src: src
});
} else if (mediaTag.toLowerCase().startsWith('<img')) {
const src = getAttribute(mediaTag, 'src');
if (src) nodes.push({
type: 'img',
src: src
});
}
// 3. 更新下一次搜索的起始位置,这对于循环至关重要。
lastIndex = mediaRegex.lastIndex;
}
// --- 阶段四: 处理收尾文本 ---
// 添加最后一个媒体标签之后的所有剩余文本。
const remainingText = processedHtml.substring(lastIndex);
processTextNode(remainingText);
// console.log(JSON.stringify(nodes, null, 4));
console.log(nodes);
return nodes;
}
如何在项目中使用它?超级简单!
有了这个强大的解析器,我的页面代码变得前所未有的清爽和优雅。👇
<!-- VideoEditorDemo.vue (修复版) -->
<template>
<view class="page-container">
<view class="preview-area">
<!-- 循环渲染节点数组 -->
<view v-for="(node, index) in nodes" :key="index">
<!-- 如果是文本节点,使用 rich-text 渲染 -->
<rich-text v-if="node.type === 'text'" :nodes="node.content"></rich-text>
<!-- 如果是视频节点,使用原生 video 组件渲染 -->
<view v-else-if="node.type === 'video'" class="video-wrapper">
<video :src="node.src" controls style="width: 100%"></video>
</view>
<!-- 图片 -->
<image v-else-if="node.type === 'img'" :src="node.src" style="width: 80%"></image>
</view>
</view>
</view>
</template>
<script>
import { parseRichTextToNodes } from './tool.js';
export default {
data() {
return {
nodes: [], // 结构化的节点数组
demoText:
'<p>\t\t缩进</p><p style="text-indent: 2em;"><span class="ql-size-small">10px</span></p>...<!-- 此处省略巨长的HTML字符串 -->'
};
},
onLoad() {
// 看,只需要一行代码!
this.nodes = parseRichTextToNodes(this.demoText);
}
};
</script>
看,在 onLoad
生命周期函数里,我只用了一行代码,就把那坨复杂的HTML转换成了我们想要的干净、结构化的nodes
数组。模板部分则通过 v-for
和 v-if
清晰地展示了所有内容。所有问题,迎刃而解!🎉
总结
回顾整个过程,我有几点心得想和大家分享:
1. 别信任任何外部HTML:从编辑器、后端API等任何外部来源获取的HTML,都不要想当然地直接渲染。把它当成“不可信”的脏数据,先“清洗”再使用。
2. 放弃split()
来解析HTML:这就像用大锤修手表,只会把事情搞砸。请拥抱正则表达式的exec()
或matchAll()
,它们才是你的“瑞士军刀”。
3. “先清理,后添加”:在处理样式时,先用正则把你不想要的样式(如text-indent
)干掉,再把你想要的样式(如margin-left
)加上去。这样能保证最终效果100%可控。
希望我这次从踩坑到爬坑的经历,能为你今后处理类似问题时提供一些思路和帮助。编程的世界就是这样,充满了挑战,但也充满了解决问题后的巨大成就感。
好了,不多说了,我得去享受我那杯胜利的咖啡了。祝大家代码无bug,上线一次过!🚀