uni-app rich-text 深度历险:从“天坑”到“真香”,我只做对了一件事

7 阅读9分钟

uni-app rich-text 深度历险:从“天坑”到“真香”,我只做对了一件事 😎

嘿,各位在 uni-app 战线上的兄弟姐妹们!我是一个在前端世界里摸爬滚打了快N年的老兵。今天不聊高大上的架构,也不卷源码,就想跟大伙儿唠唠嗑,聊聊那个让我们又爱又恨的 rich-text 组件。

相信我,如果你正在做内容展示类的 App(比如新闻、社区、产品详情等),你 100% 会遇到它。而我,就在最近一个项目中,被它“折磨”得不轻,但最终,也跟它成了“生死之交”。下面,就是我的“历险记”。

第一章:我遇到的天坑,差点就想提桶跑路了 😫

故事的开始,想必你也经历过。我们接手了一个需求,要做一个跨平台的资讯 App。后端大哥拍着胸脯说:“放心,内容都是富文本,我直接从 CMS(内容管理系统)里把 HTML 字符串给你,你只管展示就行!”

听起来很简单,对吧?我也天真地这么认为。于是,我三下五除二就写出了第一版代码:

<!-- 错误示范 ❌ -->
<template>
	<view>
		<rich-text :nodes="articleHtml"></rich-text>
	</view>
</template>

<script>
export default {
	data() {
		return {
			// 后端返回的一大坨HTML字符串
			articleHtml: '<p>这是一个<strong>加粗</strong>的段落。</p><img src="...">'
		};
	}
}
</script>

在 H5 环境一跑,哎哟,不错哦!完美显示!我心里还美滋滋地想:“uni-app 牛啊,活儿这么快就干完了!”

然而,现实很快就给了我一记响亮的耳光。当我把项目跑在 App-nvue 端时,页面一片空白!😱 打开调试器一看,控制台鲜红的报错,大意就是“不支持的 HTML 标签”。再切到支付宝小程序,部分样式又丢了...

那时候我的心情,大概是这样的:

My face when it broke

我懵了。说好的跨平台呢?说好的开箱即用呢?

病急乱投医,我开始疯狂搜索,很快就找到了一个“貌似可行”的方案:找一个第三方的 html-parser 插件,在前端把 HTML 字符串解析成 rich-text 支持的格式。我试了几个,但新的问题又来了:

  1. 包体积变大了:凭空多了一个不小的依赖。
  2. 性能堪忧:在手机上解析复杂的 HTML,那叫一个卡顿,用户体验直线下降。
  3. 维护噩梦:插件本身可能也有 Bug,或者不再维护,这不等于给自己埋雷吗?

就在我快要放弃,准备跟产品经理“真人 PK”的时候,我决定静下心来,把 uni-app 的官方文档仔仔细细地再看一遍。

第二章:柳暗花明,官方文档为我“划重点” ✨

就是这个重新阅读文档的下午,我迎来了我的“恍然大悟”时刻。我盯着 rich-textnodes 属性介绍,看到了那段我之前一扫而过,但此刻却字字珠玑的官方说明:

nodes 值为 HTML String 时,在组件内部将自动解析为节点列表,推荐直接使用 Array 类型避免内部转换导致的性能下降。App-nvue 和支付宝小程序不支持 HTML String 方式,仅支持直接使用节点列表即 Array 类型,如要使用 HTML String,则需自己将 HTML String 转化为 nodes 数组,可使用 html-parser 转换。

节点列表内的节点现支持两种类型,通过 type 来区分,分别是元素节点和文本节点,默认是元素节点,在富文本区域里显示的 HTML 节点。

