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


富文本解析终极指南:从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字符串,输出是一个结构化的数组,像这样:

javascript 复制代码
[
  { 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 闪亮登场 ✨

经过反复打磨和调试,我封装出了下面这个"终极解析器"。它完美地解决了前面提到的所有问题。

javascript 复制代码
/**
 * @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;
}

如何在项目中使用它?超级简单!

有了这个强大的解析器,我的页面代码变得前所未有的清爽和优雅。👇

vue 复制代码
<!-- 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,上线一次过!🚀

相关推荐
落霞的思绪36 分钟前
CSS复习
前端·css
咖啡の猫3 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲5 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5816 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路6 小时前
GeoTools 读取影像元数据
前端
ssshooter6 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
Jerry7 小时前
Jetpack Compose 中的状态
前端
dae bal8 小时前
关于RSA和AES加密
前端·vue.js
柳杉8 小时前
使用three.js搭建3d隧道监测-2
前端·javascript·数据可视化
lynn8570_blog8 小时前
低端设备加载webp ANR
前端·算法