ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/12 19:21:58 拓冰建站 浏览量
基于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),任何编辑操作(输入、删除、格式化)都转化为对文档树的事务操作。这种设计带来了几个决定性优势:

  1. 状态可预测与可追溯:文档的每一次变化都有明确的状态(State)记录,实现撤销/重做、协同编辑的基石变得异常简单。
  2. 强大的Schema约束:可以精确定义文档中允许出现哪些节点(如段落、标题、代码块、表格)、哪些标记(如加粗、链接),以及它们之间的嵌套规则。这从根本上防止了非法文档结构的产生,保证了输出Markdown或HTML的纯净性。
  3. 卓越的渲染性能: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节点。

真正的挑战在于实时语法高亮。我们不可能在用户每次输入时,都对整个代码块进行高亮分析,那会卡死。解决方案是:

  1. 按需高亮:使用requestIdleCallback或防抖(debounce)技术,在用户停止输入一段时间后(如300ms),才对可见区域的代码块进行高亮处理。
  2. Worker线程:将高亮计算(通常涉及复杂的词法分析)放入Web Worker,避免阻塞主线程的渲染和交互。我们使用highlight.jsPrism.js的Worker版本。
  3. 增量更新:ProseMirror的文档变更记录了旧选区(from, to)和新选区。对于代码块,我们可以只重新高亮受编辑影响的行及其上下文,而不是整个代码块。

代码块节点的视图组件需要重写,以集成高亮后的HTML片段。同时,还要实现一个浮动的语言选择菜单,其位置需要根据光标所在的代码块节点动态计算。

3.2 三种核心编辑模式的实现

  • 源代码模式:这个相对简单。本质上是隐藏ProseMirror的编辑视图,显示一个与当前文档同步的textarea。关键在于同步逻辑。不能简单地在每次textareaonChange时全量替换ProseMirror文档,那会丢失选区(selection)和历史状态。正确做法是:计算textarea与当前ProseMirror文档序列化后的Markdown文本的差异(diff),然后将这个差异转换为一组ProseMirror事务(Transaction)来应用变更。这能最大程度保留编辑状态。

  • 专注模式:Typora的专注模式会高亮当前编辑的行或段落,并淡化其他内容。我们的实现方案是,通过CSS和JavaScript动态控制。

    1. 监听光标位置变化(ProseMirror的selection更新)。
    2. 获取光标所在的节点(如段落),计算其在视口中的位置和高度。
    3. 动态创建一个“聚焦层”的CSS渐变遮罩。通常是在编辑区域上方覆盖一个linear-gradient背景,中间透明(对应光标所在行区域),上下两端渐变为半透明或模糊。更高级的实现可以用CSSbackdrop-filter: blur()来模拟毛玻璃淡化效果。
    4. 需要精细处理滚动时的重定位,保证“焦点”始终跟随光标所在行。
  • 打字机模式:目标是让当前编辑行始终保持在视窗中央。核心是监听编辑和滚动事件。

    1. 获取光标所在行的DOM元素及其相对于编辑器容器的位置。
    2. 计算该行中心点与编辑器视窗中心点的偏移量。
    3. 通过scrollTop动态调整编辑器的滚动位置,使该行居中。这里需要加入动画过渡(scroll-behavior: smooth或使用requestAnimationFrame进行平滑滚动)来提升体验。
    4. 注意事项:频繁触发滚动事件可能导致性能问题或滚动抖动。需要设置合理的触发阈值,并在用户主动滚动时暂时禁用打字机模式。

3.3 图片粘贴与上传的一体化处理

现代编辑器的标配是支持直接粘贴剪贴板中的图片(截图或文件)并上传。实现流程如下:

  1. 监听粘贴事件:在ProseMirror的编辑视图中捕获paste事件。
  2. 解析DataTransfer:检查event.clipboardData.items,遍历找到typeimage/开头的项。
  3. 读取文件:通过FileReader读取图片文件为DataURL(base64格式)。
  4. 插入占位符:立即在光标处插入一个带有srcDataURL的临时图片节点,并附加一个uploading的CSS类(如显示旋转加载图标),给用户即时反馈。
  5. 异步上传:将图片文件(Blob对象)通过FormData或直接二进制流上传到你的后端或图床服务(如OSS、Cloudinary)。
  6. 替换链接:上传成功后,获取返回的永久URL,通过ProseMirror事务,找到对应的临时图片节点,将其src属性替换为永久URL,并移除uploading类。
  7. 失败处理:上传失败时,可以将图片节点替换为一段错误提示文本,或者提供一个重新上传的按钮。

实操心得:图片上传一定要做好并发管理和失败重试。例如,用户快速粘贴多张图片时,需要维护一个上传队列。同时,临时图片的DataURL可能会很大,如果用户粘贴后立即关闭页面,可能造成数据丢失。一种更优的方案是,先将图片文件暂存到浏览器的IndexedDB中,上传成功后再清理,这样即使网络中断,重新打开页面也能恢复上传任务。

