uni-app 富文本编辑器:从“坑”满为患到游刃有余,我的踩坑与爬坑实录

0 阅读14分钟

😎 uni-app 富文本编辑器:从“坑”满为患到游刃有余,我的踩坑与爬坑实录 🧗

嘿,各位同行的朋友们,大家好!我是你们的老朋友,一个在前端世界里摸爬滚打了N年的“老兵”。今天想跟大家聊聊一个咱们在做内容型 App 时,几乎绕不开的组件——uni-app 的富文本编辑器 editor

这玩意儿,怎么说呢... 就像一把双刃剑。用好了,用户可以在 App 里自由地发布图文并茂的文章,体验直接拉满;用不好,那简直就是 Bug 的温床,能让你在深夜里对着屏幕怀疑人生。😂

今天,我就把压箱底的经验掏出来,跟大家分享一下我是如何在一个真实项目中,把这个“磨人的小妖精”调教得服服帖帖的。

一、故事的开始:一个“简单”的需求

那是一个阳光明媚的下午,产品经理笑盈盈地找到了我:“嘿,大神,咱们要在新做的‘老年大学在线课堂’ App 里加一个笔记功能,让叔叔阿姨们能记录课堂重点,图文并茂的那种,就像发公众号文章一样,简单吧?”

“简单!” 我当时自信满满。毕竟,uni-app 官方不是提供了 editor 组件吗?我看过文档,支持图片、文字格式化,不就是它了嘛!

image.png

于是,我啪啪啪地敲下了第一版代码,就是你们在上面看到的雏形。基础的加粗、斜体、标题都工作得很好。

然而,我还是太年轻了...真正的挑战才刚刚开始。

二、第一个巨坑:视频!怎么插入视频?!😱

很快,新的需求来了:“用户反馈,老师讲课的视频片段也想放到笔记里,方便回顾。”

我的第一反应是:“完了。”

我立刻翻开 uni-appeditor 文档,从头到尾看了三遍,搜索关键词 “video”,结果是:不支持直接插入 <video> 标签。编辑器会无情地过滤掉它不认识的标签。

完蛋,这功能要黄?难道要跟产品经理说做不了?不行,这不符合我“专家”的身份!冷静,一定有办法!

我的踩坑之旅:

  1. 天真的尝试:我试图用 editorCtx.insertHtml() 直接塞一段 <video> 的 HTML 进去。结果,预览时一片空白。正如文档所说,不认识的标签,bye bye~
  2. 另寻他路:是不是可以用 web-view?做一个单独的 H5 编辑器页面,再嵌入进来?不行,这太重了,而且 uni-app 的原生交互就全没了,体验会很割裂。

恍然大悟的瞬间!💡

就在我一筹莫展的时候,文档里的一句话点醒了我:

“不能直接插入视频或者其他文件,编辑时可以采用视频封面或者文件缩略图占位,并在图片属性中保存视频信息,预览时读取附加信息再还原为视频或者其他文件操作。”

“对啊!曲线救国!” 我激动地拍了下大腿。

我的解决方案(“偷天换日”法):

这个方案的核心思想就是:在编辑时用“假的”东西占位,在预览时再把“真的”东西换回来

  1. 编辑时:插入“带视频信息的图片” 当我点击“插入视频”按钮时,我不再尝试插入 <video>。而是调用 editorCtx.insertImage 方法,插入一张视频的封面图。 最关键的一步来了,我要利用 insertImagedata 属性,把真实的视频 URL 给“藏”进去。

    // 就像 VideoEditorDemo.vue 里的那样
    insertVideo() {
        const mockVideoUrl = 'https://.../your-video.mp4';
        const mockCoverUrl = 'https://.../your-cover.jpg';
      
        this.editorCtx.insertImage({
            src: mockCoverUrl, // 编辑器里显示的是这张封面图
            alt: '视频占位符',
            // ✨ 高光时刻:把视频信息塞到 data-custom 里!
            data: {
                from: 'video-placeholder',
                'video-url': mockVideoUrl 
            },
            success: () => {
                // 插入一个换行,避免光标粘连
                this.editorCtx.insertText({ text: '\n' }); 
            }
        });
    }
    

    执行后,编辑器生成的 HTML 大概长这样: <img src="your-cover.jpg" alt="视频占位符" data-custom="from=video-placeholder&amp;video-url=https://.../your-video.mp4">

  2. 预览时:解析 HTML,还原成真视频 当用户点击“预览”时,挑战又来了。我不能直接把 editor 生成的 html 丢给 rich-text 组件,因为它会把我的 <img> 标签老老实实地渲染成一张图片。

    我需要一个“翻译官”!这个翻译官的职责,就是把这段特殊的 HTML,翻译成 uni-app 视图层能理解的节点数组(Nodes Array)。

    • 遇到普通文本和图片,翻译成文本节点和图片节点。
    • 遇到我们那个带有 data-custom 的特殊 <img> 占位符时,就把它翻译成一个 <video> 视频节点!

    于是,就有了上面 VideoEditorDemo.vue 里的那段关键解析逻辑:parseHtmlToNodes

    // 在 togglePreview 方法中被调用
    togglePreview() {
        this.isPreview = !this.isPreview;
        if (this.isPreview) {
            this.editorCtx.getContents({
                success: (res) => {
                    const rawHtml = res.html;
                    // ✨ 调用我们的“翻译官”
                    this.nodes = this.parseHtmlToNodes(rawHtml);
                }
            });
        }
    }
    
    // “翻译官”本人
    parseHtmlToNodes(html) {
        const nodes = [];
        // 正则表达式:专门捕获带 data-custom 的图片和其他内容
        const regex = /<img(?=[^>]*\ssrc="([^"]+)")[^>]*data-custom="([^"]+)"[^>]*>/gi;
      
        let lastIndex = 0;
        let match;
    
        // 循环切割 HTML 字符串
        while ((match = regex.exec(html)) !== null) {
            // 1. 先把图片前面的文本部分塞进去
            const textBefore = html.substring(lastIndex, match.index);
            if (textBefore) {
                nodes.push({ type: 'text', content: textBefore });
            }
    
            // 2. 解析我们藏起来的视频信息
            const customDataString = match[2]; // "from=...&amp;video-url=..."
            const customData = this.parseQueryString(customDataString); // 自定义一个解析器
    
            // 3. 如果是视频占位符,就创建 video 节点!
            if (customData.from === 'video-placeholder' && customData['video-url']) {
                nodes.push({
                    type: 'video',
                    src: customData['video-url']
                });
            } else {
                // 如果不是,就还是当普通图片处理
                nodes.push({ type: 'text', content: match[0] });
            }
    
            lastIndex = regex.lastIndex;
        }
    
        // 4. 别忘了最后一部分文本
        const textAfter = html.substring(lastIndex);
        if (textAfter) {
            nodes.push({ type: 'text', content: textAfter });
        }
    
        return nodes;
    }
    

    然后,在 <template> 部分,我们不再使用单一的 rich-text,而是用 v-for 循环这个 nodes 数组,根据 type 来决定渲染 <rich-text> 还是 <video>

    <view v-if="isPreview" class="preview-area">
        <view v-for="(node, index) in nodes" :key="index">
            <rich-text v-if="node.type === 'text'" :nodes="node.content"></rich-text>
            <view v-else-if="node.type === 'video'" class="video-wrapper">
                <video :src="node.src" controls></video>
            </view>
        </view>
    </view>
    

    至此,视频插入和预览的问题,完美解决!🎉

示例1

image.png

<template>
	<view class="container">
		<!-- 顶部状态栏与工具栏 -->
		<view class="header-toolbar">
			<view class="status-bar">
				<text>{{ editorStatus }}</text>
			</view>
			<!-- 格式化工具栏,只在非只读模式下显示 -->
			<view class="format-toolbar" v-if="!isReadOnly">
				<!-- 为了演示状态同步,我们给按钮绑定一个 active class -->
				<view :class="['tool-item', { active: formats.bold }]" @click="format('bold')">B</view>
				<view :class="['tool-item', { active: formats.italic }]" @click="format('italic')">I</view>
				<view :class="['tool-item', { active: formats.header === 'H2' }]" @click="format('header', 'H2')">H2</view>
				<view class="tool-item" @click="insertImage">
					<image class="tool-icon" src="/static/icon-image.png"></image>
					<!-- 建议使用图标 -->
				</view>
				<!-- ... 其他工具按钮 ... -->
			</view>
		</view>

		<!-- Editor 组件本体 -->
		<editor
			id="syncNoteEditor"
			class="editor-instance"
			:read-only="isReadOnly"
			placeholder="开始撰写你的笔记,记录每一个闪光的想法..."
			:show-img-size="true"
			:show-img-toolbar="true"
			:show-img-resize="true"
			@ready="onEditorReady"
			@focus="onEditorFocus"
			@blur="onEditorBlur"
			@input="onEditorInput"
			@statuschange="onStatusChange"
		></editor>

		<!-- 底部操作栏 -->
		<view class="footer-actions">
			<button size="mini" @click="toggleReadOnly">
				{{ isReadOnly ? '编辑模式' : '预览模式' }}
			</button>
			<button size="mini" type="primary" @click="saveContentManually">手动保存</button>
		</view>
	</view>
</template>

<script>
// JavaScript 逻辑部分与之前相同,这里为了完整性再次贴出
export default {
	data() {
		return {
			isReadOnly: false,
			editorStatus: '等待编辑...',
			editorCtx: null,
			documentId: null,
			// 新增:用于同步工具栏状态
			formats: {}
		};
	},
	onLoad(options) {
		this.documentId = options.id || null;
		if (this.documentId) {
			this.isReadOnly = true;
			this.editorStatus = '正在加载文档...';
		} else {
			this.isReadOnly = false;
			this.editorStatus = '新文档';
		}
	},
	methods: {
		onEditorReady() {
			uni
				.createSelectorQuery()
				.select('#syncNoteEditor')
				.context((res) => {
					this.editorCtx = res.context;
					if (this.documentId) {
						this.loadInitialContent();
					}
				})
				.exec();
		},
		loadInitialContent() {
			const mockApiData = {
				delta: { ops: [{ insert: '这是从服务器加载的' }, { attributes: { bold: true }, insert: '加粗' }, { insert: '的初始内容。\n' }] }
			};
			this.editorCtx.setContents({
				delta: mockApiData.delta,
				success: () => (this.editorStatus = '文档加载完毕,当前为预览模式')
			});
		},
		
		onEditorFocus(e) {
			if (!this.isReadOnly) this.editorStatus = '正在输入...';
		},
		onEditorBlur(e) {
			if (!this.isReadOnly) this.editorStatus = '已自动保存';
		},
		onEditorInput(e) {
			this.editorStatus = '正在保存...';
			// 实际项目中使用防抖
			setTimeout(() => {
				if (this.editorStatus === '正在保存...') this.editorStatus = '已自动保存';
			}, 1000);
		},
		onStatusChange(e) {
			this.formats = e.detail;
			console.log('样式状态改变', this.formats);
		},
		toggleReadOnly() {
			this.isReadOnly = !this.isReadOnly;
			this.editorStatus = this.isReadOnly ? '预览模式' : '编辑模式';
			// 清空样式状态,防止模式切换后按钮状态不正确
			if (this.isReadOnly) {
				this.formats = {};
			}
		},
		format(name, value) {
			if (!this.editorCtx || this.isReadOnly) return;
			this.editorCtx.format(name, value);
		},
		insertImage() {
			if (!this.editorCtx || this.isReadOnly) return;
			uni.chooseImage({
				count: 1,
				success: (res) => {
					this.editorCtx.insertImage({
						src: res.tempFilePaths[0],
						alt: '图片描述',
						success: () => console.log('图片插入成功')
					});
				}
			});
		},
		saveContentManually() {
			if (!this.editorCtx) return;
			this.editorCtx.getContents({
				success: (res) => {
					console.log('手动保存内容:', res.delta);
					uni.showToast({ title: '保存成功!' });
					this.editorStatus = '手动保存成功';
				}
			});
		}
	}
};
</script>

<style scoped>
/* 使用 scoped 来确保样式只作用于当前组件 */

/* 整体布局 */
.container {
	display: flex;
	flex-direction: column;
	height: 100vh; /* 使容器占满整个屏幕高度 */
	background-color: #f9f9f9;
}

/* 头部区域 */
.header-toolbar {
	padding: 10px;
	border-bottom: 1px solid #e0e0e0;
	background-color: #ffffff;
}

.status-bar {
	font-size: 12px;
	color: #999;
	text-align: right;
	margin-bottom: 8px;
	height: 15px;
}

/* 格式化工具栏 */
.format-toolbar {
	display: flex;
	flex-wrap: wrap;
	align-items: center;
	gap: 15px; /* 替代 margin,提供更现代的间距控制 */
}

.tool-item {
	display: flex;
	justify-content: center;
	align-items: center;
	width: 28px;
	height: 28px;
	font-weight: bold;
	font-size: 16px;
	color: #333;
	cursor: pointer;
	border: 1px solid transparent;
	border-radius: 4px;
	transition: background-color 0.2s;
}

.tool-item:hover {
	background-color: #efefef;
}
/* 工具栏按钮激活状态 */
.tool-item.active {
	background-color: #e0e0e0;
	border-color: #ccc;
}
.tool-icon {
	width: 20px;
	height: 20px;
}

/* 编辑器实例 */
.editor-instance {
	flex: 1; /* 关键:让编辑器区域填满剩余空间 */
	width: 100%;
	padding: 15px;
	box-sizing: border-box; /* 内边距不会撑开盒子 */
	background-color: #ffffff;
}

/* 底部操作区 */
.footer-actions {
	display: flex;
	justify-content: flex-end;
	align-items: center;
	gap: 10px;
	padding: 10px;
	border-top: 1px solid #e0e0e0;
	background-color: #ffffff;
}

/* 深度作用选择器,用于设置编辑器内部内容的样式 */
/* 注意:在不同预处理器中写法可能为 ::v-deep 或 /deep/ */
/deep/ .ql-editor {
	line-height: 1.6;
}
/deep/ .ql-editor h1 {
	font-size: 2em;
	margin: 0.67em 0;
	font-weight: bold;
}
/deep/ .ql-editor h2 {
	font-size: 1.5em;
	margin: 0.83em 0;
	font-weight: bold;
}
/deep/ .ql-editor p {
	margin: 1em 0;
}
/deep/ .ql-editor img {
	max-width: 100%; /* 图片自适应宽度 */
	display: block;
	margin: 10px 0;
}
/deep/ .ql-editor ul,
/deep/ .ql-editor ol {
	padding-left: 2em;
}
</style>

示例2

image.png

<!-- VideoEditorDemo.vue (已修复版) -->
<template>
	<view class="page-container">
		<!-- 1. 功能切换与工具栏 (无变化) -->
		<view class="toolbar-wrapper">
			<view class="mode-switcher">
				<button size="mini" @click="togglePreview" :disabled="!editorCtx">
					{{ isPreview ? '返回编辑' : '进入预览' }}
				</button>
			</view>
			<view class="format-toolbar" v-show="!isPreview">
				<view :class="['tool-item', { active: formats.bold }]" @click="format('bold')">B</view>
				<view :class="['tool-item', { active: formats.italic }]" @click="format('italic')">I</view>
				<view class="tool-item" @click="insertVideo">🎬</view>
			</view>
		</view>

		<!-- 2. 编辑器实例 (无变化) -->
		<editor
			v-show="!isPreview"
			id="videoEditor"
			class="editor-instance"
			placeholder="点击 🎬 按钮,尝试插入一个视频..."
			@ready="onEditorReady"
			@statuschange="onStatusChange"
			:show-img-size="true"
			:show-img-toolbar="true"
		></editor>

		<!-- 3. 【重大改造】预览区域:从单个rich-text改为节点化渲染 -->
		<view v-if="isPreview" 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>
					<p style="font-size: 12px; color: #999; text-align: center">视频预览</p>
				</view>
			</view>
		</view>
	</view>
</template>

<script>
export default {
	data() {
		return {
			editorCtx: null,
			isPreview: false,
			formats: {},
			// 【改造】不再使用 processedHtml 字符串,而是使用一个节点数组
			nodes: [], // e.g. [ {type: 'text', content: '...'}, {type: 'video', src: '...'} ]
			demoText:
				'<iframe class="ql-video" frameborder="0" allowfullscreen="true" src="https://lf-cdn.trae.com.cn/obj/trae-com-cn/bannerIntro425.mp4"></iframe><h1><br></h1><h1><span style="color: rgb(51, 51, 51);" class="ql-size-large">范德萨范德萨</span></h1><p><br></p><p><span class="ql-size-huge">网上老年大学,是中国老年大学协会战略合作伙伴,全国老年大学官方线上学习APP,为全国中老年朋友提供知识、资讯、娱乐等优质服务,帮助中老年朋友更好地适应数字化生活。</span></p><p><br></p><p><br></p><p><img src="https://jwxtcdn.jinlingkeji.cn/saastest/308/2025-05/96f8e36b996e1f3b497796061006df43.jpg"></p>'
		};
	},
	methods: {
		/**
		 * @description 【V5.0 最终版】通用HTML转Nodes解析器(简化版)
		 * @param {string} html - 包含多种标签的富文本字符串
		 * @returns {Array} - uni-app rich-text 组件所需的 nodes 数组
		 */
		htmlToNodes(html) {
			if (!html) return [];

			const nodes = [];
			// 正则表达式,用于匹配 <iframe>, <img>, <p>, <h1> 和文本内容
			// 这是一个简化的匹配,可能无法处理复杂的嵌套
			const regex = /(<iframe[^>]*>[\s\S]*?<\/iframe>)|(<img[^>]*>)|(<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>)|(<p[^>]*>[\s\S]*?<\/p>)|([\s\S]+?)(?=<[a-z]|\s*$)/gi;

			html.replace(regex, (match, iframe, img, heading, p, text) => {
				if (iframe) {
					// 匹配到 iframe -> 转换为 video 节点
					const srcMatch = iframe.match(/src="([^"]+)"/);
					const classMatch = iframe.match(/class="([^"]+)"/);
					if (srcMatch) {
						nodes.push({
							name: 'video',
							attrs: {
								src: srcMatch[1],
								class: classMatch ? classMatch[1] : 'video-style', // 给个默认class
								controls: true,
								style: 'width: 100%;'
							}
						});
					}
				} else if (img) {
					// 匹配到 img -> 转换为 image 节点
					const srcMatch = img.match(/src="([^"]+)"/);
					if (srcMatch) {
						nodes.push({
							name: 'img',
							attrs: {
								src: srcMatch[1],
								style: 'max-width: 100%;'
							}
						});
					}
				} else if (heading || p) {
					// 匹配到标题或段落,这里做简化处理,只提取文本
					// 一个完整的解析器需要递归处理内部的 <span> 等标签
					const tagContent = heading || p;
					const textOnly = tagContent.replace(/<[^>]+>/g, ''); // 粗暴地去掉所有内部标签
					if (textOnly.trim()) {
						nodes.push({
							name: 'p', // 统一用p标签展示
							children: [
								{
									type: 'text',
									text: textOnly
								}
							]
						});
					}
				} else if (text && text.trim()) {
					// 匹配到纯文本
					nodes.push({
						type: 'text',
						text: text.trim()
					});
				}
			});
			return nodes;
		},
		// 在 getEditorContent 中调用
		previewContent() {
			this.editorCtx.getContents({
				success: (res) => {
					const nodes = this.htmlToNodes(res.html);
					// 将nodes数组交给rich-text组件渲染
					// this.nodes = nodes;
				}
			});
		},
		// onEditorReady, onStatusChange, format, insertVideo 方法无变化
		onEditorReady() {
			uni
				.createSelectorQuery()
				.select('#videoEditor')
				.context((res) => {
					this.editorCtx = res.context;
				})
				.exec();
		},
		onStatusChange(e) {
			this.formats = e.detail;
		},
		format(name, value) {
			this.editorCtx.format(name, value);
		},
		insertVideo() {
			const mockVideoUrl = 'https://lf-cdn.trae.com.cn/obj/trae-com-cn/bannerIntro425.mp4';
			const mockCoverUrl = 'https://img.88tph.com/87/c9/h8m8dbbfEeyEcQAWPgWqLw-1.jpg!/fw/700/watermark/url/L3BhdGgvbG9nby5wbmc/align/center';
			this.editorCtx.insertImage({
				src: mockCoverUrl,
				alt: '视频占位符',
				data: { from: 'video-placeholder', 'video-url': mockVideoUrl },
				success: () => {
					this.editorCtx.insertText({ text: '\n' });
				}
			});
		},

		/**
		 * @description 核心方法2:切换预览模式并处理内容
		 */
		togglePreview() {
			this.isPreview = !this.isPreview;
			if (this.isPreview) {
				this.editorCtx.getContents({
					success: (res) => {
						const rawHtml = res.html;
						console.log('获取到的原始HTML:', rawHtml);
						// 【改造】调用新的解析函数,将HTML转换为节点数组
						this.nodes = this.parseHtmlToNodes(rawHtml);
						console.log('处理后的节点数组:', this.nodes);
					}
				});
			} else {
				// 返回编辑时清空节点,释放内存
				this.nodes = [];
			}
		},

		/**
		 * @description HTML解析器
		 * 使用正确的查询字符串解析器,应对最终的数据格式
		 */
		parseHtmlToNodes(html) {
			const nodes = [];
			// 这个正则表达式本身是正确的,它成功捕获了 data-custom 的内容,所以不需要改
			const regex = /<img(?=[^>]*\ssrc="([^"]+)")[^>]*data-custom="([^"]+)"[^>]*>/gi;

			let lastIndex = 0;
			let match;

			while ((match = regex.exec(html)) !== null) {
				const textBefore = html.substring(lastIndex, match.index);
				if (textBefore) {
					nodes.push({ type: 'text', content: textBefore });
				}

				const posterUrl = match[1];
				const customDataString = match[2]; // 这就是那个 "from=...&amp;video-url=..." 字符串

				try {
					// 不使用 JSON.parse,而是使用我们自己的查询字符串解析器
					const customData = this.parseQueryString(customDataString);
					if (customData.from === 'video-placeholder' && customData['video-url']) {
						nodes.push({
							type: 'video',
							src: customData['video-url'],
							poster: posterUrl
						});
					} else {
						nodes.push({ type: 'text', content: match[0] });
					}
				} catch (e) {
					console.error('解析 custom data 失败:', e, '原始字符串:', customDataString);
					nodes.push({ type: 'text', content: match[0] });
				}

				lastIndex = regex.lastIndex;
			}

			const textAfter = html.substring(lastIndex);
			if (textAfter) {
				nodes.push({ type: 'text', content: textAfter });
			}

			if (nodes.length === 0 && html.length > 0) {
				nodes.push({ type: 'text', content: html });
			}

			return nodes;
		},

		/**
		 * @description 辅助函数:解析URL查询字符串
		 * @param {string} str - 例如 "key1=value1&amp;key2=value2"
		 * @returns {object} - 例如 { key1: 'value1', key2: 'value2' }
		 */
		parseQueryString(str) {
			const result = {};
			// 1. 先将 &amp; 替换回 &
			const decodedStr = str.replace(/&amp;/g, '&');
			// 2. 按 & 分割成键值对数组
			const pairs = decodedStr.split('&');

			for (const pair of pairs) {
				// 3. 按 = 分割键和值
				const [key, value] = pair.split('=');
				if (key) {
					// 4. 使用 decodeURIComponent 来解码值,以防URL中有特殊字符
					result[key] = decodeURIComponent(value || '');
				}
			}
			return result;
		}
	}
};
</script>

