富文本解析终极指南:从Quill到小程序,我如何用正则摆平所有坑?

0 阅读10分钟

富文本解析终极指南:从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-forv-if 清晰地展示了所有内容。所有问题,迎刃而解!🎉

总结

回顾整个过程,我有几点心得想和大家分享:

1. 别信任任何外部HTML:从编辑器、后端API等任何外部来源获取的HTML,都不要想当然地直接渲染。把它当成“不可信”的脏数据,先“清洗”再使用。 2. 放弃split()来解析HTML:这就像用大锤修手表,只会把事情搞砸。请拥抱正则表达式的exec()matchAll(),它们才是你的“瑞士军刀”。 3. “先清理,后添加”:在处理样式时,先用正则把你不想要的样式(如text-indent)干掉,再把你想要的样式(如margin-left)加上去。这样能保证最终效果100%可控。

希望我这次从踩坑到爬坑的经历,能为你今后处理类似问题时提供一些思路和帮助。编程的世界就是这样,充满了挑战,但也充满了解决问题后的巨大成就感。

好了,不多说了,我得去享受我那杯胜利的咖啡了。祝大家代码无bug,上线一次过!🚀