自研富文本编辑器:基于contenteditable与浏览器原生API的实践指南
发布时间:2026/9/15 16:55:39来源:尧图网络
年初接了一个后台内容管理系统的活需求方的原话是“就是要一个 editor能加粗、能插图片、能调字号最好还能跟我们的权限体系联动一下。”我第一反应是引入一个成熟的富文本编辑器结果一看项目里的老代码样式隔离、权限校验、上传接口全是自建的硬套第三方库反而要写一堆适配层。最后决定还是自己写一个轻量的 editor 模块。那段时间我几乎把 contenteditable、Selection、Range、document.execCommand 从头到尾摸了一遍踩了不少坑也总结出一套比较靠谱的实现思路。这篇文章就把我写“editor”这个模块时沉淀下来的方案和教训完整展开。内容包括编辑器的方案选型、contenteditable 的底层机制、如何用浏览器原生 API 实现加粗/斜体/列表等格式化操作、光标与选区的保存恢复、粘贴清洗、撤销重做以及一堆我在真机环境里踩过的怪问题。适合需要自研或深度定制编辑器、又不想盲目套开源框架的前端开发同学参考。1. 先想清楚你需要的到底是哪一种“editor”1.1 editor 的三个常见形态“editor”在项目里最容易被当成一个笼统的模块名但实际需求通常分三种纯文本编辑器本质是 textarea连换行和缩进都要自己处理适合做日志编辑、JSON 配置编辑、Markdown 源码编辑。富文本编辑器用户在界面上看到加粗、标题、列表等效果背后存的是带 HTML 标签的内容。这类编辑器才是我们日常说的“editor”。代码编辑器语法高亮、自动缩进、括号补全通常基于 contenteditable 做底子或者更激进地直接重绘渲染层。我这次要做的显然是第二种。而一旦确认是富文本编辑器就绕不开一个核心问题浏览器只给了一个 contenteditable 属性剩下的格式化、选区控制、内容清洗必须自己处理。很多同事第一次接触 contenteditable 时都会觉得“这不就是一个可以编辑的 div 吗”实际上它的复杂性远超想象。1.2 为什么可以自己写而不是直接上第三方库很多人会质疑Quill、Tiptap、ProseMirror 不是现成的吗改改配置不就能用我在项目里也认真对比过最后决定自研的理由很现实维度第三方库自研轻量 editor快速接入快文档齐全需要一定的开发期深度定制依赖插件体系核心复杂定制成本高每一行逻辑都可控样式隔离自带样式表容易和项目全局 CSS 打架完全按项目需求写与后端联动通常要适配自定义上传、权限逻辑直接在内部调业务接口学习成本要学库的概念模型只需要掌握浏览器原生编辑 API长期维护依赖社区更新老版本可能被放弃自己维护量不大就可控第三个理由最关键。老项目的全局 CSS 已经写得很乱第三方富文本编辑器自带的主题样式往往会被页面覆盖改起来比想象中麻烦。而内容管理系统对编辑器的需求其实很收敛加粗、斜体、标题、列表、链接、图片最多加个代码块和表格完全不需要 Quill 那种庞大的模块体系。如果你也有类似的项目背景我的建议是不需要畏惧自研但也不要冲动。如果你的需求有几十种复杂格式、嵌套引用、多人实时协作那乖乖用现成框架。如果只是做内部系统的富文本编辑自研一个 500 行以内的 editor 核心完全可行而且后续维护起来非常清爽。2. 编辑器与编辑原理contenteditable、Selection 与 Range 的基础认知2.1 contenteditable 到底给开发者留了什么一个可编辑的 div加上 contenteditabletrue浏览器就自动帮你接管了键盘输入、光标移动、文本选择、剪贴板粘贴这些最底层的行为。开发者不需要处理每一个 keydown 事件去决定把字符插到哪个位置这是浏览器做好的部分。但代价是你拿到的输出是浏览器自己拼出来的 HTML 片段。不同浏览器对回车、加粗、粘贴的处理方式不一样这段 HTML 的“脏乱差”程度完全由用户的习惯决定。有人从 Word 里粘贴能带进来几十层嵌套标签有人狂按回车产生一堆空的divbr/div。用一个不太准确的类比contenteditable 像是开发商交给你的毛坯房水电线路都预留了但走线不规范。你自己住进去今天这里跳闸明天那里漏水全靠事后补救。所以使用 contenteditable 的关键不是“怎么用”而是“怎么约束它”。2.2 一切格式化操作都围绕 Selection 与 Range想要理解富文本编辑器就必须理解 Selection 和 Range。这是浏览器提供的标准 API也是你绕过 execCommand 手动控制内容的唯一入口。Selection 表示用户当前选中的区域一个页面可以有多个 Selection但通常只有一个。通过window.getSelection()获取它真正的核心信息藏在getRangeAt(0)返回的 Range 对象里。Range 描述了一段连续的文档片段它由两个端点组成startContainer / startOffset起点所在的节点以及在节点内的偏移量。endContainer / endOffset终点所在的节点以及在节点内的偏移量。如果 startContainer endContainer 且 startOffset endOffset这个 Range 就是“折叠”的也就是用户只是放了一个光标没有选中任何内容。对编辑器来说Range 是定位的最小单位。举个例子用户选中一段文字想加背景色高亮你可以这样操作function highlightSelection(color) { const sel window.getSelection(); if (!sel.rangeCount) return; const range sel.getRangeAt(0); if (range.collapsed) return; // 取出选区内容 const fragment range.extractContents(); // 创建一个高亮节点 const highlight document.createElement(span); highlight.style.backgroundColor color; highlight.appendChild(fragment); // 把高亮节点插回选区位置 range.insertNode(highlight); }这个原理很简单提取内容、包一层节点、放回去。但实际项目里如果选区跨越了多个文本节点或者选中内容里已经有嵌套样式处理起来就要小心不能直接粗暴地extractContents否则可能会丢失内部结构。后面我会专门讲踩坑。2.3 execCommand 还没死以及它的替代路径提到富文本编辑器绕不开document.execCommand。这是一个老接口官方已经标记为废弃但直到现在主流浏览器还在实现它。日常的加粗、斜体、下划线、插入无序列表、创建链接都可以用一行命令搞定document.execCommand(bold);执行命令后浏览器会自动处理选区范围内的样式标签省去大量手动 DOM 操作的麻烦。对于轻量级编辑器来说这依然是最高效的做法。但我强烈建议你先设置一个隐藏的坑位开关document.execCommand(styleWithCSS, false, true);这个参数决定了加粗之后生成的是b还是stylefont-weight: bold。不同浏览器的默认行为不一致如果团队内部对内容格式有要求一定要统一。execCommand的替代路径是手动操作 Range也就是前面highlightSelection那种思路。手动操作的好处是可以完全掌控生成的 HTML坏处是代码量大、边界情况多。我的经验是简单场景用execCommand复杂场景高亮、批注、自定义结构化标签走手动 Range两者结合最舒服。还有一个容易被忽略的 APIHTMLInputElement 和 HTMLTextAreaElement 上的setRangeText。它只能替换当前选区的文本内容不能插入富文本节点所以更适用于纯文本编辑器场景。3. 手写一个最小富文本 editor从 HTML 骨架到可运行代码3.1 第一步骨架与基础样式一个最简编辑器结构上只需要三部分工具栏、可编辑区、一个用于触发按钮事件的容器。div classeditor-wrap div classeditor-toolbar button typebutton>.editor-content:empty::before { content: attr(placeholder); color: #aaa; }注意:empty只有在元素完全没有任何子节点时才生效。如果用户在里面输入了一个空格或者按了一个回车占位文字就会消失所以判断是否为空不能只依赖 CSS后面我会再讲逻辑层怎么处理。3.2 第二步工具栏与命令绑定工具栏按钮的交互逻辑核心是用 mousedown 事件保持编辑区焦点const editor document.getElementById(editor); const toolbar document.querySelector(.editor-toolbar); toolbar.addEventListener(mousedown, (e) { e.preventDefault(); }); toolbar.addEventListener(click, (e) { const btn e.target.closest(button[data-cmd]); if (!btn) return; const cmd btn.dataset.cmd; const value btn.dataset.value || null; document.execCommand(cmd, false, value); editor.focus(); });mousedown里调用e.preventDefault()的作用是阻止按钮按下时夺走编辑区的焦点。如果不这样做用户选中一段文字后去点加粗按钮编辑器区域会先失焦选区直接消失命令执行后什么效果都没有。这是新手最容易踩的第一个坑。formatBlock命令比较特殊它需要一个标签名参数比如h1、blockquote、p。这段代码里我传了一个>editor.addEventListener(paste, (e) { e.preventDefault(); const plainText e.clipboardData.getData(text/plain); const htmlText e.clipboardData.getData(text/html); if (!plainText !htmlText) return; if (htmlText confirm(检测到富文本内容是否保留原有格式)) { const cleanedHTML cleanHTML(htmlText); document.execCommand(insertHTML, false, cleanedHTML); } else { document.execCommand(insertText, false, plainText); } });cleanHTML是一个内容清洗函数最简单的方案是用DOMParser解析出 DOM再递归遍历只保留白名单标签和允许的属性function cleanHTML(html) { const doc new DOMParser().parseFromString(html, text/html); const allowedTags [P, BR, STRONG, EM, U, A, UL, OL, LI, BLOCKQUOTE, H1, H2, H3]; function walk(node) { const children Array.from(node.children || []); for (const child of children) { if (!allowedTags.includes(child.tagName)) { // 将不允许的标签替换为其子节点 child.replaceWith(...child.childNodes); continue; } if (child.tagName A) { const href child.getAttribute(href); if (!href || !href.startsWith(http)) { child.removeAttribute(href); } } child.removeAttribute(style); child.removeAttribute(class); walk(child); } } walk(doc.body); return doc.body.innerHTML; }这段代码的思路是先解析再过滤。不允许的标签直接拆掉外衣只保留里面的内容链接只允许 http 开头的地址防止javascript:协议注入所有style和class属性一律清空避免样式污染。哪怕这会损失一些视觉层次但换来了内容的安全和统一这笔账非常划算。3.4 第四步内容读取与回显编辑器内容的数据结构本质上还是 HTML 字符串。通过监听 input 事件把editor.innerHTML实时同步到业务侧editor.addEventListener(input, () { const html editor.innerHTML; const text editor.textContent.trim(); // 更新字数统计或者同步到 Vue/React 的数据模型 updateCount(text.length); saveToServer(html); });回显时不能直接editor.innerHTML html因为通过后端存出来的内容可能包含用户恶意提交的脚本。在输入侧我们的内容清洗发生在粘贴环节但这还不够。保险起见回显时要用一个更严格的白名单过滤去掉所有的script、iframe、on*事件属性以及所有非白名单标签。对内部系统来说最保险的方案是把这段清洗逻辑复用一遍而不是依赖后端来兜底。4. 决定编辑器好不好用的细节光标、撤销栈与内容清洗4.1 光标与选区的保存、恢复在编辑器里做任何 DOM 操作之前先把选区保存下来操作完成之后再恢复这是所有复杂功能的基础。比如高亮、插入表情、给选中文字加链接都会改动 DOM而 DOM 一变原来的 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 range document.createRange(); range.setStart(saved.startContainer, saved.startOffset); range.setEnd(saved.endContainer, saved.endOffset); const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }但这个方法有一个隐患如果保存选区后前一个节点被删除或者整段内容被 innerHTML 重写了节点引用就会失效。更稳妥的方案是用路径定位从编辑区根节点开始记录该节点在所有兄弟节点中的序号操作完再沿路径找回目标节点。这在项目里是一个独立的模块实现起来也不复杂核心逻辑是递归计算父节点 indexfunction getPath(container, root) { const path []; let node container; while (node node ! root) { path.unshift(Array.from(node.parentNode.childNodes).indexOf(node)); node node.parentNode; } return path; } function getNodeFromPath(path, root) { let node root; for (const index of path) { node node.childNodes[index]; if (!node) return null; } return node; }我在项目里把这两种方式结合轻微 DOM 变更用引用保存涉及 innerHTML 重写时用路径定位。这个细节很值得做因为浏览器对 Range 的容错度很低一旦引用失效恢复的时候就只能退回到编辑器末尾体验非常差。4.2 撤销重做一个不落地的方案contenteditable是有原生撤销行为的但它只作用于浏览器自己产生的 DOM 变化。执行execCommand的时候浏览器通常会把它归入撤销栈但如果你用 JS 手动修改innerHTML浏览器压根不会记录。这导致撤销行为严重不一致。我的做法是自己维护一个历史快照栈以editor.innerHTML为单位记录状态。let history []; let historyIndex -1; let saveTimer null; editor.addEventListener(input, () { clearTimeout(saveTimer); saveTimer setTimeout(() { // 截断未来的分支然后入栈 history history.slice(0, historyIndex 1); history.push(editor.innerHTML); historyIndex history.length - 1; // 限制历史长度防止内存爆掉 if (history.length 100) { history.shift(); historyIndex--; } }, 300); }); function undo() { if (historyIndex 0) return; historyIndex--; restoreHTML(history[historyIndex]); } function redo() { if (historyIndex history.length - 1) return; historyIndex; restoreHTML(history[historyIndex]); } function restoreHTML(html) { const saved saveSelection(); editor.innerHTML html; restoreSelection(saved); }为什么用 300ms 防抖因为 input 事件在输入中文、粘贴内容、拖拽图片时会高频触发每次保存 innerHTML 都是一次序列化加字符串存储非常占用内存。300ms 防抖能显著减少无效快照还顺带解决了 IME 输入法组合期快照乱跳的问题。这个方案虽然粗糙但对于普通业务编辑场景已经够用。最大的问题是撤销只能恢复到快照粒度不能做到像原生撤销那样的逐字符操作。要做得更精细就得把每一步 DOM 变化记录成 op 序列复杂度会翻几倍。我的建议是先做快照版好用优先别一上来就追求完美。4.3 空内容判断与交互体验的细节富文本编辑器判断是否为空不能直接看innerHTML 。用户输入一个空格、一个回车或者粘贴了一段只有样式的空白内容innerHTML都会变成非空字符串。正确做法是function isEmptyEditor(editor) { const text editor.textContent.trim(); const images editor.querySelectorAll(img).length; const videos editor.querySelectorAll(video, iframe).length; return text.length 0 images 0 videos 0; }其次字数统计也不能只算textContent.length。在实际项目中用户插入了一张图片字数统计里可能希望记 1 个字或者不计入字数这取决于产品需求。更常见的情况是粘贴了带大量空格的 Word 内容直接把 textContent 长度算进总数字字数会虚高。我习惯在统计前先把连续空白字符压缩再按实际可见文本计数。4.4 移动端与中文输入的注意点移动端浏览器对 contenteditable 的支持是能用的但问题也很多iOS Safari 的软键盘在光标移动时可能直接顶乱布局需要配合visualViewportAPI 做适配。Android 上部分浏览器对 Range 的 offset 计算有差异。中英文输入法的组合事件会在 input 事件里反复触发。我之前在开发时就遇到过在中文输入法下打“你好”组成过程中input事件会触发两三次历史栈里可能会出现拼音的半成品快照导致撤销后内容变成拼音字母。解决办法是监听 composition 事件let composing false; editor.addEventListener(compositionstart, () { composing true; }); editor.addEventListener(compositionend, () { composing false; // compositionend 之后可能还会触发一次 input此时再记录快照 }); editor.addEventListener(input, () { if (composing) return; // 在这里做快照或同步 });这个细节不处理好中文用户的编辑体验就会非常糟糕。5. 高频踩坑与排查实录5.1 一张表解决大部分问题我把实际开发中遇到的高频问题和排查思路整理成了一张速查表建议收藏备用现象可能原因排查思路与解决方案点击加粗后光标丢失内容跑到末尾按钮夺走了编辑区焦点或 execCommand 破坏了选区工具栏 mousedown 后 preventDefault操作前保存选区操作后恢复选区粘贴带样式内容页面样式被污染未拦截 paste 事件富文本原样插入拦截 paste纯文本与富文本分场景处理白名单过滤编辑长文后越来越卡每次 input 都同步 innerHTML 快照给快照加防抖限制历史栈长度超过 100 条就丢弃早期记录回车产生的标签不统一后端解析不一致不同浏览器对 Enter 的处理不同键监听 Enter统一插入br或div在团队内部形成约定中文输入法下撤销后内容变拼音组合事件触发 input 导致半成品入栈监听 compositionstart/compositionend组合期间跳过快照编辑器里的图片上传失败但没提示粘贴本地图片走的是 base64没经过上传接口拦截 paste 和 drop检测到图片文件后走上传接口替换为线上地址从 Word 粘贴内容后出现大量空样式Word 生成的 HTML 里含有大量无效标签和样式清洗函数里去除 style 属性只保留白名单标签必要时只保留纯文本移动端内容显示靠上光标闪烁位置不对键盘弹出导致布局高度变化使用 visualViewport 监听视口变化动态调整编辑区滚动位置5.2 三个典型的实战复盘第一个是“加粗按钮把光标弄没了”的问题。现象是用户选了一大段文字点工具栏的加粗按钮文字确实加粗了但选区和光标不见了用户必须再点一下内容区才能继续输入。排查之后发现问题不只是焦点丢失还有execCommand在部分浏览器中执行完成后会重置选区。解决方案就是在执行命令前保存选区、执行后恢复选区并且只恢复光标位置、不恢复选中状态这样用户的输入体验最接近原生编辑器。第二个是“从 Word 粘贴的内容把页面搞崩了”的问题。用户从一份带目录、带表格、带各种批注的 Word 文档里复制了一段内容粘贴进来编辑区的 HTML 结构直接乱了甚至出现了只有 Word 才有的o:p标签。我当时的处理是用DOMParser解析后递归过滤掉所有不在白名单里的元素同时把所有style清空。虽然表格和复杂排版保不住了但至少内容干净、排版统一。后来我把这个清洗函数单独抽成一个模块每次后端回显内容之前也会先过一遍这样双保险比什么都可靠。第三个是“编辑长文后卡顿明显”的问题。我刚开始实现内容同步时每触发一次 input 事件就执行一次saveToServer每执行一次就是一次完整的 innerHTML 序列化和网络请求。一篇 3000 字左右的文章编辑到一半浏览器就开始卡。后来改成两层优化第一层是 input 后 500ms 防抖再触发保存逻辑第二层是历史快照只保留最近 100 条。优化之后卡顿基本消失。遇到类似问题先看是不是保存逻辑太频繁再看是不是存了太多快照。5.3 我在项目里保留的几条铁律这些规则是踩过坑之后总结的每条都对应过一次真实的线上事故不做任何底层 DOM 操作之前先保存选区做完操作后除了明确要改变选区的场景一律恢复。不要用innerHTML 判断空内容永远用 textContent 加媒体数量综合判断。永远假设用户会粘贴任意来源的内容粘贴入口必须做白名单过滤。历史栈必须限长防抖必须要有否则长文编辑性能必崩。图片逻辑不要混在文本逻辑里图片的插入、上传、回显应该走独立的模块。任何编辑器代码在上线前至少在 Chrome、Safari 和安卓微信浏览器三个环境里各跑一遍。6. 后续扩展方向与个人体会6.1 从“能用”到“好用”的扩展清单一个基础 editor 运行稳定后下一步就是根据业务场景做扩展。根据我的经验新增功能建议按下述顺序评估Markdown 快捷键在行首输入#加空格后自动转成 h1输入-转列表这类功能通过拦截空格键的 keydown 事件实现很能提升输入效率。图片上传拖拽、粘贴截图、从工具栏选择图片三种入口统一走一个 upload 函数成功后插入图片地址。搜索替换遍历编辑区文本节点用TreeWalker找到匹配文本再用 Range 选中并替换。 提醒和人员选择记录光标位置调起候选列表选定后插入一个带有自定义 data 属性的 span 节点。代码块用 pre code 结构通过formatBlock或手动插入实现。每一项扩展都在原有的“保存选区 DOM 操作 恢复选区”框架下进行不需要推翻原设计。6.2 想要协作编辑editor 只是外层壳如果后续业务要求多人同时编辑同一篇文章那就要考虑协作算法了。目前主流方案是 OT 和 CRDT两种算法都很复杂editor 界面本身只是它们的呈现层。我的建议是如果真有协作需求优先考虑接入成熟的协作框架不要在自研 editor 里硬写同步逻辑。原因很简单多人冲突解决、服务端存储协议、离线编辑这些问题的复杂度远超绝大多数团队的日常能力范围。但即便不做多人协作也建议你在保存协议上做一点铺垫不要每次整篇传 HTML而是传增量快照或者至少在后端维护多个版本。这个设计能让后续的所有升级都轻松很多。6.3 收个尾说点真心话这些经验说到底都是写坏了两个编辑器版本之后才换来的。第一个版本是纯 textarea 加自定义工具栏功能简陋到用户用了一天就反馈不行第二个版本直接套了开源框架结果被老项目的样式和权限接口卡得死死的第三个版本才沉下心从 contenteditable 原理入手自己控制所有行为。如果你现在正要做类似的功能我的建议很直接别怕大胆从最小闭环开始。先用contenteditable加execCommand搭出能编辑、能保存、能回显的最小版本稳定跑通后再逐步加扩展。遇到问题就按这篇文章里整理的方向去排查大部分坑都有人踩过了你只需要少走几步弯路。
网站建设高端定制企业官网