我瞬间明白了!官方这句话,简直就是我踩坑之路的“官方复盘”啊!它等于在说:

  1. “推荐直接使用 Array 类型”: HTML 字符串是个性能陷阱!它看似方便,但 uni-app 在背后要悄悄帮你“翻译”成节点列表,这个过程会拖慢你的应用。
  2. “App-nvue 和支付宝小程序不支持”: 这不就是我问题的“铁证”吗?!我一直试图用一种“方言”(HTML String)去和所有平台沟通,结果人家原生渲染的 App-nvue 和支付宝小程序根本“听不懂”!
  3. “需自己将 HTML String 转化为 nodes 数组”: 官方提了一嘴 html-parser,但这更像是一个“免责声明”,意思是“如果你非要这么干,我们也没办法,你自己想办法解决吧”。而我已经亲身验证,这条路对多数项目来说,是个维护和性能的双重噩梦。
  4. “节点分两种类型:元素节点和文本节点”: 这就是官方指出的光明大道!它告诉我们,正确的做法是构建一个由这两种标准节点组成的数组。

我的核心思想彻底转变了: 停止在客户端解析 HTML!我们要么从源头(后端)就拿到 nodes 数组,要么在前端基于业务数据自己构建 nodes 数组!

思路一打开,感觉整个世界都亮了。下面,我用两个真实场景,带你看看我是如何实践这个“真理”的。

第三章:我的两大实战“法宝”,从此告别烦恼 🚀

法宝一:后端驱动 —— 专业的事交给专业的人

这个方案专门用来对付我们那个资讯 App 的场景。内容来自外部 CMS,结构复杂多变。

我的做法是: 我直接找到后端大哥,对他说:“哥们,别给我 HTML 了,这玩意儿在客户端是‘烫手山芋’。你们后端性能好,库也多(比如 Node.js 的 cheerio),帮我个忙,在接口里直接把 HTML 解析成 uni-app 官方文档定义的 nodes 数组格式再返回给我。”

后端大哥虽然一开始有点懵,但在我把 nodes 的 JSON 结构给他看了之后,他很快就搞定了。

现在,我们用模拟数据来看看前端代码变成了什么样:

1. 这是我们约定好的“模拟数据” (/mock/article.js)

// 这个 JSON,现在是后端直接返回给我的,前端再也不用管解析了!
export function getArticleData() {
	return {
		title: '探索uni-app的强大功能',
		contentNodes: [
			{ name: 'p', children: [{ type: 'text', text: '这是由后端直接生成的 nodes 数组。' }] }, // 元素节点包裹文本节点
			{ name: 'img', attrs: { src: '...', width: '100%' } }, // 元素节点
			{ name: 'p', children: [
				{ type: 'text', text: '可以包含链接:' },
				{ name: 'a', attrs: { href: '/pages/about/about' }, children: [{ type: 'text', text: '关于我们' }] }
			]}
		]
	};
}

2. 我的页面代码,变得前所未有的清爽:

<template>
	<view class="article-container">
		<h1 class="title">{{ articleTitle }}</h1>
		<!-- 直接把干净的 nodes 数组喂给组件 -->
		<rich-text :nodes="articleNodes" :selectable="true" :preview="true"></rich-text>
	</view>
</template>

<script>
import { getArticleData } from '@/mock/article.js';

export default {
	data() {
		return { articleTitle: '', articleNodes: [] };
	},
	onLoad() {
		const article = getArticleData(); // 模拟从接口获取数据
		this.articleTitle = article.title;
		this.articleNodes = article.contentNodes; // 直接赋值,完事!
	}
};
</script>

这个方案的几个关键属性,我是这么用的:

  • nodes: 毫无疑问的核心!接收后端处理好的数组,一劳永逸。
  • selectable="true": 对新闻资讯来说,允许用户复制文字是基本需求。
  • preview="true": 点击图片能自动预览,提升阅读体验。这两个属性简直是为内容展示量身定做的。

效果? App-nvue、小程序、H5,表现完美一致!性能飞起!我终于可以睡个好觉了。😌

法宝二:前端直构 —— 我的地盘我做主

解决了资讯 App 的问题,我又遇到了新挑战:一个社区功能。用户发帖时可以 @ 某人,可以插入内部话题的链接 #话题#,还可以插入一个“点击复制命令”的代码块。

这种需求,内容是动态的、交互性极强的,总不能让用户手写 HTML 吧?这时候,就轮到“前端直构”大显身手了!

