😎 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
<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="insertImage">
<image class="tool-icon" src="/static/logo.png"></image>
</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: {},
nodes: [] // e.g. [ {type: 'text', content: '...'}, {type: 'video', src: '...'} ]
};
},
methods: {
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() {
// 1. 真实的视频和封面URL
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';
// 2. Base64 编码的播放按钮 SVG 图标
// 这是一个 60x60px 的、带半透明黑色背景的白色播放三角
const playIconSvgBase64 =
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2MCIgaGVpZ2h0PSI2MCIgdmlld0JveD0iMCAwIDYwIDYwIj48Y2lyY2xlIGN4PSIzMCIgY3k9IjMwIiByPSIzMCIgZmlsbD0icmdiYSgwLDAsMCwwLjUpIi8+PHBhdGggZD0iTTI2IDIwIEw0MSAzMCBMMjYgNDAgWiIgZmlsbD0id2hpdGUiLz48L3N2Zz4=';
// 3. 1x1像素的透明GIF,用于 src 属性
const transparentGif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
// --- 构建内联样式 ---
const inlineStyle = `
/* 关键:将两个图片设为背景 */
background-image: url(${playIconSvgBase64}), url(${mockCoverUrl});
/* 让播放按钮不重复,并居中显示;让封面图覆盖整个区域 */
background-repeat: no-repeat, no-repeat;
background-position: center, center;
background-size: 60px 60px, cover;
/* 给图片一个明确的尺寸,因为它的 src 是一个没有尺寸的透明像素 */
width: 100%;
height: 200px; /* 您可以根据需要调整这个高度 */
display: block;
border: 1px dashed #ddd; /* 可选项:添加一个边框,让它在编辑器里更明显 */
`;
this.editorCtx.insertImage({
// src 使用透明图
src: playIconSvgBase64,
// alt 文本对于可访问性至关重要
alt: '视频占位符',
// 注入我们精心构造的内联样式
style: inlineStyle,
// data-* 属性依然用于逻辑处理
data: {
from: 'video-placeholder',
'video-url': mockVideoUrl
},
success: () => {
this.editorCtx.insertText({ text: '\n' });
}
});
},
/**
* @description 插入图片
* 这里使用了 uni.chooseImage 来选择图片,并插入到编辑器中
*/
insertImage() {
uni.chooseImage({
count: 1,
success: (res) => {
this.editorCtx.insertImage({
src: res.tempFilePaths[0],
alt: '图片描述',
success: () => console.log('图片插入成功')
});
}
});
},
/**
* @description 切换预览模式并处理内容
*/
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-icon {
width: 20px;
height: 20px;
}
.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;
}
/*
使用类选择器,这是为元素添加样式的最常用和最高效的方式。
.editor-instance >>> .video-placeholder-img
*/
.editor-instance >>> .video-placeholder-img {
position: relative;
filter: brightness(85%);
display: block;
}
.editor-instance >>> .video-placeholder-img::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 60px;
height: 60px;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
border-style: solid;
border-width: 15px 0 15px 26px;
border-color: transparent transparent transparent white;
box-sizing: border-box;
cursor: default;
transition: background-color 0.2s;
}
.editor-instance >>> .video-placeholder-img:hover::after {
background-color: rgba(0, 0, 0, 0.7);
}
</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 资源本地化这些“小事”,恰恰是区分一个产品是“能用”还是“好用”的关键。
希望我这次的“踩坑”实录能对大家有所帮助。在开发的道路上,我们总会遇到各种各样的“坑”,但每一次成功“爬坑”,都会让我们变得更强。💪
好了,今天的分享就到这里。如果你有任何问题,或者有更棒的解决方案,欢迎在评论区和我交流!下次见!😉