1. 从致敬到实践:为什么我们要再造一个“所见即所得”的Markdown编辑器
作为一个常年与文字和代码打交道的人,我几乎每天都要和Markdown打交道。写技术文档、记笔记、甚至写这篇分享,Markdown都是我的首选。它简洁、高效,能让我专注于内容本身,而不是格式的调整。在众多Markdown编辑器中,Typora无疑是一个里程碑式的存在。它首创的“所见即所得”(What You See Is What You Get, WYSIWYG)编辑模式,彻底改变了人们编写Markdown的体验——你无需在源码视图和预览视图之间来回切换,输入标记符号的瞬间,格式就实时渲染出来了,这种流畅感让人着迷。
然而,正是这种极致的体验,让我萌生了自己动手开发一个类似编辑器的念头。这不仅仅是“致敬”Typora那么简单。一方面,Typora在后期转向了收费模式,虽然完全理解并尊重开发者的劳动,但这确实为部分用户设置了一道门槛。另一方面,作为一个开发者,我总想深入理解那些优秀产品背后的技术原理,看看自己能否用不同的技术栈、不同的设计思路,实现类似甚至在某些细节上有所不同的体验。更重要的是,市面上很多在线或离线的Markdown编辑器,要么功能臃肿,要么体验割裂,总感觉差那么点意思。我想打造一个完全符合自己工作流、轻量且核心体验不打折的工具。
于是,这个项目就开始了。我的目标很明确:开发一个纯粹的、桌面端的所见即所得Markdown编辑器。它应该拥有Typora那种行云流水的编辑体验,同时保持极简的界面,并且最终能打包成独立的可执行文件。在这个过程中,我深入研究了实时渲染、语法解析、编辑器内核、桌面应用打包等一系列技术点,也踩了不少坑。今天,我就把这整个过程,从技术选型到核心实现,再到那些只有亲手做过才会知道的细节,毫无保留地分享出来。
2. 技术栈选型:为什么是这些组合?
在启动项目之前,技术选型是第一个也是最重要的决策。它决定了开发的效率、应用的性能、未来的可维护性以及最终的用户体验。我的核心诉求是:跨平台(至少覆盖Windows和macOS)、高性能的实时渲染、接近原生应用的体验、以及相对现代的开发模式。
经过一番调研和权衡,我最终确定了以下技术栈:
- 应用框架:Electron。这是最没有悬念的选择。虽然近年来有Tauri等更轻量的方案,但Electron的成熟度、社区生态和对于复杂桌面应用(尤其是需要深度集成Web技术)的支持是无与伦比的。它允许我们使用Web前端技术(HTML, CSS, JavaScript)来构建整个应用界面,同时通过Node.js获得完整的系统API访问能力。这对于一个需要文件系统操作、本地存储、系统菜单等功能的编辑器来说至关重要。
- 编辑器内核:CodeMirror 6。这是整个项目的核心。我们需要一个强大的、可扩展的文本编辑器基础。为什么不直接用
<textarea>或者contenteditable?因为它们对于实现语法高亮、复杂选区、撤销重做、以及最重要的——实时将Markdown语法令牌(token)替换为渲染后的元素——来说,能力远远不够。ProseMirror和CodeMirror是业界的两个标杆。我选择CodeMirror 6的原因在于其全新的、模块化的架构。它将文档模型、视图、状态管理等彻底解耦,通过“状态(State)”和“视图(View)”分离的设计,使得实现“所见即所得”这种需要动态替换文档内容的功能变得更加清晰和可控。相比之下,虽然ProseMirror在协同编辑等领域更强,但CodeMirror 6的API对于实现我们这种单用户、强渲染的场景感觉更直观。 - Markdown解析与渲染:Marked.js + 自定义渲染器。Markdown的处理流程是:将原始文本解析成抽象的语法树(AST),然后再根据AST生成HTML。
Marked.js是一个速度快、兼容性好的Markdown解析器。它的优势在于允许我们完全自定义渲染器(Renderer)。这意味着,对于解析出的每一个语法节点(如标题、代码块、粗体),我们都可以定义它最终在“所见即所得”视图中应该被转换成什么样的DOM结构。这是我们实现视觉化编辑的关键。 - 界面与样式:React + Tailwind CSS。为了提升开发效率和保证UI的一致性,我选择了React作为UI框架。配合Tailwind CSS这种实用优先的CSS框架,可以快速构建出美观、响应式的界面,而无需在样式文件间来回切换,非常适合这种需要精细调整样式的编辑器项目。
- 数据持久化与状态管理:Zustand + 本地文件。对于编辑器的应用状态(如当前主题、编辑器设置、打开的文件列表等),我使用了轻量级的Zustand库。对于文档内容本身,则直接读写本地文件系统。Electron的主进程(Main Process)提供了
fs模块,可以安全地进行文件操作。
注意:这里有一个关键考量。为什么不直接用现成的、基于Web的Markdown编辑器框架(比如一些Vue或React的组件库)?因为它们通常被设计为在浏览器中运行,其文件操作、系统集成、菜单管理等功能是缺失的,或者需要大量额外工作来适配桌面环境。从零开始基于Electron和底层编辑器库构建,虽然前期工作量更大,但能获得最大的控制权和最贴近需求的架构。
2.1 核心挑战定义:什么是真正的“所见即所得”?
在技术选型之后,我们必须明确要解决的核心技术挑战。对于Markdown编辑器,“所见即所得”并不是简单地把渲染后的HTML直接塞进一个div并设置contenteditable=true。那样做会带来灾难性的编辑体验:光标难以控制,格式容易在编辑时被破坏,撤销重做逻辑混乱。
我们需要的是一种“混合式”编辑体验:
- 在视觉上,用户看到的是渲染后的格式(如加粗的文字、不同级别的标题、代码块的高亮)。
- 在编辑行为上,用户仍然是在操作原始的Markdown文本。当光标移动到一段加粗文字中间时,后台的文档模型知道光标实际在
**粗体文字**这个字符串的某个位置。 - 在交互上,输入或删除字符应该遵循Markdown语法规则。例如,在加粗文本的中间输入新字符,新字符应该自动成为加粗格式的一部分(即被
**包围)。
因此,我们的核心任务就变成了:如何在一个基于文本的编辑器视图(CodeMirror)中,动态地、无感地将特定的Markdown语法片段,替换为对应的、不可编辑的视觉元素(如一个代表加粗的<strong>标签包裹的文本),同时保持底层的文本模型(Text Model)的完整性和可编辑性?
这听起来有点绕,但可以把它想象成高级的“语法高亮”。普通的语法高亮只是给文字上色。而我们的“WYSIWYG渲染”则是把某一段文字(如**text**)替换成一个视觉组件(如<strong>text</strong>),但这个组件在编辑器看来,仍然对应着原始的那段文字**text**。
3. 架构设计与核心实现流程
基于上面的挑战,我设计了以下的核心架构和数据流。整个应用可以粗略分为三层:呈现层(React UI)、编辑器核心层(CodeMirror + 渲染引擎)和系统集成层(Electron Main Process)。
用户输入/操作 ↓ [呈现层:React组件] | (发送动作指令) ↓ [编辑器核心层:CodeMirror View + 自定义扩展] | (文档变更、触发重渲染) ↓ [Marked.js 解析 + 自定义渲染器] | (生成装饰器 Decoration) ↓ [CodeMirror 视图更新] | (显示为视觉元素) ↓ 用户看到“所见即所得”效果3.1 第一步:搭建基础的CodeMirror编辑器
首先,我们需要在React组件中初始化一个最基础的CodeMirror编辑器。
// 在React组件中 import { EditorView, basicSetup } from '@codemirror/basic-setup'; import { EditorState } from '@codemirror/state'; import { keymap } from '@codemirror/view'; import { defaultKeymap } from '@codemirror/commands'; function MyEditor() { const editorRef = useRef(null); useEffect(() => { if (!editorRef.current) return; const startState = EditorState.create({ doc: '# Hello World\n\nThis is **bold** text.', // 初始文档 extensions: [ basicSetup, // 基础功能扩展(快捷键、行号等) keymap.of(defaultKeymap), // 默认快捷键 // 这里未来会加入我们的“所见即所得”扩展 ], }); const view = new EditorView({ state: startState, parent: editorRef.current, }); // 清理函数 return () => view.destroy(); }, []); return <div ref={editorRef} />; }现在,我们得到了一个可以编辑纯文本的编辑器,但它还只是显示Markdown源码。
3.2 第二步:创建动态装饰器(Decorations)——实现渲染的关键
CodeMirror 6 中有一个核心概念叫“装饰器(Decoration)”。装饰器允许你在文档的某个范围(Range)上附加额外的DOM结构或样式,而不会改变文档本身的文本内容。这完美契合了我们的需求:在**text**这段文本的位置上,附加一个视觉上看起来是<strong>text</strong>的装饰。
我们需要创建一个CodeMirror扩展(Extension),这个扩展会做以下几件事:
- 监听文档的变化。
- 每当文档变化后,获取全文内容。
- 使用
Marked.js的解析器(Lexer)将全文解析成令牌(Tokens)流。注意,我们这里不直接生成HTML,而是获取语法结构信息。 - 遍历这些令牌,为每一个需要“视觉化”的令牌(如强调、加粗、行内代码、标题等)计算其在文档中的起止位置。
- 根据令牌类型,创建对应的装饰器。例如,对于加粗令牌,我们创建一个
Decoration.replace(),它会在指定位置用一个<strong>元素替换掉原始的**和**,但只替换其显示,底层文档坐标不变。 - 将所有装饰器收集起来,返回给CodeMirror视图进行绘制。
下面是一个高度简化的核心代码片段,展示了如何创建一个返回装饰器的视图插件(View Plugin):
import { ViewPlugin, Decoration, WidgetType } from '@codemirror/view'; import { RangeSetBuilder } from '@codemirror/state'; import { marked } from 'marked'; // 1. 定义一个Widget,用于表示加粗文本的视觉元素 class BoldWidget extends WidgetType { constructor(text) { super(); this.text = text; } toDOM() { let span = document.createElement('span'); span.innerHTML = `<strong>${this.text}</strong>`; // 关键:这个元素本身不可编辑,且不会影响光标导航 span.contentEditable = 'false'; span.style.cssText = 'font-weight: bold;'; return span; } } // 2. 创建视图插件 const wysiwygPlugin = ViewPlugin.fromClass( class { constructor(view) { this.decorations = this.buildDecorations(view); } update(update) { if (update.docChanged || update.viewportChanged) { this.decorations = this.buildDecorations(update.view); } } buildDecorations(view) { const builder = new RangeSetBuilder(); const doc = view.state.doc; const text = doc.toString(); // 使用marked的lexer获取令牌流 const tokens = marked.lexer(text); // 递归遍历令牌(这里简化处理,只找加粗) function processTokens(tokens, startPos) { for (let token of tokens) { if (token.type === 'strong') { // marked.js中加粗的令牌类型是'strong' // token.text 是去掉**后的内容,如“text” // 我们需要计算它在原文中的位置。这里是个难点! // 实际上,marked的令牌没有直接提供在原字符串中的索引。 // 我们需要自己通过遍历原文,结合正则表达式来定位。 // 这是一个简化示例,假设我们能计算出from和to。 const from = startPos + token.position.start.offset; // 这是理想情况,实际marked的position对象可能不准确 const to = from + token.raw.length; // token.raw 是包含**的原始字符串“**text**” // 创建装饰器,用Widget替换from到to的显示区域 const deco = Decoration.replace({ widget: new BoldWidget(token.text), block: false, }); builder.add(from, to, deco); } // 处理其他令牌类型... if (token.tokens) { startPos = processTokens(token.tokens, startPos); // 递归处理嵌套结构 } } return startPos; // 返回处理到的位置 } processTokens(tokens, 0); return builder.finish(); } }, { decorations: v => v.decorations } // 这个插件提供decorations ); // 3. 将这个插件加入到编辑器的extensions中 const startState = EditorState.create({ doc: '# Hello World\n\nThis is **bold** text.', extensions: [basicSetup, wysiwygPlugin], // 加入我们的插件 });这段代码揭示了实现过程中最棘手的问题之一:令牌定位(Token Positioning)。Marked.js解析出的令牌(Token)默认不包含它在原始字符串中精确的字符索引(position属性可能不完整或不准)。这意味着,我们无法简单地将一个令牌映射回编辑器文档中的from和to位置。
解决方案:我最终没有完全依赖Marked.js的position。而是采用了一种更可靠但也更复杂的方法:在解析的同时,使用一个指针同步扫描原始文本。当Marked.js的lexer产出令牌时,我根据令牌的类型(如**、#)和内容,用正则表达式在指针当前位置的文本中进行匹配,从而精确计算出该段语法在原文中的起止位置。这保证了装饰器能精准地覆盖到对应的源码片段。
3.3 第三步:处理编辑与光标交互
仅仅显示装饰器还不够。当用户点击一个被渲染成加粗的文字时,光标应该落在哪里?当用户在加粗文字中间输入时,新输入的文字应该是什么格式?
这是通过装饰器的inclusive、block等属性,以及CodeMirror本身的选区(Selection)和事务(Transaction)系统来协同处理的。在上面的BoldWidget中,我们设置了contentEditable='false',这会让CodeMirror将这个Widget视为一个不可编辑的原子单元。光标无法进入其内部,但可以落在它的前面或后面。
但这并不完美。理想情况是,用户感觉自己在直接编辑“加粗的文字”,而不是在编辑**text**。为了实现这一点,我们需要更精细的控制:
- 光标感知:我们需要监听光标移动事件。当光标靠近或进入一个装饰器的“影响范围”时,我们可以通过计算,将视图中的光标位置(在Widget旁边)映射回底层文档中对应的原始文本位置(在
**内部)。这需要重写CodeMirror的部分光标定位逻辑。 - 输入处理:当用户在装饰器对应的文本范围内输入时,我们需要确保输入的内容被正确的Markdown语法符号包围。例如,在
**text|**(|代表光标)处输入“new”,结果应该是**textnew**,而不是**text**new。这需要通过监听输入事件,判断输入发生的位置是否在某个语法装饰器内,然后对应地修改文档事务(Transaction),自动插入或维护语法符号。
这部分是编辑器交互体验的“灵魂”,也是最耗费精力的部分。我参考了CodeMirror官方关于replace装饰器和自定义输入处理的示例,编写了大量的边界条件判断(比如处理删除操作时,是删除一个语法符号还是删除被包裹的文本)。
3.4 第四步:扩展更多Markdown元素
解决了加粗(Strong)和斜体(Em)这类行内元素后,块级元素(Block Elements)如标题、代码块、引用块、列表等是下一个挑战。
对于块级元素,策略有所不同。例如标题# Heading,我们可能希望将整个行替换为一个更大字体的视觉块。这时使用Decoration.replace并设置block: true是合适的。但对于代码块(```)和列表,情况更复杂,因为它们可能是多行的,并且有嵌套结构。
- 代码块:我将其处理为一个独立的Widget,内部使用
highlight.js来实现语法高亮。这个Widget完全替换掉从 ``` 到下一个 ``` 之间的所有行。编辑时,点击代码块会聚焦到整个块的开始或结束处,要修改代码内容,需要“进入”代码块模式(类似Typora,临时显示源码)。 - 列表:列表的渲染和交互极其复杂。需要渲染出项目符号或数字,并且要处理缩进、多级列表、任务列表(
- [ ])等。我的实现方式是,为每一行列表项创建一个装饰器,装饰器左侧添加一个自定义绘制的项目符号(通过CSS或SVG)。同时,需要重写回车键、Tab键和Shift+Tab键的行为,以智能地创建新列表项或调整缩进级别。
4. 踩坑实录:那些只有动手做才知道的细节
理论很美好,实践起来处处是坑。下面分享几个让我调试了最久的典型问题。
4.1 装饰器更新性能与抖动
最初,我的wysiwygPlugin在update方法中,只要文档一变化(docChanged)就全文档重新解析并构建装饰器。当文档超过几百行时,频繁输入会导致明显的卡顿和视图抖动。
优化方案:
- 节流与增量更新:不是每次变化都全量解析。利用CodeMirror的
update.viewportChanged和changedRanges属性。我们可以只重新解析并渲染视口内以及发生变化的那部分文本所影响的区域。这需要维护一个装饰器的“缓存池”,并能进行局部的增删改。 - 异步解析:将Markdown解析和装饰器构建过程放到
requestIdleCallback或Web Worker中,避免阻塞UI线程。但要注意光标和选区状态的同步。 - 简化解析:对于正在快速输入的当前行,可以采用一个更轻量、更快速的解析器(或简单的正则表达式)进行即时预览,待用户停止输入一段时间后,再用完整的
Marked.js进行精确解析。
4.2 中文输入法(IME)兼容性问题
在中文、日文等需要IME(输入法)组合输入的文字时,问题出现了。在装饰器替换的区域,IME的候选词框可能会错位,或者在输入过程中,装饰器频繁重建导致候选词消失。
根因:IME输入是一个复合过程(composition),在最终确认前,编辑器会接收到一系列compositionstart、compositionupdate和compositionend事件。我们的装饰器在每次compositionupdate(每敲一个拼音字母)时都可能触发重建,干扰了IME的正常工作。
解决方案:在CodeMirror的视图插件中,需要检测组合输入状态。当compositionstart事件触发时,暂时“冻结”或标记当前活动编辑区域的装饰器更新逻辑,直到compositionend事件触发后,再统一更新。CodeMirror的EditorView本身对IME有基础支持,但和我们的自定义装饰器结合时需要额外小心处理状态同步。
4.3 复制粘贴的格式处理
用户从外部(如网页)复制富文本内容并粘贴到编辑器时,我们期望它能被转换为Markdown。反之,从编辑器复制“所见即所得”的文本到其他地方(如Word),我们期望它能携带基本的格式(如加粗、标题)。
实现方案:
- 粘贴HTML转Markdown:监听粘贴事件(
handlePaste扩展),从剪贴板中获取text/html数据。然后使用一个库如Turndown或html-to-md,将HTML转换为Markdown字符串,再插入到编辑器中。这里需要处理一些不规范的HTML标签。 - 复制时提供多种格式:重写复制事件(
copy)。当用户复制编辑器内的内容时,我们除了提供纯文本(text/plain,即Markdown源码)格式外,还可以同时提供富文本(text/html)格式。这样粘贴到支持富文本的地方就能保留格式。生成HTML时,可以直接调用我们已有的、用于渲染的Marked.js自定义渲染器。
4.4 撤销/重做(Undo/Redo)堆栈管理
由于我们的编辑操作不仅仅是文本的增删,还伴随着装饰器的动态创建和销毁,这可能会扰乱CodeMirror默认的撤销历史记录。
解决方案:CodeMirror的EditorState是 immutable(不可变)的,状态变更通过事务(Transaction)进行。我们的装饰器是作为视图插件(ViewPlugin)的一部分,其状态(decorations)也是EditorState的一部分。因此,只要我们的装饰器构建过程是纯函数的(即相同的文档内容总是生成相同的装饰器集合),那么撤销/重做就能正常工作。CodeMirror在撤销时,会回滚到之前的EditorState,其中自然包含了当时的decorations状态。关键在于,我们的buildDecorations函数不能依赖任何外部可变状态。
5. 超越编辑:打包、优化与功能完善
当核心的编辑体验基本跑通后,就进入了“产品化”阶段。
5.1 使用Electron Builder打包分发
Electron Builder是目前最流行的Electron应用打包工具。配置文件electron-builder.yml或package.json中的build字段是关键。
// package.json 片段 { "name": "my-markdown-editor", "version": "1.0.0", "main": "main.js", "scripts": { "start": "electron .", "pack": "electron-builder --dir", "dist": "electron-builder" }, "build": { "appId": "com.yourname.markdown-editor", "productName": "My Markdown Editor", "directories": { "output": "dist" }, "files": [ "!**/node_modules/*/{CHANGELOG.md,README.md,README,readme.md,readme}", "!**/node_modules/*/{test,__tests__,tests,powered-test,example,examples}", "!**/node_modules/*.d.ts", "!**/*.{iml,o,hprof,orig,pyc,pyo,rbc,swp,csproj,sln,xproj}", "!.editorconfig", "!**/._*", "!**/{.DS_Store,.git,.hg,.svn,CVS,RCS,SCCS,.gitignore,.gitattributes}", "!**/{__pycache__,thumbs.db,.flowconfig,.idea,.vs,.nyc_output}", "!**/{appveyor.yml,.travis.yml,circle.yml}", "!**/{npm-debug.log,yarn.lock,.yarn-integrity,.yarn-metadata.json}" ], "mac": { "category": "public.app-category.productivity", "icon": "build/icon.icns" }, "win": { "target": ["nsis"], "icon": "build/icon.ico" }, "linux": { "target": ["AppImage"], "icon": "build/icon.png" } } }打包优化心得:
- 依赖修剪:通过
files字段精确控制哪些文件被打包,排除开发文档、测试文件等,能显著减小应用体积。 - 原生模块(Native Modules):如果你的项目依赖了需要编译的Node.js原生模块(如某些数据库驱动),需要确保它们与目标Electron版本兼容,并在打包环境中为所有目标平台(Windows, macOS, Linux)提前编译好。
- 代码签名与公证:对于macOS应用,代码签名和公证(Notarization)是上架或避免安全警告的必经步骤,过程比较繁琐,需要Apple开发者账号。
5.2 添加实用功能
一个基本的编辑器成型后,可以围绕Markdown工作流添加一系列实用功能:
- 文件树与多标签页:使用React状态管理当前打开的文件,利用Electron的
dialog模块实现文件打开/保存对话框。 - 主题切换:将编辑器和预览的CSS样式抽象为主题对象,用户切换时动态加载对应的CSS文件或更新CSS变量。
- 导出功能:集成
pandoc(通过Node.js子进程调用)或使用纯JavaScript库(如markdown-pdf),实现导出PDF、Word、HTML等功能。这是一个深坑,因为排版和样式控制非常复杂。 - 图床集成:粘贴或拖入图片时,自动上传到配置好的图床(如SM.MS、阿里云OSS等),并将返回的URL以Markdown图片格式插入。这极大提升了插入图片的体验。
- 专注模式与打字机模式:纯粹通过CSS和编辑器视图的滚动逻辑实现,让当前编辑行始终处于屏幕中央或特定位置。
5.3 性能监控与调试
开发后期,性能优化至关重要。我主要使用以下工具:
- Chrome DevTools (Electron):通过
mainWindow.webContents.openDevTools()打开开发者工具,使用Performance面板录制分析渲染性能,查找导致卡顿的函数。 - Electron Fiddle:用于快速创建和测试Electron代码片段,隔离问题。
- 自定义日志:在关键路径(如装饰器构建、文件读写)添加性能计时日志,在生产环境中可以开关。
6. 回顾与展望:从项目中学到了什么?
这个项目从构想到实现一个可用的版本,断断续续花了近三个月的时间。它远未达到Typora那样精致和稳定,但核心的“所见即所得”编辑体验已经基本实现。回顾整个过程,最大的收获不是做出了一个工具,而是深入理解了现代编辑器设计的复杂性。
我认识到,一个优秀的编辑器,是数据结构(文档模型)、算法(解析与渲染)、人机交互(光标、键盘、鼠标)和系统工程(性能、打包、扩展)的紧密结合。CodeMirror 6的架构设计给了我很大启发,其状态与视图分离、基于事务的变更、插件化的扩展机制,都是构建复杂编辑器的优秀范式。
对于也想尝试类似项目的朋友,我的建议是:从小处着手,逐步迭代。不要一开始就想实现所有Markdown语法。可以先从最简单的加粗、斜体开始,把“动态替换”这个核心流程跑通。然后处理标题、代码块,最后再挑战列表、表格等复杂结构。每实现一个语法,你都会对编辑器的运作机制有更深的理解。
这个项目也让我对Typora这样的优秀作品更加敬佩。它背后所隐藏的工程细节和交互设计思考,远比表面上看起来的“简洁流畅”要多得多。自己动手实现一遍,是最好的致敬方式。未来,我可能会继续完善它,比如尝试用Tauri重写以减小体积,或者增加插件系统。但无论如何,这段开发经历本身,已经是一笔宝贵的财富。