尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
富文本编辑器 rea:从技术选型到渲染性能的工程实践踩坑记录
做 Web 端内容编辑处理这个方向这么久我一直觉得 rea 是个特别容易让人困惑的名字。第一眼看到它你可能以为是什么框架的缩写其实它是我在推进某公司内容平台后台时给那个富文本编辑器项目起的内部代号Rich Editor Application。之所以想把它单独拎出来写一篇是因为这个项目踩过的坑、沉淀下来的工程化套路几乎覆盖了从技术选型、浏览器兼容、内容安全到渲染性能的全部关键环节。无论你是刚接触编辑器开发的新手还是正在为自己的业务场景定制编辑能力的前端工程师这篇文章里的思路和踩坑记录都值得直接拿来对照参考。做编辑器这件事有一个很反直觉的特点表面上你是在写一个“能输入文字的输入框”但深入进去之后会发现你真正在写的是一整套内容生产与渲染的体系。re 绝不是换个标签页那么简单。1. 项目概述从“一个名字”到一套编辑器方案1.1 “rea”到底在解决什么问题最开始接到这个需求时业务方的描述其实特别简单给后台的内容管理页面加一个能排版、能插图、能改样式的输入框。但真正把需求拆开看事情远没有这么简单。后台编辑场景里有几个刚性的诉求第一编辑体验要接近桌面文档不能是简单的 textarea第二内容保存后要能在前台稳定渲染不能在用户无感知的情况下结构错乱第三编辑过程产生的 HTML 不能直接入库必须经过清洗第四后续还要支持扩展新的功能模块比如表格、引用块、代码块。这些问题单独看都不难但组合在同一个项目里就需要一个完整的内容编辑方案而不是某个编辑控件。这也是我给项目取名 rea 的原因它不是一个简单的“输入框升级”而是一套完整的内容编辑应用。这背后隐含的一个判断是编辑器的复杂度不会因为业务“看起来简单”而降低。哪怕你只需要加粗和插入图片底层依然要处理光标、选区、粘贴、撤销、序列化这些基础能力。所以从一开始就把项目当作一个长线工程来规划而不是临时拼一个富文本组件这个心态很重要。1.2 技术选型的几个关键决策点我当时做了三组对比。第一组是自研还是集成现成内核。集成现成编辑器内核可以拿到完整的交互、快捷键、撤销重做支持但代价是样式和交互行为都要迁就内核的默认实现。自研看起来自由但光标、选区、粘贴清理这些基础能力足够耗尽所有时间。我最后选了一条中间路线采用以模块化著称的成熟内核作为底座在此基础上搭建业务侧的能力模块。这样既保住稳定性也保留扩展空间。第二组是数据模型怎么设计。很多编辑器内核给的是 DOM 环境直接在可编辑区域内操作节点另一些内核则会把内容映射成一份独立的 JSON 数据模型再由模型驱动视图渲染。我在 rea 里选了后者。原因很简单数据模型能脱离 DOM 单独存在序列化、校验、协同处理都有了抓手而直接操作 DOM后续要加协同、要扩散到多端渲染几乎都要推翻重来。第三组是内容清洗放的时机。清洗 HTML 到底是保存时做还是编辑时做在实际项目里我的结论是编辑时就要拦截保存时再做二次校验。原因放在后面安全章节具体说这里先给结论。我把这几个决策点整理成一张简单的对比表方便对照决策点方案A方案B我的选择核心理由底座来源完全自研集成成熟内核集成成熟内核稳定性和交付节奏优先内容模型直接操作 DOM独立数据模型独立数据模型便于序列化、校验与后续扩展清洗时机保存时统一清洗编辑时拦截保存时校验编辑时拦截保存时校验早发现早处理避免脏数据扩散渲染方式服务端拼接 HTML标准化结构前端渲染标准化结构前端渲染控制渲染入口保证一致性这个表格看起来虽然简单但每一个选择背后都有具体的业务约束。比如集成成熟内核这件事刚开始团队里也有不同意见觉得内容也不复杂手写一个编辑器就行。但真到做撤销重做、做复杂选区处理的时候手写方案要补的课就太多了光是浏览器差异就能磨掉好几天。又比如内容模型选独立数据模型前期确实要多写不少序列化和反序列化的代码但后期加协同、加多端渲染时几乎没有返工。这算是项目里第一个重要判断也是后续所有设计的地基。2. 核心细节编辑器底层机制与浏览器差异2.1 内容模型为什么选择富文本结构而非纯静态 HTML做编辑器的人应该都听过一句话不要把 contenteditable 当输入框用。它其实暴露了浏览器底层的 DOM 编辑能力这套能力本身没有任何内核级别的数据约定。你去用 contenteditable本质上就是在跟浏览器对 Node 的增删改打交道。rea 为什么选择富文本结构、而不是直接存一段静态 HTML我做个生活化的类比。静态 HTML 就像一张打印好的报纸版式固定、改一个字都得重印富文本结构更像一份可编辑的文稿它把内容拆成段落、行内元素、块级元素每一项都可以被程序单独访问和修改。编辑器的撤销、局部渲染、协同编辑依赖的都是这种可定位、可修改的结构。直接存 HTML 虽然看着省事但后续任何一次结构调整都可能需要写正则或者字符串截断来适配那是另一个深渊。在 rea 的数据模型里每一段内容会被拆成块block和行内元素inline element两级。块级记录的是结构信息比如段落、标题、列表、引用行内记录的是样式信息比如加粗、斜体、链接。保存时模型序列化成 JSON 入库渲染时JSON 再映射成前台页面结构。这个过程中还顺带解决了多端一致性问题因为渲染入口是统一的数据模型不存在编辑器里看到的和前台展示的不一致这种问题。这个设计也会直接影响复制粘贴和导入导出。如果内容模型是结构化的那么从外部导入 Word 或 Markdown 内容时只需要做一次外部格式转内部模型的适配如果内容模型是一段固定 HTML那么每接一个新的内容源就要写一套 HTML 解析清洗逻辑工作量大得多。rea 后续接入批量导入工具时这个决策省下的时间非常可观。2.2 输入与选区最容易被忽略的三大坑编辑器开发的绝大多数疑难杂症最后都追到了输入和选区两个底层问题上。这里我记录三个高频坑。第一个坑是中文输入法组合输入。中文用户输入你好的时候输入法会先产生一段组合态文本再提交最终结果。编辑器如果监听的是传统的 keydown/input 事件很容易在组合过程中就触发内容更新造成拼音字母上屏、候选词错乱。rea 的做法是监听输入法组合事件在组合开始和组合进行中暂停一切内容自动修正逻辑等组合结束后再把最终内容纳入模型。这个细节不处理中文用户基本没法用。第二个坑是光标位置丢失。很多编辑器操作完成后会重新设置内容比如插入一张图片后需要把光标移动到图片后面的位置。如果只是简单地调用 innerHTML 或者批量替换节点浏览器会把光标重置到容器开头用户每操作一次就要重新点回编辑位置。正确的做法是操作之前记录下当前的光标位置操作之后显式恢复。具体操作里我会把选区的起点和终点记录下来基于节点路径保存等 DOM 更新完成后再寻址回去。这个逻辑听起来简单但是在嵌套 blockquote、列表等结构时寻址路径很容易算错建议单独抽成一个工具模块多写测试用例。第三个坑是粘贴内容格式混乱。从网页复制内容时剪切板里既有纯文本也有带样式 HTML很多编辑器默认行为会把大量内联样式一并塞进来导致内容里出现大量垃圾标签和私有样式。rea 在粘贴事件里做了拦截先读剪切板里的 HTML做一次白名单过滤把无效样式、危险标签、重复嵌套全部清掉再交给数据模型。这里不能只依赖默认的粘贴行为因为各浏览器对粘贴内容的处理逻辑差异巨大。这三个坑里最隐蔽又最耗时的还是选区恢复问题。调试这类问题时我强烈建议把选择状态打印到控制台看 startContainer、endContainer、offset 这几个关键数值单纯靠肉眼观察页面效果很难定位。3. 实操过程从零搭建 rea 核心模块3.1 第一步基础编辑器的骨架实现我先给出一个最精简的编辑器骨架不考虑插件、不考虑数据模型序列化先把能输入、能获取内容这件事走通class ReaEditor { constructor(container, options {}) { this.container container; this.options options; this.isComposing false; this.init(); } init() { this.editable document.createElement(div); this.editable.contentEditable true; this.editable.className rea-editable; this.container.appendChild(this.editable); this.editable.addEventListener(compositionstart, () { this.isComposing true; }); this.editable.addEventListener(compositionend, () { this.isComposing false; this.emit(change, this.getContent()); }); this.editable.addEventListener(input, () { if (!this.isComposing) { this.emit(change, this.getContent()); } }); } getContent() { return { html: this.editable.innerHTML, text: this.editable.innerText }; } emit(eventName, payload) { const handlers this.listeners this.listeners[eventName]; if (handlers) { handlers.forEach((handler) handler(payload)); } } on(eventName, handler) { this.listeners this.listeners || {}; this.listeners[eventName] this.listeners[eventName] || []; this.listeners[eventName].push(handler); } }这段代码虽然短但已经把两个关键工程点立起来了一是编辑区域从普通输入框升级成了可编辑容器后续所有内容操作都有了明确的作用域二是事件体系先搭好后续插件和业务模块都是基于事件通知来联动。你在实际业务里可以直接在这个骨架上叠功能比如在 input 事件里加上防抖、把 getContent 的返回值交给后面的清洗模块。这个骨架不太适合直接生产使用最明显的问题是 getContent 直接返回 innerHTML而 innerHTML 里可能混杂着危险标签、脏样式和来自粘贴内容的冗余节点这些都必须交给清洗流程处理但作为第一步把输入链路和事件链路跑通验证浏览器在目标环境下的基本行为这个目标已经达到了。接下来所有的扩展都是在这个最小可运行版本上一步步长出来的。3.2 第二步模块化与插件机制设计rea 的项目里插件机制是整个扩展能力的核心。编辑器的功能模块非常杂工具栏、图片上传、表格、代码块、字数统计每一个都想独立开发、独立测试如果全部塞进一个编辑器类里最后一定会变成千行级别的巨型类。插件机制的思路是编辑器核心只负责基础编辑能力、数据模型和事件分发其余功能以插件的形式注册进来。每个插件有独立的初始化、销毁方法可以订阅编辑器的生命周期事件也可以调用编辑器暴露的命令接口。这里给一个简化的接口示例class ReaPlugin { constructor(editor, options {}) { this.editor editor; this.options options; this.name options.name || anonymous; } init() { // 子类实现注册工具栏按钮、监听事件等 } destroy() { // 子类实现解绑事件、清理DOM等 } }编辑器注册插件时只需要维护一个插件列表在初始化阶段逐个调用 init在销毁阶段逐个调用 destroy。这个模式在 rea 里跑得很稳。比如图片上传插件它的 init 里注册上传入口、显示上传进度、插入图片标记字数统计插件它订阅 change 事件每次内容变化后统计文本长度更新到状态栏。核心编辑器完全不需要知道这些细节。这里有一个非常重要的认知插件机制的核心并不是把代码分文件而是把能力边界切开。如果只是按照功能拆了一堆文件但模块之间仍然互相调用、互相修改内部状态那本质上还是一坨耦合代码只不过披了一层模块化的外衣。在 rea 的实际开发里我要求插件只能通过编辑器提供的公开接口来交互禁止插件之间直接引用彼此的内部实现。这个约束让每个插件都能独立测试、独立替换也为后面多人协作分工提供了清晰的操作边界。这个设计带来的好处在项目中期功能模块变多的时候体现得特别明显。3.3 第三步性能优化的一线操作编辑器与普通页面的一个性能差异在于每一次键盘输入都可能是全量内容的结构性更新而页面内容本身又可能很大。rea 在性能上主要做了三件事。第一件事是撤销/重做快照的节流。如果每次输入都完整保存一份内容快照内容稍长一点内存就爆了。rea 的做法是维护一个快照栈每次变更后不立刻入栈而是经过 300 毫秒的延迟合并。连续输入的过程只更新当前快照停顿后才生成新的历史快照。这个策略让内存占用直接降了一个量级。第二件事是渲染路径上做局部更新。当光标所在段落发生格式变化时不需要把整个文档内容重新映射一次。rea 会优先尝试在当前块内做最小范围的节点更新只有结构级变化比如插入表格、切换块类型才触发全量重渲染。这个判断逻辑要写得很保守宁可多渲染一点也不能出现视觉不一致。第三件事是监控长任务。我用 PerformanceObserver 监控 longtask把这些时长超过 50 毫秒的任务打点上报。比如某次上线后后台观察到不少用户的长任务集中在打开文档的瞬间后来排查发现是初始渲染时同步执行了内容解析和高亮代码的语法分析。改成异步分片解析后长任务数量明显下降。这里尤其想说性能优化不要靠猜先上监控用数据说话。我自己踩过很多次感觉这里慢的坑最后发现真正的瓶颈根本不在直觉指向的那个模块。有了数据之后优化才有优先级也才能验证优化到底有没有效果。4. 内容安全与渲染管线容易被低估的环节4.1 XSS 过滤与白名单策略富文本编辑器的内容天然是用户可控的 HTML如果这条输入链路不设防恶意脚本就能随着文章内容进入站点变成存储型风险。这是 rea 项目里最不敢省的一环。我的做法是白名单过滤为主黑名单拦截为辅。白名单指明确允许哪些标签、哪些属性除此之外全部剥离。比如典型的段落、行内格式、链接、图片这些会被保留但 script、iframe、object、embed 这些标签直接丢弃事件属性如 onclick、onerror、onload 一律剔除。下面是一个极简的清洗函数示例它只保留白名单内的标签和属性const ALLOWED_TAGS new Set([ p, br, strong, em, a, img, ul, ol, li, blockquote, code, pre, h1, h2, h3 ]); const ALLOWED_ATTRS { a: [href, title, target], img: [src, alt, width, height] }; function sanitize(node) { const walker document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT); const toRemove []; while (walker.nextNode()) { const el walker.currentNode; if (!ALLOWED_TAGS.has(el.tagName.toLowerCase())) { toRemove.push(el); continue; } const allowed ALLOWED_ATTRS[el.tagName.toLowerCase()] || []; Array.from(el.attributes).forEach((attr) { if (!allowed.includes(attr.name)) { el.removeAttribute(attr.name); } }); } toRemove.forEach((el) el.replaceWith(...el.childNodes)); return node; }清洗逻辑需要同时跑在两个阶段编辑时粘贴内容进入编辑器之前先过一遍保存时序列化后的 HTML 再整体过一遍。编辑时清洗的意义在于让作者尽早看到干净的内容而不是保存后才提示风险保存时清洗的意义在于兜底毕竟编辑器内可能被其他代码注入了脏内容。这个环节我还有一个很深的体会白名单过滤不能只在前端做。编辑器核心代码在浏览器里跑本身就暴露给用户任何人都可以绕过前端逻辑直接向接口提交内容。所以最保险的做法是后端也维护一套同样的白名单策略入库前再校验一次。前后端清洗规则不一致时以后端为准。链接属性也建议做协议白名单href 只允许 http、https、mailto 等常规协议javascript: 这类协议必须拒绝。很多攻击向量藏在这里。4.2 渲染性能的监控与调优rea 里有一个独立的渲染性能观察模块它的职责不是做优化而是持续观察。下面这段代码展示的是如何收集长任务数据function observePerformance() { if (typeof PerformanceObserver undefined) return; const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.entryType longtask) { reportMetric(longtask, { duration: entry.duration, startTime: entry.startTime }); } } }); observer.observe({ entryTypes: [longtask] }); }性能观察的结果要落到一个可决策的指标上。在 rea 项目里我主要盯三个指标输入响应延迟、初始渲染耗时、长任务频率。输入响应延迟用 Performance 的 mark 和 measure 来记录初始渲染耗时在内容从 JSON 到 DOM 的转换完成时打点长任务频率用来发现那些偶发但伤害体感的卡顿。调优的顺序也有讲究。先把长任务数量压下来再谈减少渲染总量。因为长任务是明确让用户感知到卡顿的元凶一个 200 毫秒的长任务就是一次肉眼可见的停顿。很多时候我们花大力气优化了渲染时间但真正让用户难受的是那几次长任务。另外不要忘记降级方案。如果某次渲染因为内容过于复杂无法在合理时间内完成至少要保证光标和输入不被阻塞。我会把文档解析拆成多个分片任务在浏览器空闲时继续处理。这个策略不改变总耗时但能极大改善用户输入的流畅度。5. 常见问题与排查技巧实录5.1 问题速查表这部分是把 rea 开发过程中遇到的典型问题整理成速查表方便复现时直接对照症状大概率原因处理方法中文拼音字母被当成内容上屏未处理输入法组合事件监听 compositionstart / compositionend组合期间暂停内容修正插入图片后光标跳回开头操作后未恢复选区操作前记录选区路径DOM更新后按节点路径恢复粘贴内容带满屏内联样式未拦截并清洗粘贴事件在 paste 事件中读取剪切板HTML走白名单过滤后再插入文档内容较长时输入卡顿每次变更全量快照或全量渲染快照延迟合并块级变化局部更新结构级变化才全量渲染保存后的内容在前台样式错乱编辑态与渲染态使用的结构不一致统一以数据模型作为渲染入口前端渲染与服务端结构对齐图片上传后内容立即出现但刷新丢失插入的是 Blob 临时地址上传完成后替换为持久化地址并更新数据模型代码块内按 Tab 无法缩进默认 Tab 行为导致光标跳出拦截键盘事件在代码块内插入两个空格或制表符并保持光标位置撤销两次后内容状态异常快照栈未合并连续输入做延迟合并每次入栈记录差异类型避免重复快照每一行背后都对应着一次真实的排查经历。比如粘贴内联样式那一条我在一次联调里看到用户从文档站点复制了一段内容保存后整篇文章的样式全乱最后追到是 paste 事件没有拦截浏览器默认把源站点的 class 和内联 style 全带进来了。又比如光标跳回开头那条看起来是个小问题但用户每插入一张图片就要手动点回原位体验极差甚至有人因为这个功能直接放弃了图片插入。这类问题往往比功能缺失更隐蔽因为它们要等到真实用户操作时才会暴露。5.2 几条独家的实操心得最后分享几条我在 rea 项目中沉淀出来的实操心得这些内容不太容易在官方文档里看到。第一不要在业务代码里直接依赖 innerHTML 做内容判断。编辑器的内容判断要基于数据模型而不是基于序列化后的 HTML 字符串。否则每次内容一变化你就要做一次 DOM 解析性能差且语义容易错。基于数据模型做判断比如当前光标是否在一个列表里、这一行是否加粗都有结构化路径可以查。第二保存记录尽量存 JSON 结构而非 HTML。HTML 是渲染层的产物JSON 才是内容层的数据。存 JSON 之后以后要做版本对比、做内容迁移、做多端渲染都会从容很多。HTML 可以在渲染时动态生成这样即使未来前台页面重构也不会影响历史内容的读取。第三给编辑器的脏值检测做渐进式快照。我的方案是每隔一段时间自动保存一份不带历史记录的轻量快照这样即使用户撤销到底或者误操作导致大范围内容丢失也能从最近的轻量快照恢复不会出现改了半天全没了的悲剧。这个功能上线后客服反馈的问题数量明显减少。第四调试选区问题时把选择状态的三个关键值打印出来startContainer、endContainer、offset。肉眼盯着页面看不出名堂但一旦你把这几个值打到控制台很多光标问题原因就一目了然了。我一直保留这个调试习惯它帮我省下了大量无效排查时间。编辑器这个方向踩坑是常态但把这些坑记录下来、沉淀成工具和机制后面的人就会少走很多弯路。这也是我写这篇文章的初衷希望大家在动手做类似项目时能少加几天班。
RELATED

相关推荐

生产级世界国家GeoJSON数据包:WGS84+ISO标准化+拓扑修复

生产级世界国家GeoJSON数据包:WGS84+ISO标准化+拓扑修复

简介:本资源是一套覆盖全球所有国家及地区的标准GeoJSON地理边界数据集,面向GIS开发人员、数据可视化工程师、前端地图应用开发者及地理信息专业学习者,用于快速集成高精度行政区域轮廓至Web地图、空间分析系统或教学演示项目。压缩包共2000个…

📅 2026/10/21 13:06:49
个人技能管理系统:盘点、组合与刻意练习指南

个人技能管理系统:盘点、组合与刻意练习指南

这些年我越来越觉得,"skills"这个词看起来简单,其实最容易被人低估。很多人把它理解成"我会点什么"的静态列表,但实际上,技能是一个需要持续维护、迭代、校准的动态系统——就像一套需要保养的设备&#xff0…

📅 2026/10/21 13:06:49
中国智能驾驶出海:从算法方案到全球主理人的角色跃迁

中国智能驾驶出海:从算法方案到全球主理人的角色跃迁

这两年汽车圈里最值得注意的变化,不是某款新车又拿下了多少订单,而是中国智能驾驶方案开始频繁出现在海外车型的配置单上。过去很长一段时间,国内讨论的基调是“追平海外”,看别人定义硬件规格、制定软件接口,我们跟着…

📅 2026/10/21 13:06:49
MORE NEWS

更多资讯

📰

Flutter for OpenHarmony表单验证实战:二手置换App开发踩坑与模块化设计

在把某二手物品置换App从原型推到可用版本的过程中,我反复打磨最多的一块,不是图片上传、不是消息推送,反而是很多人觉得“随便写写就行”的表单验证。Flutter for OpenHarmony这套组合本身就有一些环境上的特殊问题,再加上二手置…

📰

告警风暴治理实战:动态基线如何替代固定阈值,实现自适应告警

先聊聊 REA 这个代号。它是我在某公司中间件团队做的一个内部项目——Resource-Adaptive Alerting,资源自适应告警系统。说实话,最初立项时我就没打算做个大而全的新监控平台,目标只有一个:把每天几千条的告警通知压缩到几十条&am…

📰

MFC源码解析:揭开Windows GUI消息循环与文档视图架构真相

简介:本资源是《精通MFC(光盘)》配套源代码包,面向Windows桌面开发初学者及C中级程序员,系统解决MFC框架下窗口编程、消息机制、文档/视图架构、GDI/GDI绘图、多线程、DLL与COM组件开发等核心难点。压缩包含1114个文件…

📰

Docker部署Nginx实现WebSocket SSL反向代理实战

做后台实时推送、在线聊天、协同编辑这类功能时,WebSocket基本是绕不开的方案。可一旦要把服务正式上线,问题就来了:前端要用wss://加密连接,WebSocket服务本身又不能随便暴露公网端口,这时候用Docker部署Nginx做WebSo…

📰

虚拟机安装Win11全攻略:从环境准备到性能优化与迁移

1. 为什么选择虚拟机跑Win11而不是物理机1.1 虚拟机方案的核心优势很多人第一次接触Win11,最纠结的就是要不要直接装到物理机上。我的建议很明确:如果你手头只有一台主力电脑,先别急着格盘。虚拟机方案最大的好处就是“隔离”和“可回滚”——…

📰

零代码创建671B大模型联网应用:从配置到发布全流程

不知道你在实际工作中遇到过没有:明明拿到了一个很好的大模型接入入口,业务人员也很想把“聊天机器人”变成“能查到实时信息、能对着自己的文档回答问题”的实用工具,可一提到要写代码、要部署服务、要管理后端,项目就搁置了。我…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