基于ProseMirror与Remark构建类Typora的Web Markdown编辑器

📅 2026/8/12 18:57:01
基于ProseMirror与Remark构建类Typora的Web Markdown编辑器
1. 项目缘起与核心价值作为一个常年与Markdown打交道的文字工作者和开发者我对Typora的喜爱是深入骨髓的。那种在简洁的编辑界面中指尖敲击键盘左侧是清晰的Markdown语法右侧是实时渲染的优雅排版写作的“心流”体验无与伦比。它完美诠释了“所见即所得”的精髓——不是传统富文本编辑器那种臃肿的工具栏而是将轻量级标记语言的简洁与最终呈现的美观无缝融合。然而当我们需要将Markdown编辑器集成到自己的Web应用、知识库系统或在线协作平台时往往会发现一个尴尬的局面市面上成熟的在线编辑器要么过于笨重牺牲了Typora那种极致的专注体验要么功能过于简陋无法满足复杂的定制化需求。这种“工具”与“产品”之间的鸿沟促使我决定动手开发一个致敬Typora理念的、可深度集成的Web版所见即所得Markdown编辑器。这个项目的核心目标非常明确在Web环境中复现甚至超越Typora的核心编辑体验。这不仅仅是实现一个文本渲染器而是要构建一个完整的编辑交互体系。它需要做到第一真正的实时渲染输入即呈现无延迟、无闪烁第二纯净的编辑模式提供类似Typora的“源代码模式”、“专注模式”和“打字机模式”让作者能沉浸其中第三强大的扩展性作为第三方库它必须易于集成、主题可定制、功能可插拔第四卓越的性能即使处理上万字的长文档滚动和编辑也必须流畅。最终我们希望开发者能通过几行代码就在自己的产品中为用户提供一个“类Typora”的顶级写作环境从而提升产品的整体格调与用户体验。2. 核心架构设计与技术选型要打造一个高性能的Web版所见即所得Markdown编辑器技术选型是地基。经过多轮技术调研与原型验证我最终确定了以ProseMirror为核心搭配Remark生态的技术栈。这个选择背后有深刻的考量。2.1 为什么是ProseMirror市面上常见的编辑器方案如基于contenteditable的直接操作、或简单的textarea加预览窗都存在难以克服的缺陷。原生contenteditable的行为在不同浏览器间差异巨大处理复杂文档结构时极易产生脏HTML状态管理更是噩梦。而ProseMirror提供了一个基于事务Transaction的文档模型它将文档抽象为一个不可变的、结构化的JSON树类似Slate任何编辑操作输入、删除、格式化都转化为对文档树的事务操作。这种设计带来了几个决定性优势状态可预测与可追溯文档的每一次变化都有明确的状态State记录实现撤销/重做、协同编辑的基石变得异常简单。强大的Schema约束可以精确定义文档中允许出现哪些节点如段落、标题、代码块、表格、哪些标记如加粗、链接以及它们之间的嵌套规则。这从根本上防止了非法文档结构的产生保证了输出Markdown或HTML的纯净性。卓越的渲染性能ProseMirror通过虚拟DOM类似React来更新视图只对发生变化的部分进行重绘。在处理长文档时相比全量替换innerHTML的方案性能有数量级的提升。2.2 为什么搭配Remark生态ProseMirror擅长管理编辑状态和视图但Markdown的解析Markdown - ProseMirror Document与序列化ProseMirror Document - Markdown需要另一个强大的工具链。Remark是 unified 生态系统中处理Markdown的标杆。它采用插件化架构将Markdown文本解析为语法树MDAST经过一系列插件处理再重新序列化为文本。我们的架构流程是用户输入Markdown文本 -remark-parse插件将其解析为MDAST - 通过自定义转换器将MDAST映射为ProseMirror文档模型PM Doc - ProseMirror负责渲染和编辑交互 - 用户编辑产生新的PM Doc - 通过另一个自定义转换器将PM Doc映射回MDAST -remark-stringify插件将MDAST序列化为Markdown文本。这个双向转换层是项目的核心难点之一需要精细处理所有Markdown语法元素如GFM任务列表、表格、脚注与ProseMirror节点/标记的对应关系。注意在转换器实现中要特别注意“无损往返”原则。即一段Markdown文本经过“解析-转换为PM Doc-再序列化”这个过程后得到的Markdown文本应该与原始输入在语义上完全等价允许格式化上的细微差别如换行符。这需要大量细致的测试用例来保证。2.3 整体技术栈一览基于以上核心最终的技术栈如下编辑器核心ProseMirror (Model, View)Markdown处理Remark (Parse, Stringify), 及其插件生态如remark-gfm处理表格、删除线等构建与开发Vite TypeScript。TypeScript对于管理如此复杂的类型系统ProseMirror Schema, Remark AST至关重要能极大减少运行时错误。样式与主题Sass (SCSS)。采用CSS变量定义主题色、字体、间距等实现一套代码多套主题切换。测试Vitest Testing Library。单元测试覆盖核心工具函数和转换器集成测试模拟用户交互。3. 关键功能模块的深度实现有了稳固的架构接下来就是逐一攻克那些让编辑器拥有“Typora灵魂”的关键功能模块。3.1 实时渲染与语法高亮的无缝融合Typora的一个魔法时刻是当你输入“”后回车瞬间出现一个带有语言选择和语法高亮的代码块。我们要在Web中实现这一点。首先在ProseMirror的Schema中我们需要定义code_block节点。它包含一个language属性。当用户输入“”或“~~~”时我们通过输入规则Input Rule或快捷键触发一个命令Command将当前行或选中的文本转换为一个code_block节点。真正的挑战在于实时语法高亮。我们不可能在用户每次输入时都对整个代码块进行高亮分析那会卡死。解决方案是按需高亮使用requestIdleCallback或防抖debounce技术在用户停止输入一段时间后如300ms才对可见区域的代码块进行高亮处理。Worker线程将高亮计算通常涉及复杂的词法分析放入Web Worker避免阻塞主线程的渲染和交互。我们使用highlight.js或Prism.js的Worker版本。增量更新ProseMirror的文档变更记录了旧选区from, to和新选区。对于代码块我们可以只重新高亮受编辑影响的行及其上下文而不是整个代码块。代码块节点的视图组件需要重写以集成高亮后的HTML片段。同时还要实现一个浮动的语言选择菜单其位置需要根据光标所在的代码块节点动态计算。3.2 三种核心编辑模式的实现源代码模式这个相对简单。本质上是隐藏ProseMirror的编辑视图显示一个与当前文档同步的textarea。关键在于同步逻辑。不能简单地在每次textarea的onChange时全量替换ProseMirror文档那会丢失选区selection和历史状态。正确做法是计算textarea与当前ProseMirror文档序列化后的Markdown文本的差异diff然后将这个差异转换为一组ProseMirror事务Transaction来应用变更。这能最大程度保留编辑状态。专注模式Typora的专注模式会高亮当前编辑的行或段落并淡化其他内容。我们的实现方案是通过CSS和JavaScript动态控制。监听光标位置变化ProseMirror的selection更新。获取光标所在的节点如段落计算其在视口中的位置和高度。动态创建一个“聚焦层”的CSS渐变遮罩。通常是在编辑区域上方覆盖一个linear-gradient背景中间透明对应光标所在行区域上下两端渐变为半透明或模糊。更高级的实现可以用CSSbackdrop-filter: blur()来模拟毛玻璃淡化效果。需要精细处理滚动时的重定位保证“焦点”始终跟随光标所在行。打字机模式目标是让当前编辑行始终保持在视窗中央。核心是监听编辑和滚动事件。获取光标所在行的DOM元素及其相对于编辑器容器的位置。计算该行中心点与编辑器视窗中心点的偏移量。通过scrollTop动态调整编辑器的滚动位置使该行居中。这里需要加入动画过渡scroll-behavior: smooth或使用requestAnimationFrame进行平滑滚动来提升体验。注意事项频繁触发滚动事件可能导致性能问题或滚动抖动。需要设置合理的触发阈值并在用户主动滚动时暂时禁用打字机模式。3.3 图片粘贴与上传的一体化处理现代编辑器的标配是支持直接粘贴剪贴板中的图片截图或文件并上传。实现流程如下监听粘贴事件在ProseMirror的编辑视图中捕获paste事件。解析DataTransfer检查event.clipboardData.items遍历找到type以image/开头的项。读取文件通过FileReader读取图片文件为DataURLbase64格式。插入占位符立即在光标处插入一个带有src为DataURL的临时图片节点并附加一个uploading的CSS类如显示旋转加载图标给用户即时反馈。异步上传将图片文件Blob对象通过FormData或直接二进制流上传到你的后端或图床服务如OSS、Cloudinary。替换链接上传成功后获取返回的永久URL通过ProseMirror事务找到对应的临时图片节点将其src属性替换为永久URL并移除uploading类。失败处理上传失败时可以将图片节点替换为一段错误提示文本或者提供一个重新上传的按钮。实操心得图片上传一定要做好并发管理和失败重试。例如用户快速粘贴多张图片时需要维护一个上传队列。同时临时图片的DataURL可能会很大如果用户粘贴后立即关闭页面可能造成数据丢失。一种更优的方案是先将图片文件暂存到浏览器的IndexedDB中上传成功后再清理这样即使网络中断重新打开页面也能恢复上传任务。3.4 大纲导航与文档状态管理对于长文档大纲导航是刚需。实现原理是监听文档变化从ProseMirror文档树中遍历出所有标题节点heading。提取大纲编写一个函数遍历文档的content收集所有类型为heading且level在1-6之间的节点。记录其文本内容、层级level以及在文档中的位置pos。生成导航DOM根据提取的数据渲染一个嵌套的列表ul、li作为大纲视图。每个li的缩进由标题层级决定。滚动联动点击大纲跳转为每个li绑定点击事件触发时使用ProseMirror的tr.setSelection将编辑器光标定位到对应标题的位置并滚动到视图中。编辑区域滚动时高亮大纲监听编辑器的滚动事件计算当前视口内最顶部的标题是哪个然后在大纲视图中高亮对应的li项。这里需要用到getBoundingClientRect来比较标题元素与视口的相对位置。性能优化遍历整个文档计算大纲在长文档下可能耗时。可以使用ProseMirror的Node对象的descendants方法进行高效遍历并对结果进行缓存仅在文档结构真正改变时通过比较文档的哈希或版本号重新计算。4. 性能优化与深度定制实践当核心功能完成后一个工业级的编辑器必须经过性能优化的淬炼并开放足够的定制能力。4.1 应对长文档的渲染性能挑战万级字数的文档是对编辑器性能的终极考验。我们的优化策略是多层次的视图复用与虚拟滚动这是最核心的优化。ProseMirror默认会为文档中的每个节点创建一个对应的DOM元素。对于超长文档这会导致DOM节点数爆炸。我们可以实现一个自定义的节点视图NodeView对于段落、列表项等大量重复的简单节点在滚动出视口时将其对应的DOM元素回收到一个池子里当需要渲染新的同类节点时从池中复用并更新内容。这需要手动管理DOM的挂载mount与卸载unmount。更复杂的方案是集成类似react-window的虚拟滚动库只渲染视口附近的节点。节流Throttle与防抖Debounce将高开销操作如语法高亮、大纲重新计算、拼写检查与频繁触发的事件如输入、滚动解耦。使用防抖确保在用户停止输入后再执行使用节流保证在一定时间间隔内只执行一次。选择性重绘充分利用ProseMirror的增量更新机制。在更新视图时确保只对受事务影响的DOM子树进行修改避免全量更新。4.2 插件化系统与主题定制设计为了让编辑器能被不同项目灵活使用必须设计良好的扩展机制。插件系统我们借鉴ProseMirror和Remark的插件设计。一个插件就是一个包含name、schema扩展节点/标记、commands自定义命令、inputRules输入规则、keymaps快捷键等属性的对象。开发者可以通过一个use方法来加载插件。例如一个“绘图插件”可以添加一个drawing节点类型并注册一个/draw的斜杠命令来触发。// 示例一个简单的字数统计插件 const wordCountPlugin { name: wordCount, view(editorView) { const div document.createElement(div); div.className word-count-status; const update (view) { const text view.state.doc.textBetween(0, view.state.doc.content.size, ); const words text.trim().split(/\s/).length; div.textContent 字数: ${words}; }; update(editorView); // 监听文档变化更新字数 // ... 返回一个ProseMirror Plugin实例在apply事务时调用update return { dom: div, update }; } };主题系统所有CSS样式都基于CSS变量Custom Properties定义。我们提供一套默认的“亮色”和“暗色”主题变量文件。用户可以通过覆盖这些变量来定制颜色、字体、边框、阴影等。更高级的定制允许用户传入完整的CSS字符串或通过构建工具替换我们的Sass源文件。/* 默认主题变量 */ :root { --editor-bg: #ffffff; --editor-text: #333333; --editor-border: #e1e4e8; --code-bg: #f6f8fa; --link-color: #0366d6; } /* 暗色主题 */ .theme-dark { --editor-bg: #1e1e1e; --editor-text: #d4d4d4; --editor-border: #3e3e3e; --code-bg: #2d2d2d; --link-color: #569cd6; }4.3 协同编辑的初步探索虽然Typora是单机工具但作为Web编辑器协同编辑是很多场景的潜在需求。我们可以基于CRDT无冲突复制数据类型或操作转换OT算法来实现。这里以相对更成熟的OT为例简述集成思路选择OT库例如sharedb或ot.js。定义操作将ProseMirror的每一次事务Transaction序列化为一个OT操作。这个操作需要描述从旧文档状态到新状态的增量变化如“在位置N插入字符串‘abc’”、“删除位置M到N的字符”。客户端集成在编辑器中本地事务产生后先通过OT算法与本地待发送的操作队列进行转换如果需要然后发送到协同服务器。同时监听服务器广播的其他用户的操作将其转换后通过view.dispatch应用到本地ProseMirror文档中。冲突解决OT算法的核心就是保证无论操作以何种顺序到达最终所有客户端的文档状态都是一致的。这要求操作必须是可交换、可关联的。注意事项协同编辑的实现复杂度极高涉及网络延迟、离线恢复、光标同步显示其他用户的光标和选区等诸多挑战。对于大多数项目如果不需要实时强协同可以考虑更简单的“自动保存冲突检测”模式即在用户保存时提示文档已被他人修改并提供合并或覆盖选项。5. 开发、调试与集成指南5.1 构建与发布流程项目采用ViteTypeScript构建。库的最终输出目标是多种格式以适配不同使用环境dist/index.esm.js: ES模块格式供现代构建工具如Vite, Webpack直接导入。dist/index.umd.js: UMD格式可直接通过script标签引入全局变量暴露。dist/index.css: 提取出的所有CSS样式。在package.json中需要正确配置main、module、unpkg、types等字段。使用npm publish发布前务必运行完整的测试套件和构建流程。5.2 集成到不同前端框架作为一个无框架依赖的核心库它可以被任何前端框架封装。React集成示例import { useEffect, useRef } from react; import { Editor } from your-md-editor; import your-md-editor/dist/index.css; function MarkdownEditor({ value, onChange }) { const editorRef useRef(null); const editorInstance useRef(null); useEffect(() { if (!editorInstance.current) { editorInstance.current new Editor({ element: editorRef.current, content: value, onUpdate: (content) onChange(content), }); } return () { editorInstance.current?.destroy(); }; }, []); // 外部更新value时同步到编辑器 useEffect(() { if (editorInstance.current value ! editorInstance.current.getContent()) { editorInstance.current.setContent(value); } }, [value]); return div ref{editorRef} /; }关键是将编辑器的生命周期初始化、销毁与React组件的生命周期绑定并通过回调函数实现数据的双向同步。Vue集成示例思路类似在onMounted钩子中初始化编辑器在onBeforeUnmount中销毁通过watch监听props.value的变化来更新编辑器内容。5.3 常见问题与排查技巧在开发和集成过程中你可能会遇到以下典型问题问题现象可能原因排查与解决思路编辑器无法初始化控制台报Schema错误1. 插件加载顺序冲突导致节点/标记重复定义。2. 自定义Schema与默认Schema合并时出现冲突。1. 检查所有插件的name是否唯一。2. 使用ProseMirror的Schema对象的spec.nodes.append和spec.marks.append方法安全地扩展Schema。3. 在开发环境输出最终的Schema对象检查节点/标记定义。输入Markdown语法如**没有实时渲染1. 对应的输入规则Input Rule未正确注册或优先级被覆盖。2. 该语法对应的节点/标记在Schema中未定义。1. 检查插件中inputRules数组是否包含该规则。2. 使用浏览器的开发者工具在输入时监听键盘事件查看ProseMirror是否触发了事务。3. 确保Schema中定义了strong加粗标记。复制粘贴内容格式错乱1. 从网页如Word、谷歌文档粘贴的HTML内容过于复杂转换到Markdown时丢失信息。2. ProseMirror的clipboardTextParser或自定义粘贴处理逻辑有误。1. 实现一个强大的HTML到Markdown的转换器可使用turndown库并在粘贴钩子中调用它。2. 为粘贴的内容定义一个“安全”的Schema子集只允许基本的段落、列表、链接等过滤掉不支持的复杂样式。在滚动长文档时出现明显卡顿1. 未实现虚拟滚动或节点视图复用。2. 语法高亮、图片加载等操作未做节流/防抖。3. 某个插件在每次更新时执行了昂贵的计算。1. 使用Chrome Performance面板录制滚动时的性能找到耗时最长的函数。2. 针对性地对高亮、大纲计算等操作进行性能优化。3. 检查自定义NodeView的update方法是否高效。与其他UI库如Ant Design, Element UI的样式冲突全局CSS样式污染特别是box-sizing,font-family,line-height等基础属性。1. 为编辑器根容器设置一个特定的类名如.md-editor-root所有编辑器样式都嵌套在这个类名下提高样式优先级。2. 使用CSS-in-JS方案如styled-components将样式完全隔离。3. 在构建时使用CSS Modules对类名进行哈希化。5.4 最后的经验之谈开发一个完整的所见即所得Markdown编辑器是一个“细节魔鬼”工程。除了上述主要模块还有无数小点需要打磨中文输入法IME的兼容性、移动端触摸交互的支持、无障碍访问ARIA标签的添加、导出为PDF/HTML的功能、与数学公式KaTeX、图表Mermaid等第三方库的集成。我的体会是不要试图一开始就造一个完美轮子。可以从最核心的“段落、标题、粗体、斜体”的实时渲染开始确保这个最小闭环稳定、流畅。然后像搭积木一样一个一个地添加代码块、列表、表格、图片等功能。每添加一个功能都要编写相应的单元测试和集成测试。广泛收集用户反馈尤其是从Typora迁移过来的用户他们对体验的细节最为敏感。这个项目最大的收获不是最终产出的编辑器库而是在深度拆解ProseMirror和Remark这两个顶级开源项目过程中对编辑器技术、数据结构树、事务、渲染性能优化理解的巨大提升。最终当你看到用户在你的编辑器中流畅地写作忘记了工具的存在时那种成就感是对所有埋头编码夜晚的最好回报。