😎 uni-app 富文本编辑器:从“坑”满为患到游刃有余,我的踩坑与爬坑实录 🧗
嘿,各位同行的朋友们,大家好!我是你们的老朋友,一个在前端世界里摸爬滚打了N年的“老兵”。今天想跟大家聊聊一个咱们在做内容型 App 时,几乎绕不开的组件——uni-app
的富文本编辑器 editor
。
这玩意儿,怎么说呢... 就像一把双刃剑。用好了,用户可以在 App 里自由地发布图文并茂的文章,体验直接拉满;用不好,那简直就是 Bug 的温床,能让你在深夜里对着屏幕怀疑人生。😂
今天,我就把压箱底的经验掏出来,跟大家分享一下我是如何在一个真实项目中,把这个“磨人的小妖精”调教得服服帖帖的。
一、故事的开始:一个“简单”的需求
那是一个阳光明媚的下午,产品经理笑盈盈地找到了我:“嘿,大神,咱们要在新做的‘老年大学在线课堂’ App 里加一个笔记功能,让叔叔阿姨们能记录课堂重点,图文并茂的那种,就像发公众号文章一样,简单吧?”
“简单!” 我当时自信满满。毕竟,uni-app
官方不是提供了 editor
组件吗?我看过文档,支持图片、文字格式化,不就是它了嘛!
于是,我啪啪啪地敲下了第一版代码,就是你们在上面看到的雏形。基础的加粗、斜体、标题都工作得很好。
然而,我还是太年轻了...真正的挑战才刚刚开始。
二、第一个巨坑:视频!怎么插入视频?!😱
很快,新的需求来了:“用户反馈,老师讲课的视频片段也想放到笔记里,方便回顾。”
我的第一反应是:“完了。”
我立刻翻开 uni-app
的 editor
文档,从头到尾看了三遍,搜索关键词 “video”,结果是:不支持直接插入 <video>
标签。编辑器会无情地过滤掉它不认识的标签。
完蛋,这功能要黄?难道要跟产品经理说做不了?不行,这不符合我“专家”的身份!冷静,一定有办法!
我的踩坑之旅:
- 天真的尝试:我试图用
editorCtx.insertHtml()
直接塞一段<video>
的 HTML 进去。结果,预览时一片空白。正如文档所说,不认识的标签,bye bye~ - 另寻他路:是不是可以用
web-view
?做一个单独的 H5 编辑器页面,再嵌入进来?不行,这太重了,而且uni-app
的原生交互就全没了,体验会很割裂。
恍然大悟的瞬间!💡
就在我一筹莫展的时候,文档里的一句话点醒了我:
“不能直接插入视频或者其他文件,编辑时可以采用视频封面或者文件缩略图占位,并在图片属性中保存视频信息,预览时读取附加信息再还原为视频或者其他文件操作。”
“对啊!曲线救国!” 我激动地拍了下大腿。
我的解决方案(“偷天换日”法):
这个方案的核心思想就是:在编辑时用“假的”东西占位,在预览时再把“真的”东西换回来。
-
编辑时:插入“带视频信息的图片” 当我点击“插入视频”按钮时,我不再尝试插入
<video>
。而是调用editorCtx.insertImage
方法,插入一张视频的封面图。 最关键的一步来了,我要利用insertImage
的data
属性,把真实的视频 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&video-url=https://.../your-video.mp4">
-
预览时:解析 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=...&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
<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
<!-- 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=...&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&key2=value2"
* @returns {object} - 例如 { key1: 'value1', key2: 'value2' }
*/
parseQueryString(str) {
const result = {};
// 1. 先将 & 替换回 &
const decodedStr = str.replace(/&/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,还有几个小怪需要清理,它们严重影响开发和用户体验。
-
工具栏状态不同步 问题:当我把光标移动到一段加粗的文字上时,工具栏的“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>
这样,工具栏就能实时反映光标所在位置的文本样式了,体验感瞬间提升!
-
H5 端加载失败或缓慢 问题:有同事反馈,在H5环境下,编辑器偶尔加载不出来,白屏。打开控制台一看,是
quill.min.js
这个文件从国外unpkg.com
加载超时了。 解决方案:千万别依赖国外的 CDN!这在生产环境是致命的。官方文档也给出了最佳实践:- 方案一(简单粗暴):把
quill.min.js
和image-resize.min.js
下载下来,放到你项目的static
目录下。然后在public/index.html
里直接引入。 - 方案二(更优雅):使用 npm 安装
quill
,然后在main.js
或App.vue
中,通过编译条件判断,在 H5 环境下把它挂载到window
对象上。// main.js // #ifdef H5 import quill from "quill"; window.Quill = quill; // #endif
我个人更推荐方案二,依赖管理更清晰。
- 方案一(简单粗暴):把
四、总结与感悟 🚀
回顾整个过程,uni-app
的 editor
组件本身只是提供了一个基础的“画布”,而要构建一个功能完善、体验良好的富文本编辑器,需要我们开发者在其之上做大量的“装修”工作。
我的核心感悟是:
- 别被文档吓倒:“不支持”不代表“做不了”。官方文档给出的往往是标准路径,而“附加信息”里常常藏着解决复杂问题的钥匙。
- 分离“编辑”与“预览”的思维:编辑态追求的是数据录入的正确性(比如用占位符),预览态追求的是内容的正确展示(比如把占位符还原)。对
editor
来说,getContents
获取的html
只是一个“中间数据源”,而不是最终的渲染目标。 - 魔鬼在细节中:像工具栏状态同步、H5 资源本地化这些“小事”,恰恰是区分一个产品是“能用”还是“好用”的关键。
希望我这次的“踩坑”实录能对大家有所帮助。在开发的道路上,我们总会遇到各种各样的“坑”,但每一次成功“爬坑”,都会让我们变得更强。💪
好了,今天的分享就到这里。如果你有任何问题,或者有更棒的解决方案,欢迎在评论区和我交流!下次见!😉