资讯详情

资讯详情

富文本编辑器选型与实现:从需求拆解到数据模型与安全防护

1. 需求拆解先行你以为要“一个编辑器”实际要“一套编辑环境”接到一个后台系统的需求说要加“富文本编辑器”很多人第一反应是找个开源库npm install初始化完事。真这么简单也就不会有后面连续两周的改 bug 时间了。先说清楚富文本编辑器到底解决什么问题它本质上是把一个不可控的 HTML 编辑过程包装成用户可理解的 Word 形态。用户看到的是加粗、标题、列表、图片浏览器背后做的事情是修改 DOM 结构、维护光标选区、处理粘贴进来的千奇百怪内容。所以选型之前先别急着切代码把需求问清楚比什么都重要。我一般会先列一张业务需求清单围绕五个口径来问编辑范围是整页整页的图文排版还是只允许小段文字加粗、加链接前者要考虑标题层级、段落间距、缩进、引用块、代码块后者一个轻量编辑器就够了。数据形态最终存进数据库的是什么格式纯 HTML 字符串、JSON 对象还是 Markdown 转 HTML这直接决定编辑器内核选型因为不同编辑器对外输出的数据结构完全不同。协同需求是否要多人同时编辑同一篇文档如果需要就不是普通富文本编辑器能解决的得考虑基于 OT 或 CRDT 的协同框架选型范围会急剧缩小。资源处理方式图片、视频、附件。上传到自己的服务器、走 OSS 直传、还是先存 base64这涉及编辑器集成层要写多少扩展代码。使用场景复杂度是内部后台的公告编辑还是面向 C 端用户的文章创作后者对交互要求高很多比如斜杠菜单、行内 / 行块拖拽、字数统计、快捷键习惯这些都是一层一层叠上去的。大多数项目失败不是编辑器不行而是需求没说清就选型最后发现能力边界对不上。比如内部系统里只发个通知套了个重型文档编辑器数据结构调整复杂、体积又大反过来一个需要精细排版的创作产品塞了个轻量编辑器进去用户想做个二级标题都吃力。先花半小时梳理需求后面能少踩一大半坑。除需求外还要确认团队的消化能力。富文本编辑器的源码复杂度在所有前端项目里排得上号如果团队没有能力做二次封装和定制那就老老实实选生态成熟、社区活跃的方案别一上来就挑战高难度内核。2. 技术选型拆解一步错步步错到底该选哪条赛道先记住一个基础事实所有网页富文本编辑器底层都绕不开contenteditable这个属性把普通 HTML 元素变成可编辑区域浏览器在里面维护光标、响应快捷键和输入法。区别在于不同编辑器在它之上盖了多少层。选型之前需要把contenteditable的底细摸清。很多人以为它不就是一个可编辑区域吗实际上它有大量历史包袱不同浏览器生成的 HTML 结构不一致、光标管理非常脆弱、拼音输入法组合状态时会出现内容丢失。一套成熟编辑器的核心工作就是把这些杂乱行为统一成稳定的编辑模型。2.1 三条技术路线的横向对比市面上常见的方案大致分三条路线我做了个简化对比方便大家直接找位置路线类型代表思路数据格式上手难度定制能力典型场景轻量方案以简单封装为主的编辑器HTML 字符串低低后台通知、简单备注中间地带带格式模型与模块化架构自定义 JSON 或带规范的 HTML中中高博客后台、通用内容管理重型方案基于虚拟 DOM 与事务模型Schema 约束的 JSON 文档高极高协同文档、复杂富文本创作轻量方案的特点是直接暴露contenteditable用户输入什么基本就存什么。优点是接入快缺点是一旦用户从 Word 或网页复制内容进来HTML 直接变脏——大量span标签、内联样式、无效嵌套结构前端渲染时样式错乱严重后端清洗也更困难。中间地带方案是当前后台项目的主流选择。这类编辑器自带一套格式模型会对内容做归一化无论用户从哪里粘贴最终生成的 HTML 都符合预设标签规范。比如只允许p、h1-h6、ul、ol、blockquote等结构标签内联样式只保留白名单超出部分自动丢弃。这是我目前最推荐大多数业务项目采用的思路兼顾可控性和开发成本。重型方案则把文档当成严格的数据模型来管理每次输入都是一次事务操作天然适合撤销重做、协同编辑、历史版本对比。但代价是学习曲线很陡如果你只需要一个后台文本域引入这类框架就是用大炮打蚊子。2.2 选型时容易被忽视的三个指标除了功能 demo 表现三个隐藏指标必须提前看包体积与加载策略。现代编辑器普遍采用模块化设计但你真正用的主包多大拆包后首屏加载多少全量引入还是按需加载这些直接关系到系统性能。后台系统如果对首屏要求不高还好面向用户的产品就要谨慎。数据清洗与回显的闭环。看编辑器输出的 HTML 是不是规矩、稳定。我有一个土办法存一段包含加粗、列表、链接、引用块的测试内容然后反复编辑保存十次每次对比输出差异。输出结构乱变、标签越套越深、甚至出现不可解析内容的直接淘汰。移动端和输入法兼容。别只看 PC 桌面端效果。现在很多人用平板和手机访问后台点选文字、调工具栏、输入法上屏、光标定位这些环节最容易出问题。真正测试时建议用中文输入法在 iOS 和 Android 的 WebView 里各走一遍完整流程输入长文本、插入图片、修改中间段落、复制粘贴。2.3 自研还是使用现成框架每次聊到富文本编辑器都会有人问能不能自己写一个我的观点是可以但要明确边界。自己写的好处是数据模型完全可控、依赖少、扩展符合业务要求坏处是所有浏览器兼容坑都得自己趟。如果项目最大的诉求是“一段可编辑的格式化文本”不需要复杂文档结构自研是可行的本文后面也会给出一个极简实现思路。但如果需求一旦涉及复杂排版、图片拖拽缩放、表格编辑、协同能力就要严肃考虑成熟框架。真实项目里自研往往不是技术问题而是时间成本问题。把一周时间花在修光标 bug 上不如拿一周时间做业务定制功能性价比天差地别。提示选型前的最后一步去看这个框架最近一年有没有发版记录和 issue 响应别选一个已经停止维护的项目否则未来的兼容问题只能自己扛。3. 核心机制解析选区、指令与数据模型搞懂这三点才能不翻车开始写代码前必须把富文本编辑器背后三个核心机制理清楚否则你会发现自己改一个 bug 引出两个新 bug永远在“为什么选中文字点了加粗没反应”里打转。3.1 选区系统编辑器真正的命脉用户用鼠标选中一段文字浏览器会生成一个选区Selection对象里面包含零到多个范围Range。富文本编辑器的绝大多数操作都要先读这个选区拿到起点节点、终点节点、各自的偏移量然后决定在哪个位置插入格式或替换内容。难点在于选区信息非常容易丢失。用户点击工具栏按钮的瞬间编辑区就已经失去焦点浏览器会把当前选区清空。所以成熟编辑器都会在失焦前把选区保存下来工具栏操作时再恢复。这也是很多自研编辑器“一加粗就乱跳光标”的根源。来看一段保存与恢复选区的极简实现// 保存选区 function saveSelection() { const sel window.getSelection(); if (!sel.rangeCount) return null; const range sel.getRangeAt(0); return { startContainer: range.startContainer, startOffset: range.startOffset, endContainer: range.endContainer, endOffset: range.endOffset }; } // 恢复选区 function restoreSelection(saved) { if (!saved) return; const sel window.getSelection(); sel.removeAllRanges(); const range document.createRange(); range.setStart(saved.startContainer, saved.startOffset); range.setEnd(saved.endContainer, saved.endOffset); sel.addRange(range); }这段代码本身不复杂难点在什么时候保存、什么时候恢复。正确的时机是mousedown发生在编辑器外部时保存工具栏按钮执行mousedown时阻止默认行为避免原生行为提前把焦点抢走然后再开始操作选区。这里有个关键细节工具栏按钮不用click事件而要用mousedown并在其中调用preventDefault()很多新手都会踩这个坑。3.2 execCommand 的局限性以及它为什么还活着曾几何时浏览器提供了document.execCommand这套指令系统开发者只需要执行document.execCommand(bold)就能加粗选区。听起来很美好实际用起来就发现它不是统一的 API更像一个浏览器各自实现的黑盒同一指令在不同浏览器里的输出结构不同部分指令样式差异明显还有很多指令在移动端直接失效。它至今没被完全移除仅仅是因为生态里还有大量编辑器依赖它浏览器不想搞出兼容性灾难。既然它不稳定为什么还在用因为实现一个加粗按钮最快捷可靠的方案仍是它。但要注意直接在真实编辑器场景里频繁调用这套指令你会失去对文档结构的控制。比如用户连续多次加粗一段文字浏览器可能会生成多层嵌套的b或strong标签这不再是可预期的结构。3.3 数据模型与序列化决定你未来维护体验的胜负手大多数编辑器最终对外暴露的是 HTML 字符串但内部必须有一个“规范的结构模型”。核心目标有三个结构白名单只允许特定标签存在比如段落用p标题用h1-h6列表用ul/ol/li行内样式用strong/em/a其余标签一律过滤。样式白名单style属性只保留极少数必要项比如text-align、background-color其余全部剥离避免各种内联样式污染。自动纠错用户粘贴进来一段垃圾 HTML系统要能把它“洗”成规范结构。比如合并冗余的span、补齐缺失的闭合标签、把嵌套在错误位置的标签移到正确层级。为什么这套模型如此重要因为在后端渲染时你无法控制用户曾经从哪些来源复制过内容。Word 粘贴的 HTML 能带几十层嵌套微信文章复制过来可能带一堆私有类名网页复制的内容会携带原站点的样式。如果编辑器不统一模型这些脏内容直接落库最终展示页就会千奇百怪而且安全风险极高——用户粘贴的内容里完全可能携带恶意脚本后面会详细讲。一个常用的清洗思路是在输出端做标签白名单过滤const ALLOWED_TAGS new Set([ P, H1, H2, H3, UL, OL, LI, STRONG, EM, A, IMG, BLOCKQUOTE, CODE, BR ]); function sanitizeHTML(input) { const doc new DOMParser().parseFromString(input, text/html); function clean(node) { if (node.nodeType Node.ELEMENT_NODE) { if (!ALLOWED_TAGS.has(node.tagName)) { while (node.firstChild) { node.parentNode.insertBefore(node.firstChild, node); } node.parentNode.removeChild(node); return; } // 只保留白名单属性 const attrs Array.from(node.attributes); for (const attr of attrs) { if (![href, src, alt, target].includes(attr.name)) { node.removeAttribute(attr.name); } } } node.childNodes.forEach(clean); } clean(doc.body); return doc.body.innerHTML; }上面的清洗逻辑是简化版真实业务里还要处理协议头、href 的javascript:前缀、图片src的伪造协议等。核心思路不是“写一个完美的消毒器”而是把编辑器输出的内容也纳入同一套清洗流程双保险。4. 实战落地从接入到封装的完整过程选型定了机制也理解了接下来就是实操环节。下面这套流程我已经在多个项目里跑过踩过的坑都标出来了。4.1 步骤一初始化编辑器并配置基础能力以目前后台项目常用的中间地带方案为例初始化的第一件事是配置工具栏和能力项。不要一上来把全部功能铺上去而是根据第一步需求清单里的编辑范围裁剪。内部公告场景一般只需要加粗、斜体、标题、无序列表、有序列表、链接、引用、代码块、图片上传这几项。工具栏按钮配置要遵循一个原则每一项都清晰对应一个输出结构。比如“标题”按钮应该固定映射到H3标签而不是给用户十个选择。功能越明确内容越干净。如果用户需要更多标题层级再开放对应配置不要默认全开。4.2 步骤二按项目场景封装成可复用的业务组件无论用什么框架都不要直接在页面里散落初始化代码。把它封装成一个业务组件统一对外暴露配置项和事件后续维护会轻松很多。以 Vue 组件为例常见封装形态如下// RichEditor.vue 核心逻辑示意 export default { props: { modelValue: { type: String, default: }, placeholder: { type: String, default: 请输入内容... }, toolbar: { type: Array, default: () [] } }, emits: [update:modelValue], mounted() { this.initEditor(); }, methods: { initEditor() { // 初始化编辑器实例 this.editor createEditor({ el: this.$refs.editorWrap, placeholder: this.placeholder, toolbar: this.toolbar, onChange: (html) { this.$emit(update:modelValue, html); } }); // 回显内容 this.editor.setHTML(this.modelValue); } }, watch: { modelValue(newVal) { // 外部重置内容时同步注意避免光标跳动死循环 if (this.editor newVal ! this.editor.getHTML()) { this.editor.setHTML(newVal); } } } };这里有两个很关键的细节。其一双向绑定不要用v-model直接把 HTML 绑上去因为编辑器内部的输入事件频率非常高每次输入都触发一次父组件更新性能和时序都可能出问题。更好的做法是只在blur或防抖时间过后才对外提交内容。其二监听modelValue变化时需要判断当前值与编辑器实际内容是否一致否则外部赋值一次编辑器就把光标重置一次用户输入到一半内容突然跳回开头这个体验非常糟糕。4.3 步骤三定制图片上传与资源回填图片上传是后台系统的高频需求。最直接的做法是把图片转成 base64 放到src里但这样数据库里会塞进一大堆超长文本一条文章就能让接口响应增大数十倍。正确路径是在本地先预览然后异步上传到对象存储拿到 URL 后回填到编辑器的图片节点。需要注意在图片上传期间转圈提示以及上传失败时的回调处理。4.4 步骤四处理粘贴内容拦截脏数据用户从 Word、公众号、网页复制内容粘贴进来时HTML 里会包含大量样式和私有标签。编辑器如果直接接受前面的结构白名单就形同虚设。规范做法是监听paste事件取剪贴板中的 HTML 内容经过统一清洗再插入编辑器。在无法获取 HTML 的情况下退化成纯文本粘贴至少保证不引入脏标签。editor.on(paste, (event) { event.preventDefault(); const html event.clipboardData.getData(text/html); const text event.clipboardData.getData(text/plain); if (html) { const clean sanitizeHTML(html); editor.insertHTML(clean); } else { editor.insertText(text); } });这个处理逻辑需要在编辑器内核层去做普通外部监听往往拿不到拦截时机。4.5 步骤五输入安全防护不能省但也不能过度说到安全有几个经典攻击路径要堵上用户粘贴内容里带有script标签或事件属性比如img srcx onerroralert(1)。链接的href被写成javascript:协议。Markdown 转换时代码块里嵌入可执行的 HTML。处理思路是白名单过滤同时进行协议校验href和src只允许http:、https:、mailto:、tel:这些安全协议。但安全也要防止过犹不及。有些团队对富文本内容管控过严把链接、图片全禁了那不如用纯文本框。安全目标是可控不是把功能砍光。另一个实际经验是展示端渲染时也要做一遍同样的过滤不能只依赖编辑器输出端。因为数据可能会从其他渠道写入也可能在后续迭代中被别的手段绕过。4.6 从零实现一个极简编辑器理解原理的最短路径为了展示自研的可行性我给出一个极简编辑器的骨架。虽然它不适合生产业务但足以帮你建立对编辑器整体架构的直觉。核心结构是一个contenteditable容器 一个工具栏 选区保存与恢复 指令执行。div idtoolbar button>// 保存最后一个选区按钮按下时恢复 let savedRange null; document.getElementById(editor).addEventListener(keyup, saveRange); document.getElementById(editor).addEventListener(mouseup, saveRange); function saveRange() { const sel window.getSelection(); if (sel.rangeCount 0) savedRange sel.getRangeAt(0).cloneRange(); } document.getElementById(toolbar).addEventListener(mousedown, (e) { e.preventDefault(); // 防止工具栏抢走焦点 const btn e.target.closest(button); if (!btn) return; if (savedRange) { const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(savedRange); } const cmd btn.dataset.cmd; if (cmd formatBlock) { document.execCommand(formatBlock, false, btn.dataset.value); } else { document.execCommand(cmd); } document.getElementById(editor).focus(); });这个骨架能跑通加粗、斜体、标题三个功能但你会发现它在真实场景里远远不够输入法上屏时keyup不一定触发、嵌套标签不受控、粘贴没有清洗、撤销层级混乱。这些就是前面说的“自研要趟坑”的地方。5. 高频问题与排查技巧实录真实上线后问题一般集中在以下几个方面我把它们整理成速查表遇到问题先对着找。表现排查方向处理方案点击工具栏后内容没变化选区丢失或焦点不在编辑器上检查工具栏按钮是否使用了mousedown preventDefault确认操作前已恢复选区光标跳到开头或末尾双向绑定的外部值不断重置编辑器对比外部传入值与编辑器当前 HTML一致时不要调用setHTML粘贴后样式错乱粘贴内容未经清洗直接插入在paste事件中拦截走统一清洗流程图片保存后页面加载极慢图片以 base64 形式存在数据库改为上传到对象存储数据库只存 URL移动端点按钮就失焦工具栏按钮触发原生 click抢走输入焦点改为mousedown或pointerdown并阻止默认行为输入中文时出现丢字/多字编辑器的选区维护不当与输入法状态冲突升级编辑器版本或检查是否在输入法锁定时执行了 DOM 修改操作加粗后标签嵌套重叠浏览器自动生成的嵌套strong/b交叉在输出端增加归一化逻辑将非规范嵌套展开为规范结构回显时内容布局全乱保存时未清洗脏 HTML 进入数据表输出端和展示端都做白名单过滤必要时写迁移脚本清洗存量数据再补充几个线上问题的心得。第一个是超长内容的性能问题。当文章达到数万字且包含大量图片时编辑器的 DOM 操作会越来越卡顿。这类场景需要在输入事件里做防抖或节流同时尽量让模型层与渲染层分离避免每次输入都触发全量重渲染。第二个是内容统计和字数限制。这个看似简单实际上不同内容形态的字数统计口径完全不同。纯文本要统计中文、英文、数字的差异还要考虑表情符号的代理对问题。不要用简单的innerText截取最好在编辑器提供的纯文本文档上做统计。第三个是“脏数据”清理的存量问题。项目上线一段时间后数据库里可能已经存了未清洗的内容临时后再加清洗只在回显端生效并不够。建议写一个后端清洗任务定时把历史数据做一次盲清洗过滤非白名单标签和危险属性。还有一个容易被忽略但很重要的点就是编辑器在不同宿主环境下的表现不一致。比如同一个编辑器在弹窗内展示和全屏页面展示下拉菜单的计算位置不同在懒加载组件里初始化和在首屏初始化样式加载时序不同。可用性测试别只在常规页面做要在各种容器环境下分别验证。6. 结尾前后做过多个涉及富文本的项目我最大的体会是这个领域里真正稀缺的并不是“能把按钮做出来”而是能预见数据会以什么形态回来、能控制文档结构的边界、知道什么时候该收手不自己造轮子。富文本编辑器是一个看似小而深的水潭表层需求三天就能交付但数据模型和边界条件才是水面下的冰山。如果让我给一个最实用的建议那就是选型时先做一个“数据交付模拟”把你业务里最复杂的一篇典型内容拿到目标编辑器里反复编辑、保存、回显验证结构的稳定性。这一步能替你省下未来大量数据迁移和样式灾难的抢救时间。自研这条路线除非团队有充足预算去打磨数据模型否则我更推荐把精力花在业务定制上——很多问题在处理到一半时你会发现根源根本不在于编辑器本身而在于你对它的理解深度。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →