资讯动态

从零手写Web编辑器:富文本与Markdown混合开发实战解析

发布时间:2026/9/15 7:30:05 来源:尧图企业网站定制
你有没有想过一个人从“只会用别人写的编辑器”到“动手写一个属于自己的编辑器”中间到底隔着什么我今天想分享的就是这样一个项目editor。它不是一个听起来特别酷炫的名字但恰恰是这种朴实让它承载了我对“编辑器”三个字最完整的理解。这个项目从零开始最终实现了一个基于 Web 技术栈的富文本与 Markdown 混合编辑器让我把输入、存储、渲染、格式化、快捷键这整条链路彻底跑通了一遍。这篇文章我会把整个设计与实现过程拆开揉碎从最底层的技术选型到最容易被忽略的撤销栈设计再到我在实际开发中踩过的坑和总结的排查方法论全部硬核地写出来。无论你是准备自己动手造一个编辑器轮子的初学者还是想了解复杂前端项目架构的中级开发者这篇文章应该都能给你一些可以真正落地的参考。1. 内容整体设计与思路拆解1.1 为什么要自己写一个编辑器而不是用现成的市面上现成的编辑器不计其数开源的 TinyMCE、CKEditor商业的 Froala各自都很成熟。直接拿来用可能只需要半天就能集成到项目里。但“直接用”和“自己写”之间差的不是一个编辑器而是对编辑器底层机制的完整理解。我做这个项目的原因有三个第一业务上确实需要一个高度定制、可以完全控制渲染层和输入流的编辑环境现成组件越到后期越难改第二我想要一个可以运行在浏览器里、同时支持 Markdown 语法和富文本粘贴的轻量编辑器而这个需求在现成方案里要么重要么匹配度不够第三也是最重要的一点我想通过亲手实现把 contenteditable 的原理、文档模型的设计、输入事件的处理这些底层知识补全这些是面试造火箭、工作拧螺丝时都非常值钱的东西。这个 editor 项目让我在真实业务场景里把一个“看起来很简单”的编辑功能做到了可落地、可维护、可扩展的工程水平。1.2 核心架构从“一个大文件”到“分层模块”项目一开头我犯了一个典型的错误直接把所有逻辑写在一个 JavaScript 文件里。等到代码量超过 2000 行维护开始变得极其痛苦。后来我做了一次彻底的重构按照数据流把整个编辑器拆分成了六个核心模块核心控制器Core Controller负责初始化编辑器、管理生命周期、分发事件文档模型层Document Model负责将用户输入的内容解析为结构化的文档树渲染层Renderer负责将文档树渲染到 DOM并处理 contenteditable 的输入输出命令系统Command System负责所有操作命令的注册、执行、撤销与重做序列化模块Serializer负责将内容转换为 HTML、Markdown 或纯文本快捷键管理Keymap Manager负责快捷键绑定与冲突处理。这次重构之后整个项目的可读性有了质的提升这也是我能继续往下迭代的基础。架构设计看似是“虚”的东西但在实际开发中它决定了一个项目走多远。2. 核心细节解析与实操要点2.1 技术选型的每一个选择背后都有原因在技术栈上我没有选择当下非常火的框架而是用了原生 JavaScript 搭配 bundler 进行构建。为什么不直接用 React因为编辑器本身是一个输入密度极高、DOM 操作极频繁的组件直接用框架会导致每一次输入都经历虚拟 DOM diff 的过程性能损耗非常明显。为了直观地表达我的选型思路这里做一个简单对比维度原生 JS 手动 DOM 操作React 受控组件Vue 受控组件输入响应性能最高直接操作 DOM 节点中等有 diff 开销中等有响应式追踪开销状态管理复杂度低完全自定义高需要与框架状态同步高需要与框架状态同步bug 概率高易出现 DOM 不一致中框架帮你同步中框架帮你同步对编辑器机制理解深入一般一般从前端架构的角度来说编辑器是最典型的“重 DOM 操作为主、状态管理为辅”的场景。很多业务页面适合用框架但编辑器本身原生 JavaScript 反而是更优解。2.2 文档模型设计为什么直接用 DOM 不行关于编辑器最容易犯的一个认知错误是直接操作 DOM 不就行了吗光标在这里我在这里插入一个节点不就好了实际开发中你会发现如果你直接操作 DOM那么在处理“撤销”的时候就会很麻烦。因为撤销的本质是要恢复到一个历史状态但 DOM 是不可变的结构你很难低成本地回到之前的某一时刻。所以正确的做法是引入一个独立的文档模型将 DOM 看作是模型的投影。设计上我参考了经典模型将文档抽象为Document ├── Block (段落块) │ ├── Inline (行内元素列表) │ ├── Inline (行内元素列表) │ └── ... ├── Block (引用块) │ └── Nested Blocks └── Block (代码块) └── CodeLine (代码行)所有编辑操作先在模型上执行执行成功后再触发渲染器做最小化的 DOM 更新。这样做收益很大撤销与重做只需要保存模型快照搜索与替换也可以直接遍历模型复制与粘贴甚至可以做跨编辑器的完全一致的约束校验。2.3 撤销与重做的实现细节撤销与重做是编辑器的核心能力一个没有撤销功能的编辑器是没法用的。实现时我采用的是“操作栈 快照”组合方案。每次操作触发前我把当前模型序列化成一个轻量快照压入撤销栈。快照不是 JSON.stringify 整个文档那样太大太慢我是按块做持久化引用只记录被修改块的前后差异这样平均一次撤销的内存占用可以控制在几十个字节到几个 KB 级别。具体实现时我区分了三种粒度字符级操作连续输入时合并成一次操作减少撤销栈爆炸块级操作回车换行、删除整段等独立记录命令级操作加粗、插入链接、列表转引用整体入栈。2.4 光标与选区管理编辑器里最烦琐的细节光标与选区管理是编辑器开发中公认最难啃的部分没有之一。浏览器提供的 Selection API 非常底层需要处理各种边界情况。我在项目里踩得最惨的坑是光标丢失在某些操作后浏览器会强制清空或重置选区导致用户的输入“断掉”。我最终采用的方案是在每次操作前记录当前光标位置为一个稳定的 anchors 路径数组blockIndex, inlineIndex, offset操作完成后根据这个路径重新定位光标。这听起来简单但实现过程中有非常多细节需要注意。比如在一个空行里光标的 offset 是 0 还是 1在代码块里光标如何跨行移动在列表项里按下 Tab 后光标应该停留在哪个层级。2.5 编辑器性能优化从防抖到虚拟滚动编辑器是最容易卡顿的场景之一尤其是当文档特别大的时候。我针对这个问题做了三个层次的优化第一输入防抖。在很多辅助逻辑比如字数统计、自动保存、目录生成上我都使用了 300ms 的防抖。这里需要说明的是渲染层的数据流不能被防抖防抖只能用于副作用逻辑。第二按区域渲染。对于长文档我采用了区块懒渲染策略屏幕外的区块只渲染占位节点不渲染内部内容。滚动时动态加载可视区域附近的区块。第三样式隔离。编辑器的样式必须用独立的 CSS 作用域避免被页面全局样式污染同时也不允许编辑器内部样式泄漏到外部页面。这三层优化做完之后一个 10 万字的文档在普通办公电脑上的滚动和编辑体验依然可以保持流畅。3. 实操过程与核心环节实现3.1 初始化编辑器实例我首先封装了一个编辑器工厂函数传入容器节点和配置项返回完整的编辑器实例。这是整个项目的基础入口。const editor createEditor({ container: document.getElementById(editor-root), initialValue: # 欢迎使用 editor, lang: zh-CN, placeholder: 请输入内容..., autosave: { enable: true, interval: 3000, storage: localStorage }, toolbar: [bold, italic, heading, code, quote, list], onReady(instance) { console.log(编辑器已就绪, instance); } });这种设计的好处在于使用者不需要关心内部如何实现只需要传入配置项就可以得到一个功能完整的编辑器。3.2 实现编辑器主题区域渲染编辑器最核心的部分是主体区域。它的核心是提供一个可输入的容器并监听用户的输入事件。class EditorView { constructor(model, renderer) { this.model model; this.renderer renderer; this.root document.createElement(div); this.root.setAttribute(contenteditable, true); this.root.classList.add(editor-root); this.bindEvents(); } bindEvents() { this.root.addEventListener(input, this.handleInput.bind(this)); this.root.addEventListener(keydown, this.handleKeydown.bind(this)); this.root.addEventListener(paste, this.handlePaste.bind(this)); } handleInput(event) { // 将 DOM 变化同步到文档模型 this.renderer.syncToModel(this.root); this.model.normalize(); this.renderer.syncToDOM(this.model, this.root); } }这里的核心逻辑是“两个同步”一个是 DOM - Model一个是 Model - DOM。这两个同步必须保持高效准确否则编辑器就会出现光标乱跳、内容丢失的问题。3.3 实现编辑器快捷操作命令在输入之外编辑器还需要支持格式化命令。含快捷键的表格式速查如下命令快捷键功能说明toggleBoldCtrl/CmdB加粗选中文本无选中时开启后输入的内容加粗toggleItalicCtrl/CmdI斜体toggleHeadingCtrl/CmdShiftH将当前行切换为标题insertCodeBlockCtrl/Cmd插入代码块toggleQuoteCtrl/CmdShiftQ切换引用undo / redoCtrl/CmdZ / Ctrl/CmdShiftZ撤销与重做这里我实现的是一个通用命令注册表const commands {}; function registerCommand(name, execute, context) { commands[name] { execute, context }; } function executeCommand(name, editor) { const command commands[name]; if (command command.context(editor)) { command.execute(editor); } }这种设计的好处是后续增加新功能只需要注册新的命令而不需要修改核心控制器。3.4 集成编辑器主题编辑器存储功能一个没有持久化的编辑器是反人类的。在存储这一块我实现了两种方式本地存储与远程同步。本地存储实现得非常直接使用 localStorage 加防抖3 秒自动保存一次。远程同步留了接口可以通过配置传入自定义的保存函数。实际上项目中我通常建议团队用 IndexedDB 来存大文档。class AutosaveService { constructor(editor, storage, interval) { this.editor editor; this.storage storage; this.interval interval; this.timer null; } start() { this.timer setInterval(() { const content this.editor.getContent(markdown); this.storage.setItem(editor:autosave, content); }, this.interval); } restore() { return this.storage.getItem(editor:autosave) || ; } }关于为什么用 IndexedDB 而不是 localStoragelocalStorage 存储量只有约 5MB且是同步 API对于编辑器这种频繁读写的场景会造成主线程阻塞。IndexedDB 是异步的存储空间大得多更适合保存大型文档和二进制资源。3.5 编辑器主题内容序列化与反序列化编辑器支持多种格式的导出与导入核心是序列化模块。下面是我实现的 Markdown 序列化核心代码class MarkdownSerializer { serialize(documentModel) { return documentModel.blocks.map(block { switch (block.type) { case heading: return ${#.repeat(block.level)} ${block.text}; case paragraph: return block.text; case code: return \n block.code \n; case quote: return block.lines.map(line ${line}).join(\n); case list: return block.items.map(item - ${item}).join(\n); default: return ; } }).join(\n\n); } }这个模块的难点在于序列化之后的文本必须能准确无误地反序列化回原来的文档结构即“往返一致性”。如果没有严格的测试覆盖很容易出现序列化丢失数据的问题。3.6 处理编辑器主题中的粘贴与拖放粘贴是编辑器最容易翻车的地方。从 Word、网页、PDF 复制过来的内容往往带着大量内联样式和乱七八糟的标签。我的处理策略是拦截 paste 事件优先读取剪贴板中的 text/html其次才是 text/plain对 HTML 进行白名单清洗只保留标签白名单和类白名单将清洗后的 HTML 解析为文档模型块插入到光标位置。handlePaste(event) { event.preventDefault(); const html event.clipboardData.getData(text/html); const text event.clipboardData.getData(text/plain); if (html) { const cleaned sanitizeHTML(html); const blocks parser.parse(cleaned); this.model.insertBlocks(blocks, this.selection.getCaretPosition()); } else { this.model.insertText(text, this.selection.getCaretPosition()); } }拖放的处理也类似核心是阻止浏览器默认行为不能让它把拖入的内容强加 DOM。4. 常见问题与排查技巧实录开发编辑器一定会遇到各种诡异问题。这里我整理几张排查实录都是实际开发过程中掉过的坑希望能帮后来的人省下几个不眠之夜。4.1 内容丢失一个防不胜防的坑现象用户输入一段文字后切换到别的应用再切回来内容消失了。排查过程这个问题我在初版中遇到时第一反应是去查自动保存逻辑后来发现定时器没有问题。再排查发现根因是浏览器的内容可编辑区域在失去焦点后会触发一次内部的重排这个过程中某些格式不一致的 DOM 节点被浏览器自动修复而我的模型没有同步到这次修复。解决方案监听 blur 事件在失焦时主动将 DOM 完全同步到模型并将模型序列化后的内容强制写回 DOM确保 DOM 与模型始终一致。虽然多了一次序列化开销但彻底解决了内容丢失问题。4.2 光标乱跳block 合并后遗症现象删除一个段落的末尾字符当段落为空时自动合并到上一个段落此时光标跳到了文档开头。排查过程定位问题后发现是光标定位计算用了旧数据而合并操作触发了 DOM 重建导致旧路径失效。解决方案所有涉及块合并、拆分、重排的操作必须返回到新的路径且这个路径要基于新模型计算而非旧模型。4.3 快捷键冲突浏览器不给机会现象想实现 CtrlY 重做但浏览器直接执行了重做而不是我的命令。排查过程不同浏览器对默认行为的拦截策略不一样有些快捷键在 keydown 阶段无法被阻止。解决方案区分快捷键的绑定阶段能拦截的在 keydown 处理拦截不了的在 beforeinput 阶段处理优先级不对的还要考虑用 keyup 补偿。4.4 大文档卡顿渲染层性能瓶颈现象文档超过 5 万字后输入开始出现明显卡顿。排查过程用 Performance 面板逐帧分析发现 DOM 节点数超过 3 万个浏览器重排耗时指数级上升。解决方案实施虚拟滚动策略只渲染视口附近的块。对于超长文档这是一个必须做的基础功能否则永远无法达到可用级别。4.5 中文输入法组合态丢失现象用中文输入法打字拼音组合过程中一旦触发 onInput就会导致拼音候选词消失。排查过程这是所有中文编辑器都会遇到的问题。核心是在组合输入composition期间不能对 DOM 做任何同步改动。解决方案维护一个 isComposing 标志位在 compositionstart 与 compositionend 之间暂停 DOM 同步只在 compositionend 后执行一次完整同步。下面整理一个“常见问题速查表”方便对标自查问题核心原因排查思路推荐解法内容丢失DOM 被浏览器修复检查 Model 与 DOM 是否同步blur 时强制双向同步光标乱跳旧路径失效检查合并/拆分操作后的路径更新基于新模型重新计算光标路径快捷键冲突浏览器默认行为拦截不住检查 keydown/beforeinput 阶段分层绑定快捷键优先拦截大文档卡顿DOM 节点过多Performance 面板看重排耗时虚拟滚动 按区域渲染输入法乱跳composition 期间改动 DOM确认 compositionstart/end 事件组合期间暂停同步4.6 编辑器主题兼容性测试清单在项目发布前我建议你至少自查以下场景空文档编辑后刷新内容不丢失中文输入组合过程中截图与内容不闪现粘贴来自 Word、公众号、网页的混排内容样式不崩坏快速连续 CtrlZ 能逐步回退步数不丢失文档顶部插入大段内容后原地光标位置正确浏览器的页面缩放 50% 到 200% 时光标位置不偏移。这些场景看着细碎但任何一个出问题都会直接影响用户信任感。4.7 编辑器主题后续可扩展的方向这个项目目前已经可以支撑日常的 Markdown 写作和富文本排版需求。它在未来的扩展方向上可以接 PDF 导出、云文档协同、AI 辅助写作、以及移动端的触控优化。如果未来要往协同方向发展核心要改的是文档模型需要引入 CRDT 这样的数据结构来支撑多人同时编辑。这条路很深但每一步更新都很有价值。5. 写在最后的一些经验心得编辑器这个项目是我所有前端实践中收获最大的一个。它不仅让我把 DOM、Selection、Range、事件循环这些基础概念内化成了一种嵌入直觉的技能也让我真正体会到了“基础设施型组件”和普通业务组件的差异。编辑器的每一处细节都需要对用户行为有足够的预判每一处错误都会以最直观的方式被放大。如果你也想挑战一下自己我建议你别满足于接入第三方编辑器动手从零写一个最小可用的版本然后不断替它增加能力。相信我哪怕只是实现加粗、列表和撤销这三个功能你对前端开发的理解也会上升一个台阶。最后分享一个我实际调试中特别有效的小技巧给自己的编辑器加一个“调试模式”会在控制台输出每一次命令执行前后的模型快照对比。排查诡异问题时这个小功能比任何断点都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价