如何用 contenteditable 实现富文本编辑器?有哪些坑
结论:contenteditable 能让你在 20 分钟内做出一个"能敲字、能加粗"的编辑器,但要做成生产级富文本,正确姿势是"DOM 只当渲染层、数据模型当唯一数据源",并且在中文输入法、撤销栈、粘贴这三件事上必须专门处理;如果需求超过"加粗/斜体/列表",直接用 ProseMirror、Lexical、Tiptap、Quill 这类成熟库,自己从零写几乎一定翻车。
contenteditable 的最小实现要写哪些代码?
最小可用版本只要三行结构:容器 contenteditable="true"、监听 input 事件取 innerHTML、工具栏按钮调 document.execCommand('bold')。但注意 execCommand 已被 MDN 标记为 deprecated(obsolete),虽然 Chrome、Edge、Firefox、Safari 至今仍然实现,新项目不应把它当作长期方案。
几个立刻能用的配置项:
- 段落分隔:
document.execCommand('defaultParagraphSeparator', false, 'p'),避免 Chrome 默认插入<div>、Firefox 插入<br>造成结构不一致。 - 用标签而不是内联样式:
document.execCommand('styleWithCSS', false, 'false'),让加粗输出<b>而非<span style="font-weight:bold">。 - 只想要纯文本:
contenteditable="plaintext-only"比true干净得多,Chromium 系早已支持,Firefox 需 136 及以上,Safari 支持较晚;落地前用document.createElement('div').setAttribute('contenteditable','plaintext-only'); el.contentEditable === 'plaintext-only'做特性检测。 - CSS 必须补:
white-space: pre-wrap保留空格换行、word-break: break-word防长串溢出、outline: none去焦点框;再关掉浏览器干扰项spellcheck="false" autocorrect="off" autocapitalize="off"。
为什么中文输入法是 contenteditable 的第一大坑?
因为拼音候选阶段浏览器会临时改写 DOM,而 beforeinput / input 事件在组合期间会连续触发,你的格式化逻辑一旦在这个窗口改动 DOM,候选框就会错位、光标跳到开头,甚至吞字。
正确做法:监听 compositionstart / compositionend,用一个 isComposing 标志位。compositionstart 时置 true 并停止一切格式化与模型同步;compositionend 时置 false,再统一读取一次内容。同时 input 事件里用 e.isComposing 再做一次兜底判断,因为 compositionend 之后浏览器还会补发一个 input。
为什么不能把 DOM 当编辑器数据源?
因为同一个语义在 DOM 里有无数种写法:<b>、<strong>、<span style="font-weight:bold"> 是同一件事;一个空行可能是 <br>、<div><br></div>、<p></p> 或 。你直接读 innerHTML,等于把浏览器的实现细节写进了业务数据。
结论:编辑器内部应该维护一份结构化模型(JSON 或 AST),所有用户操作先转换为模型变更,再由模型渲染 DOM。监听 beforeinput 拿到 e.inputType(insertText、insertParagraph、deleteContentBackward、insertFromPaste 等),自己做 preventDefault() 后按类型改模型。这样撤销重做、协同编辑、序列化才有统一入口。
粘贴、撤销、光标丢失怎么解决?
粘贴:在 paste 事件里 e.preventDefault(),从 e.clipboardData.getData('text/html') 和 getData('text/plain') 两条路取内容。从 Word、网页复制过来的 HTML 会带 mso- 样式、<script>、<iframe>,必须过一遍 DOMPurify.sanitize(html, {ALLOWED_TAGS: ['p','br','strong','em','ul','ol','li','a']}) 白名单清洗,否则就是 XSS 入口。最省事的降级策略是只取 text/plain 后用模型自己重建段落。
撤销栈:不要依赖浏览器的 execCommand('undo'),它在你有任何直接 DOM 操作后就会错乱。自己维护一个操作栈,每次模型变更 push 一条,Ctrl/Cmd+Z 时 preventDefault() 后回滚模型并重渲染。
光标丢失:如果你用 React/Vue 受控组件,把模型序列化回 innerHTML 会让光标回到开头。解决方式是渲染前用 window.getSelection().getRangeAt(0) 保存偏移,渲染后用 document.createRange() + selection.removeAllRanges() + selection.addRange(range) 恢复;更稳的方案是把模型节点映射成 DOM 节点,只做增量更新而不是整体重写。
另外,图片、@提及这类原子节点要设 contenteditable="false",否则光标会钻进图内部;空段落里插入 \u200B(零宽空格)可以防止输入框塌陷。
该自己写还是用现成库?
结论:只要涉及结构化文档、协作、撤销重做三项中的任意两项,就用现成库。ProseMirror 适合复杂文档模型和协同(配 Y.js),Lexical 是 Meta 出品、体积小、React 集成好,Tiptap 基于 ProseMirror、上手最快,Quill 生态成熟但模型较老。自己写只适合"单行输入 + 几个简单样式"的场景,这时优先考虑 plaintext-only 而不是 contenteditable="true"。
总结一下:contenteditable 本身不难,难在浏览器把它当"可编辑页面"而不是"编辑器组件"。记住三件事——IME 期间不动 DOM、DOM 不当数据源、粘贴必须清洗——就能避开绝大多数坑;超出这个范围,交给成熟库更划算。
原文链接:https://www.gj0.com/thread-925.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。