3.4 大纲导航与文档状态管理

对于长文档,大纲导航是刚需。实现原理是监听文档变化,从ProseMirror文档树中遍历出所有标题节点(heading)。

  1. 提取大纲:编写一个函数,遍历文档的content,收集所有类型为headinglevel在1-6之间的节点。记录其文本内容、层级(level)以及在文档中的位置(pos)。
  2. 生成导航DOM:根据提取的数据,渲染一个嵌套的列表(<ul><li>)作为大纲视图。每个<li>的缩进由标题层级决定。
  3. 滚动联动
    • 点击大纲跳转:为每个<li>绑定点击事件,触发时,使用ProseMirror的tr.setSelection将编辑器光标定位到对应标题的位置,并滚动到视图中。
    • 编辑区域滚动时高亮大纲:监听编辑器的滚动事件,计算当前视口内最顶部的标题是哪个,然后在大纲视图中高亮对应的<li>项。这里需要用到getBoundingClientRect来比较标题元素与视口的相对位置。
  4. 性能优化:遍历整个文档计算大纲在长文档下可能耗时。可以使用ProseMirror的Node对象的descendants方法进行高效遍历,并对结果进行缓存,仅在文档结构真正改变时(通过比较文档的哈希或版本号)重新计算。

4. 性能优化与深度定制实践

当核心功能完成后,一个工业级的编辑器必须经过性能优化的淬炼,并开放足够的定制能力。

4.1 应对长文档的渲染性能挑战

万级字数的文档是对编辑器性能的终极考验。我们的优化策略是多层次的:

  • 视图复用与虚拟滚动:这是最核心的优化。ProseMirror默认会为文档中的每个节点创建一个对应的DOM元素。对于超长文档,这会导致DOM节点数爆炸。我们可以实现一个自定义的节点视图(NodeView),对于段落、列表项等大量重复的简单节点,在滚动出视口时,将其对应的DOM元素回收到一个池子里,当需要渲染新的同类节点时,从池中复用并更新内容。这需要手动管理DOM的挂载(mount)与卸载(unmount)。更复杂的方案是集成类似react-window的虚拟滚动库,只渲染视口附近的节点。

  • 节流(Throttle)与防抖(Debounce):将高开销操作(如语法高亮、大纲重新计算、拼写检查)与频繁触发的事件(如输入、滚动)解耦。使用防抖确保在用户停止输入后再执行,使用节流保证在一定时间间隔内只执行一次。

  • 选择性重绘:充分利用ProseMirror的增量更新机制。在更新视图时,确保只对受事务影响的DOM子树进行修改,避免全量更新。

4.2 插件化系统与主题定制设计

为了让编辑器能被不同项目灵活使用,必须设计良好的扩展机制。

  • 插件系统:我们借鉴ProseMirror和Remark的插件设计。一个插件就是一个包含nameschema(扩展节点/标记)、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为例,简述集成思路:

  1. 选择OT库:例如sharedbot.js
  2. 定义操作:将ProseMirror的每一次事务(Transaction)序列化为一个OT操作。这个操作需要描述从旧文档状态到新状态的增量变化(如“在位置N插入字符串‘abc’”、“删除位置M到N的字符”)。
  3. 客户端集成:在编辑器中,本地事务产生后,先通过OT算法与本地待发送的操作队列进行转换(如果需要),然后发送到协同服务器。同时,监听服务器广播的其他用户的操作,将其转换后,通过view.dispatch应用到本地ProseMirror文档中。
  4. 冲突解决:OT算法的核心就是保证无论操作以何种顺序到达,最终所有客户端的文档状态都是一致的。这要求操作必须是可交换、可关联的。

注意事项:协同编辑的实现复杂度极高,涉及网络延迟、离线恢复、光标同步(显示其他用户的光标和选区)等诸多挑战。对于大多数项目,如果不需要实时强协同,可以考虑更简单的“自动保存+冲突检测”模式,即在用户保存时提示文档已被他人修改,并提供合并或覆盖选项。

5. 开发、调试与集成指南

5.1 构建与发布流程

项目采用Vite+TypeScript构建。库的最终输出目标是多种格式,以适配不同使用环境:

  • dist/index.esm.js: ES模块格式,供现代构建工具(如Vite, Webpack)直接导入。
  • dist/index.umd.js: UMD格式,可直接通过<script>标签引入,全局变量暴露。
  • dist/index.css: 提取出的所有CSS样式。

package.json中需要正确配置mainmoduleunpkgtypes等字段。使用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.appendspec.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这两个顶级开源项目过程中,对编辑器技术、数据结构(树、事务)、渲染性能优化理解的巨大提升。最终,当你看到用户在你的编辑器中流畅地写作,忘记了工具的存在时,那种成就感,是对所有埋头编码夜晚的最好回报。