<style scoped>
/* 样式基本无变化,可按需添加 .video-wrapper 的样式 */
.page-container {
	display: flex;
	flex-direction: column;
	height: 100vh;
}
.toolbar-wrapper {
	padding: 10px;
	border-bottom: 1px solid #e0e0e0;
	background: #fff;
}
.mode-switcher {
	margin-bottom: 10px;
	text-align: right;
}
.format-toolbar {
	display: flex;
	align-items: center;
	gap: 15px;
}
.tool-item {
	display: flex;
	justify-content: center;
	align-items: center;
	width: 30px;
	height: 30px;
	font-size: 18px;
	font-weight: bold;
	border-radius: 4px;
	transition: background-color 0.2s;
	cursor: pointer;
}
.tool-item:hover {
	background-color: #efefef;
}
.tool-item.active {
	background-color: #e0e0e0;
}
.editor-instance {
	flex: 1;
	width: 100%;
	padding: 15px;
	box-sizing: border-box;
	background: #fff;
}
.preview-area {
	flex: 1;
	padding: 15px;
	background: #f7f7f7;
	overflow-y: auto;
}
.video-wrapper {
	margin: 10px 0;
}
</style>

三、一些被忽略却很重要的“小坑” 🧐

解决了视频这个大 Boss,还有几个小怪需要清理,它们严重影响开发和用户体验。

  1. 工具栏状态不同步 问题:当我把光标移动到一段加粗的文字上时,工具栏的“B”按钮居然不是高亮状态!这让用户很困惑。 解决方案@statuschange 事件是你的好朋友!每当光标所在位置的样式发生变化,它就会触发,并告诉你当前的样式集合 formats

    // <script>
    data() {
        return {
            formats: {} // 用一个对象来存储当前光标的样式
        }
    },
    methods: {
        onStatusChange(e) {
            this.formats = e.detail;
            console.log('当前光标样式:', this.formats);
        }
    }
    
    // <template>
    // 给工具栏按钮绑定一个动态 class
    <view :class="['tool-item', { active: formats.bold }]" @click="format('bold')">B</view>
    

    这样,工具栏就能实时反映光标所在位置的文本样式了,体验感瞬间提升!

  2. H5 端加载失败或缓慢 问题:有同事反馈,在H5环境下,编辑器偶尔加载不出来,白屏。打开控制台一看,是 quill.min.js 这个文件从国外 unpkg.com 加载超时了。 解决方案:千万别依赖国外的 CDN!这在生产环境是致命的。官方文档也给出了最佳实践:

    • 方案一(简单粗暴):把 quill.min.jsimage-resize.min.js 下载下来,放到你项目的 static 目录下。然后在 public/index.html 里直接引入。
    • 方案二(更优雅):使用 npm 安装 quill,然后在 main.jsApp.vue 中,通过编译条件判断,在 H5 环境下把它挂载到 window 对象上。
      // main.js
      // #ifdef H5
      import quill from "quill";
      window.Quill = quill;
      // #endif
      

    我个人更推荐方案二,依赖管理更清晰。

四、总结与感悟 🚀

回顾整个过程,uni-appeditor 组件本身只是提供了一个基础的“画布”,而要构建一个功能完善、体验良好的富文本编辑器,需要我们开发者在其之上做大量的“装修”工作。

我的核心感悟是:

  1. 别被文档吓倒:“不支持”不代表“做不了”。官方文档给出的往往是标准路径,而“附加信息”里常常藏着解决复杂问题的钥匙。
  2. 分离“编辑”与“预览”的思维:编辑态追求的是数据录入的正确性(比如用占位符),预览态追求的是内容的正确展示(比如把占位符还原)。对 editor 来说,getContents 获取的 html 只是一个“中间数据源”,而不是最终的渲染目标。
  3. 魔鬼在细节中:像工具栏状态同步、H5 资源本地化这些“小事”,恰恰是区分一个产品是“能用”还是“好用”的关键。

希望我这次的“踩坑”实录能对大家有所帮助。在开发的道路上,我们总会遇到各种各样的“坑”,但每一次成功“爬坑”,都会让我们变得更强。💪

好了,今天的分享就到这里。如果你有任何问题,或者有更棒的解决方案,欢迎在评论区和我交流!下次见!😉