我的思路是: 后端只给我结构化的业务数据,比如 [{ type: 'text', content: '...' }, { type: 'mention', userId: '123' }]。然后我在前端写一个“工厂函数”,把这些业务数据“翻译”成 nodes 数组。

来看实战代码:

<template>
	<view class="post-container">
		<!-- 注意这里的属性设置,都是为了强交互! -->
		<rich-text
			:nodes="postNodes"
			:selectable="false"
			:preview="false"
			@itemclick="onItemClick"
		></rich-text>
	</view>
</template>

<script>
export default {
	data() {
		return { postNodes: [] };
	},
	onLoad() {
		// 模拟从后端拿到的结构化业务数据
		const postMockData = [
			{ type: 'text', content: '快来试试这个命令:' },
			{ type: 'command', action: 'copy', text: 'npm install' },
			{ type: 'text', content: ',这个技巧由 ' },
			{ type: 'mention', action: 'gotoProfile', userId: '_laozhang', displayText: '@老张' },
			{ type: 'text', content: ' 分享。' }
		];

		// 调用我的“工厂函数”
		this.postNodes = this.generateNodesFromData(postMockData);
	},
	methods: {
		// 这就是我的“工厂函数”!
		generateNodesFromData(data) {
			const children = data.map(item => {
				if (item.type === 'text') {
					return { type: 'text', text: item.content };
				}
				if (item.type === 'mention') {
					return {
						name: 'a', // 使用元素节点
						attrs: {
							class: 'mention',
							'data-action': item.action, // 埋入自定义指令
							'data-userid': item.userId
						},
						children: [{ type: 'text', text: item.displayText }] // 内部是文本节点
					};
				}
				if (item.type === 'command') {
					return {
						name: 'a',
						attrs: {
							class: 'command',
							'data-action': item.action,
							'data-text': item.text
						},
						children: [{ type: 'text', text: item.text }]
					};
				}
				return null;
			}).filter(Boolean);
			
			return [{ name: 'p', children }];
		},
		// 统一的事件处理中心!
		onItemClick(e) {
			const attrs = e.detail.node.attrs;
			console.log('你点击了:', attrs);
			switch (attrs['data-action']) {
				case 'copy':
					uni.setClipboardData({ data: attrs['data-text'], success: () => uni.showToast({ title: '已复制' }) });
					break;
				case 'gotoProfile':
					uni.navigateTo({ url: `/pages/profile/profile?id=${attrs['data-userid']}` });
					break;
			}
		}
	}
};
</script>

这个方案里,我对属性和方法的理解是这样的:

  • selectable="false" & preview="false": 在这个场景,我不希望用户能常规地选择文本或预览图片。我希望他们的每一次点击,都是一个明确的交互行为。
  • @itemclick: 这就是神技!它能捕获所有可点击节点(比如 <a> 标签)的点击事件。配合我在“工厂函数”里埋下的各种 data-* 自定义属性,我可以在一个方法里,像个指挥官一样处理所有交互逻辑。复制、跳转、弹窗...无所不能!

这套组合拳打下来,不仅功能完美实现,代码还特别优雅,维护起来简直不要太爽!

第四章:我的血泪总结 ✍️

好了,故事讲完了。回顾这次的“历险”,我的核心收获就一句话:

放弃在客户端解析 HTML 的执念,全面拥抱 nodes 数组!

  • 对于外部内容(文章、商品详情等):让后端成为你的盟友,推行“后端驱动”模式,让接口直接吐出 nodes 数组。
  • 对于内部内容(动态、强交互):自己动手,丰衣足食。在前端编写“工厂函数”,将业务数据“翻译”成 nodes 数组,配合 @itemclick 实现无限可能。

希望我踩过的这些坑,以及从官方文档中得到的“真传”,能让你未来的 uni-app 开发之路更平坦一些。如果你有更好的想法,或者也想分享你的“踩坑”故事,欢迎在评论区和我交流!我们一起,让码农的世界少一点 bug,多一点 a-ha moment!😉👋