做 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。肉眼盯着页面看不出名堂,但一旦你把这几个值打到控制台,很多光标问题原因就一目了然了。我一直保留这个调试习惯,它帮我省下了大量无效排查时间。
编辑器这个方向,踩坑是常态,但把这些坑记录下来、沉淀成工具和机制,后面的人就会少走很多弯路。这也是我写这篇文章的初衷,希望大家在动手做类似项目时,能少加几天班。