尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于Electron与CodeMirror 6构建所见即所得Markdown编辑器的技术实践

基于Electron与CodeMirror 6构建所见即所得Markdown编辑器的技术实践 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并设置contenteditabletrue。那样做会带来灾难性的编辑体验光标难以控制格式容易在编辑时被破坏撤销重做逻辑混乱。我们需要的是一种“混合式”编辑体验在视觉上用户看到的是渲染后的格式如加粗的文字、不同级别的标题、代码块的高亮。在编辑行为上用户仍然是在操作原始的Markdown文本。当光标移动到一段加粗文字中间时后台的文档模型知道光标实际在**粗体文字**这个字符串的某个位置。在交互上输入或删除字符应该遵循Markdown语法规则。例如在加粗文本的中间输入新字符新字符应该自动成为加粗格式的一部分即被**包围。因此我们的核心任务就变成了如何在一个基于文本的编辑器视图CodeMirror中动态地、无感地将特定的Markdown语法片段替换为对应的、不可编辑的视觉元素如一个代表加粗的strong标签包裹的文本同时保持底层的文本模型Text Model的完整性和可编辑性这听起来有点绕但可以把它想象成高级的“语法高亮”。普通的语法高亮只是给文字上色。而我们的“WYSIWYG渲染”则是把某一段文字如**text**替换成一个视觉组件如strongtext/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**这段文本的位置上附加一个视觉上看起来是strongtext/strong的装饰。我们需要创建一个CodeMirror扩展Extension这个扩展会做以下几件事监听文档的变化。每当文档变化后获取全文内容。使用Marked.js的解析器Lexer将全文解析成令牌Tokens流。注意我们这里不直接生成HTML而是获取语法结构信息。遍历这些令牌为每一个需要“视觉化”的令牌如强调、加粗、行内代码、标题等计算其在文档中的起止位置。根据令牌类型创建对应的装饰器。例如对于加粗令牌我们创建一个Decoration.replace()它会在指定位置用一个strong元素替换掉原始的**和**但只替换其显示底层文档坐标不变。将所有装饰器收集起来返回给CodeMirror视图进行绘制。下面是一个高度简化的核心代码片段展示了如何创建一个返回装饰器的视图插件View Pluginimport { 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中我们设置了contentEditablefalse这会让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键和ShiftTab键的行为以智能地创建新列表项或调整缩进级别。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重写以减小体积或者增加插件系统。但无论如何这段开发经历本身已经是一笔宝贵的财富。
返回列表