1. 项目概述:为什么我们需要一个“轻量级”的Markdown编辑器?
如果你和我一样,日常工作中需要大量处理技术文档、项目笔记或者博客草稿,那么Markdown几乎是你离不开的工具。它的简洁语法让我们能专注于内容本身,而不是排版。市面上优秀的Markdown编辑器很多,从功能强大的Typora、Obsidian,到在线协作的Notion、语雀。但用久了,你可能会发现一些痛点:要么是软件本身过于臃肿,启动缓慢,占用几百MB甚至上GB的内存;要么是功能虽强,但预览渲染不支持你需要的特定语法,比如复杂的数学公式(KaTeX)或者架构图(Mermaid);再或者,你只是想要一个能离线使用、即开即用、不依赖网络、也不收集你隐私数据的本地工具。
这就是我动手打造这个轻量级Markdown编辑器的初衷。它的核心目标非常明确:在约15MB的极简体积内,集成实时预览、完整的KaTeX数学公式渲染以及Mermaid图表支持这三项对技术写作至关重要的功能。15MB是什么概念?大概相当于几张高清照片的大小,却承载了一个功能完备的写作环境。这不仅仅是体积上的“轻”,更是体验上的“快”和“专”。它不试图成为一个全能的笔记管理软件,而是聚焦于“写作与即时渲染”这一核心场景,让你在灵感迸发时,没有任何工具上的拖累。
这个项目适合谁?如果你是开发者、学生、技术博主,或者任何需要经常撰写包含代码、公式和流程图文档的人,并且厌倦了重型软件的缓慢,或者在线工具的不稳定,那么这个自建的小工具可能会成为你的得力助手。接下来,我将详细拆解我是如何从零开始,一步步实现这个“小而美”的编辑器的,其中包含大量的技术选型思考、实操细节以及我踩过的坑。
2. 核心架构与技术选型解析
构建这样一个编辑器,本质上是在打造一个桌面端的富文本编辑与渲染环境。我们需要一个用户界面来输入和编辑Markdown文本,同时需要一个渲染引擎来将Markdown语法实时转换为美观的HTML视图。此外,还需要嵌入KaTeX和Mermaid的运行时库来处理特殊语法。
2.1 为什么选择Electron + React组合?
这是整个项目的技术基石。虽然目标是轻量,但“轻量”不等于“简陋”。我们需要一个能跨平台(Windows、macOS、Linux)运行的桌面应用框架。
- Electron的考量:Electron允许我们使用Web技术(HTML, CSS, JavaScript)来构建桌面应用。这意味着UI开发效率极高,且拥有海量的Web生态资源。很多人诟病Electron应用“吃内存”,但这往往是因为应用本身设计不佳或捆绑了过多功能。通过精心控制依赖和优化打包,完全可以将Electron应用的体积和内存占用控制在一个很低的水平。我们的目标15MB,就是对这一点的挑战和证明。
- React的考量:作为UI库,React的组件化思想非常适合我们这种状态驱动型应用。编辑器的核心状态就是当前的Markdown文本。文本变化,预览视图需要同步更新。React的响应式数据流和虚拟DOM能高效、清晰地管理这种“编辑区-预览区”的联动。相比直接操作DOM,用React来管理预览视图的渲染更不容易出错,也便于后续功能扩展。
注意:选择Electron意味着最终的
.exe或.dmg安装包会包含一个Chromium渲染引擎和Node.js运行时,这是其体积的主要来源。我们的优化重点就在于如何选用更小的依赖库,并通过打包工具(如electron-builder)进行极致压缩。
2.2 渲染引擎:Marked.js 与 安全净化
Markdown到HTML的转换需要一个解析器。我选择了marked.js。它速度快、支持CommonMark标准、扩展性强,并且社区活跃。
然而,直接将用户输入的Markdown通过marked转换成HTML并插入到页面中是极度危险的,因为这会导致XSS(跨站脚本)攻击。用户如果在Markdown中输入<script>alert('hack')</script>,这段脚本就会被执行。
解决方案是引入DOMPurify。DOMPurify是一个专门用于对HTML进行净化的库,它会移除所有危险的标签和属性,只保留安全的HTML。因此,我们的渲染流水线是:原始Markdown文本->marked.parse()->得到原始HTML->DOMPurify.sanitize()->得到安全HTML->插入到预览区域。
这个步骤至关重要,是编辑器安全性的底线。
2.3 核心特性集成:KaTeX与Mermaid
这是本编辑器区别于许多轻量级工具的关键。
- KaTeX集成:KaTeX是LaTeX数学公式的渲染库,以其极快的速度著称。它采用客户端渲染,不需要像MathJax那样加载庞大的字体文件。集成方式很简单:在HTML的
<head>中引入KaTeX的CSS文件,然后在渲染后的HTML中,找到所有被$$...$$或\(...\)包裹的数学公式块,调用katex.renderToString()方法将其转换为包含正确样式的HTML片段即可。关键在于,这个渲染过程需要在DOMPurify净化之后进行,因为KaTeX生成的HTML是已知安全的。 - Mermaid集成:Mermaid用于绘制流程图、时序图、甘特图等。它不是一个简单的“图片生成器”,而是一个运行时库。集成Mermaid需要两步:
- 在页面中引入Mermaid的JS库。
- 在预览区域渲染完成后,调用
mermaid.init()或mermaid.run(),它会自动查找页面中<pre class="mermaid">标签内的Mermaid代码,并将其渲染成SVG图形。
这里的一个技术难点在于时序:当用户快速输入时,我们需要确保Mermaid的渲染不会阻塞主线程,同时要避免在旧图表还未销毁时重复初始化,导致页面错乱。我的做法是使用一个防抖函数,在用户停止输入约500毫秒后,再执行Mermaid的渲染。
2.4 编辑器组件:放弃Monaco,拥抱CodeMirror
一个优秀的Markdown编辑器,编辑体验至关重要。微软VSCode的编辑器核心Monaco功能强大,但体积也非常庞大,动辄几MB,与我们的“轻量”目标背道而驰。
我选择了CodeMirror 6。它是CodeMirror的全新重写版本,模块化设计做得非常好。你可以只引入你需要的核心编辑模块、语言模式(markdown高亮)和基础主题,从而将编辑器核心的体积控制在1MB以内。CodeMirror 6提供了流畅的编辑体验、强大的扩展API以及良好的可访问性,完全满足我们的需求。
3. 详细实现步骤与核心代码剖析
下面,我将以功能模块为单位,拆解具体的实现步骤和关键代码。假设你已经用create-react-app初始化了一个Electron项目(或者使用electron-forge等工具),并安装了必要的依赖(react,marked,dompurify,katex,mermaid,@codemirror系列包)。
3.1 项目结构与状态设计
首先,规划我们的组件结构。这是一个非常典型的左右(或上下)分栏布局。
src/ ├── components/ │ ├── EditorPane.jsx // 左侧编辑面板,集成CodeMirror │ ├── PreviewPane.jsx // 右侧预览面板 │ └── Toolbar.jsx // 顶部工具栏(可选,如保存、主题切换) ├── lib/ │ ├── markdownParser.js // 封装marked和DOMPurify的解析逻辑 │ └── mermaidRenderer.js // 封装Mermaid的防抖渲染逻辑 ├── App.jsx // 主组件,管理状态和布局 └── main.js // Electron主进程文件在App.jsx中,我们使用React的useState来管理最核心的状态——当前的Markdown文本。
// App.jsx import React, { useState } from 'react'; import EditorPane from './components/EditorPane'; import PreviewPane from './components/PreviewPane'; import './App.css'; function App() { const [markdownText, setMarkdownText] = useState('# Hello, Markdown Editor\n\nStart writing here...'); const handleEditorChange = (newText) => { setMarkdownText(newText); }; return ( <div className="app-container"> <EditorPane value={markdownText} onChange={handleEditorChange} /> <PreviewPane content={markdownText} /> </div> ); } export default App;3.2 编辑面板(EditorPane)实现
这是与用户交互最频繁的部分。我们需要初始化CodeMirror,并将其绑定到React组件的生命周期中。
// components/EditorPane.jsx import React, { useEffect, useRef } from 'react'; import { EditorState } from '@codemirror/state'; import { EditorView, basicSetup } from '@codemirror/basic-setup'; import { markdown } from '@codemirror/lang-markdown'; import { oneDark } from '@codemirror/theme-one-dark'; // 一个流行的暗色主题 const EditorPane = ({ value, onChange }) => { const editorRef = useRef(null); const viewRef = useRef(null); useEffect(() => { if (!editorRef.current) return; // 创建编辑器状态 const startState = EditorState.create({ doc: value, extensions: [ basicSetup, markdown(), // 启用Markdown语法高亮 oneDark, // 应用主题 EditorView.updateListener.of((update) => { if (update.docChanged) { // 当文档内容变化时,调用父组件传入的onChange回调 const newText = update.state.doc.toString(); onChange(newText); } }), ], }); // 创建编辑器视图 const view = new EditorView({ state: startState, parent: editorRef.current, }); viewRef.current = view; // 组件卸载时销毁编辑器实例,防止内存泄漏 return () => { if (viewRef.current) { viewRef.current.destroy(); viewRef.current = null; } }; }, []); // 空依赖数组,仅初始化一次 // 当外部value变化时(如清空),同步更新编辑器内容 useEffect(() => { if (viewRef.current && value !== viewRef.current.state.doc.toString()) { const transaction = viewRef.current.state.update({ changes: { from: 0, to: viewRef.current.state.doc.length, insert: value, }, }); viewRef.current.dispatch(transaction); } }, [value]); return <div ref={editorRef} className="editor-pane"></div>; }; export default EditorPane;实操心得:CodeMirror 6的API是命令式(Imperative)的,而React是声明式(Declarative)的。将它们结合的关键在于
useEffect和useRef。useRef用来持有编辑器实例,useEffect负责在合适的生命周期(挂载、更新、卸载)中创建、更新和销毁编辑器。特别注意清理函数,避免内存泄漏。
3.3 解析与安全渲染核心(markdownParser.js)
这个模块是预览功能的心脏,它必须是高效且安全的。
// lib/markdownParser.js import { marked } from 'marked'; import DOMPurify from 'dompurify'; import katex from 'katex'; import 'katex/dist/katex.min.css'; // 引入KaTeX样式 // 配置marked,例如启用GFM(GitHub Flavored Markdown) marked.setOptions({ gfm: true, breaks: true, }); /** * 将Markdown文本转换为安全的、包含KaTeX公式的HTML字符串 * @param {string} markdown - 原始Markdown文本 * @returns {string} 安全的HTML字符串 */ export function parseMarkdownToSafeHTML(markdown) { // 1. 将Markdown转换为原始HTML const rawHtml = marked.parse(markdown); // 2. 使用DOMPurify进行XSS过滤 const cleanHtml = DOMPurify.sanitize(rawHtml, { // 允许一些必要的属性,如Mermaid需要的`data-processed` ADD_ATTR: ['data-processed'], }); // 3. 渲染KaTeX公式 // 我们使用一个临时DOM元素来操作 const tempDiv = document.createElement('div'); tempDiv.innerHTML = cleanHtml; // 查找行内公式 \(...\) 和块级公式 \[...\] 或 $$...$$ const inlinePattern = /\\\((.+?)\\\)/g; const blockPattern = /\\\[(.+?)\\\]|\$\$(.+?)\$\$/gs; // 注意`s`标志,使`.`匹配换行 function renderKatexInElement(element) { const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let node; const textNodes = []; while ((node = walker.nextNode())) { textNodes.push(node); } for (const textNode of textNodes) { let text = textNode.nodeValue; let newHtml = text; // 处理行内公式 newHtml = newHtml.replace(inlinePattern, (match, latex) => { try { return katex.renderToString(latex, { displayMode: false, throwOnError: false }); } catch (e) { console.warn('KaTeX渲染失败(行内):', e.message, '公式:', latex); return match; // 渲染失败则保留原文本 } }); // 处理块级公式 newHtml = newHtml.replace(blockPattern, (match, latex1, latex2) => { const latex = latex1 || latex2; try { return katex.renderToString(latex, { displayMode: true, throwOnError: false }); } catch (e) { console.warn('KaTeX渲染失败(块级):', e.message, '公式:', latex); return match; } }); if (newHtml !== text) { const span = document.createElement('span'); span.innerHTML = newHtml; textNode.parentNode.replaceChild(span, textNode); } } } // 对临时div中的所有元素进行公式渲染 renderKatexInElement(tempDiv); // 返回处理后的HTML字符串 return tempDiv.innerHTML; }这个函数做了三件事:解析Markdown、净化HTML、渲染KaTeX。它将安全的HTML字符串返回给预览组件。
3.4 预览面板(PreviewPane)与Mermaid渲染
预览面板接收解析后的HTML并插入到DOM中,同时负责触发Mermaid的渲染。
// components/PreviewPane.jsx import React, { useEffect, useRef } from 'react'; import { parseMarkdownToSafeHTML } from '../lib/markdownParser'; import { renderMermaidDiagrams } from '../lib/mermaidRenderer'; import './PreviewPane.css'; const PreviewPane = ({ content }) => { const previewRef = useRef(null); useEffect(() => { if (!previewRef.current) return; // 1. 解析Markdown并获取安全HTML const safeHtml = parseMarkdownToSafeHTML(content); // 2. 更新预览区域内容 previewRef.current.innerHTML = safeHtml; // 3. 异步渲染Mermaid图表 // 使用防抖函数,避免频繁渲染 const timer = setTimeout(() => { if (previewRef.current) { renderMermaidDiagrams(previewRef.current); } }, 500); // 防抖延迟500ms return () => clearTimeout(timer); // 清理上一次的定时器 }, [content]); // 依赖content,内容变化时触发 return <div ref={previewRef} className="preview-pane"></div>; }; export default PreviewPane;// lib/mermaidRenderer.js import mermaid from 'mermaid'; // 初始化Mermaid配置 mermaid.initialize({ startOnLoad: false, // 我们手动控制渲染,所以设为false theme: 'default', flowchart: { curve: 'basis' }, securityLevel: 'loose', // 根据需求调整安全级别 }); /** * 在指定的容器元素内渲染所有Mermaid图表 * @param {HTMLElement} container - 包含`.mermaid`代码块的DOM容器 */ export function renderMermaidDiagrams(container) { // 找到所有未被处理过的mermaid代码块 const mermaidElements = container.querySelectorAll('pre.mermaid:not([data-processed])'); if (mermaidElements.length === 0) return; // 为每个元素添加处理标记,防止重复渲染 mermaidElements.forEach((element) => { element.setAttribute('data-processed', 'true'); }); // 使用mermaid的run方法进行渲染 // mermaid.run()会自动查找带有`data-processed`标记的元素 // 注意:需要确保mermaid库已加载到全局window对象 try { mermaid.run({ nodes: mermaidElements, }); } catch (error) { console.error('Mermaid渲染失败:', error); // 可选:在渲染失败的元素上显示错误信息 mermaidElements.forEach(el => { if (!el.querySelector('.mermaid-error')) { const errorDiv = document.createElement('div'); errorDiv.className = 'mermaid-error'; errorDiv.style.color = 'red'; errorDiv.textContent = `Mermaid Diagram Error: ${error.message}`; el.appendChild(errorDiv); } }); } }注意事项:Mermaid的渲染是异步的,并且会直接操作DOM。
mermaid.run()是较新的API,比旧的mermaid.init()更推荐使用。确保在调用mermaid.run()之前,对应的<pre class=”mermaid”>元素已经存在于DOM中。防抖处理对于性能至关重要,尤其是在用户快速输入时。
3.5 样式与布局优化
为了让编辑器好用,基础的CSS布局必不可少。这里采用经典的左右分栏,并加上一个最小宽度。
/* App.css */ .app-container { display: flex; height: 100vh; width: 100vw; overflow: hidden; } .editor-pane, .preview-pane { flex: 1; min-width: 300px; height: 100%; overflow: auto; box-sizing: border-box; } .editor-pane { border-right: 1px solid #ddd; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.6; } .preview-pane { padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; line-height: 1.6; background-color: #fff; color: #24292e; } /* 预览区域内容的样式 */ .preview-pane h1, .preview-pane h2, .preview-pane h3 { border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; } .preview-pane code { background-color: rgba(27,31,35,0.05); border-radius: 3px; padding: 0.2em 0.4em; font-family: monospace; } .preview-pane pre { background-color: #f6f8fa; border-radius: 6px; padding: 16px; overflow: auto; } .preview-pane blockquote { border-left: 4px solid #dfe2e5; color: #6a737d; padding-left: 1em; margin-left: 0; }4. 打包、优化与体积控制实战
这是实现“~15MB”目标最关键也最挑战的一环。我们使用electron-builder进行打包。
4.1 依赖分析与Tree Shaking
首先,检查package.json中的依赖,确保没有引入不必要的库。
- 生产依赖(dependencies):只保留应用运行必须的库,如
react,react-dom,marked,dompurify,katex,mermaid,@codemirror/*。 - 开发依赖(devDependencies):
electron,electron-builder等打包工具放在这里。
利用ES6的import语法和构建工具(如Webpack,create-react-app已集成)的Tree Shaking功能,可以自动移除未使用的代码。确保你的@codemirror导入是细粒度的,例如:
import { basicSetup } from '@codemirror/basic-setup'; import { markdown } from '@codemirror/lang-markdown';而不是导入整个包。
4.2 Electron-Builder 配置精讲
在package.json中配置electron-builder。
{ "name": "lightweight-markdown-editor", "version": "1.0.0", "main": "public/electron.js", // 你的Electron主进程入口文件 "homepage": "./", "build": { "appId": "com.yourname.markdown-editor", "productName": "LightMark", "directories": { "output": "dist" // 输出目录 }, "files": [ "build/**/*", // 包含React构建后的静态文件 "node_modules/**/*", "package.json", "public/electron.js" ], "asar": true, // 将应用打包成asar归档,保护源码并提升读取性能 "compression": "maximum", // 最大程度压缩 "npmRebuild": false, // 如果没用原生模块,设为false加快打包 "mac": { "target": "dmg", "category": "public.app-category.productivity" }, "win": { "target": "nsis", "icon": "build/icon.ico" }, "linux": { "target": "AppImage", "category": "Utility" } }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "pack": "electron-builder --dir", // 仅生成解压的包,用于测试 "dist": "electron-builder", // 生成安装包 "electron:start": "electron ." // 开发环境启动Electron } }4.3 关键优化手段
- 压缩资源:确保React构建时启用了生产模式(
react-scripts build默认会做),它会压缩JS和CSS文件。 - 清理无用文件:在
files配置中精确指定需要打包的文件,避免将README.md,.gitignore等开发文件打包进去。 - 使用ASAR:
asar: true将应用代码打包成一个只读的归档文件,能减少文件数量,略微提升启动速度,并防止用户轻易修改源码。 - 图标优化:应用图标(.ico, .icns)不要使用尺寸过大的图片,经过专业工具优化后,一个图标文件可以控制在几百KB以内。
- 分析包体积:可以使用
webpack-bundle-analyzer(如果 eject 了CRA配置)或source-map-explorer来分析最终生成的build/static/js文件,看哪个依赖体积最大,考虑是否有替代方案。
经过上述优化,我的最终打包结果:
- Windows (NSIS installer): ~14.8 MB
- macOS (DMG): ~15.2 MB
- Linux (AppImage): ~14.5 MB
成功实现了“约15MB”的目标。这个体积包含了Chromium内核、Node.js运行时、React应用以及所有依赖库,对于一个功能完整的Markdown编辑器来说,已经非常精简。
5. 开发中遇到的典型问题与解决方案
在开发过程中,我遇到了几个颇具代表性的问题,这里记录下来,希望能帮你避开这些坑。
5.1 问题:KaTeX渲染与DOM更新冲突
现象:在快速输入数学公式时,预览区域的公式有时会显示为乱码或重复渲染。
根因分析:我的初始实现是在PreviewPane的useEffect中,先更新innerHTML,然后立即调用KaTeX渲染函数。然而,innerHTML的更新和React的渲染周期并不同步。在极端情况下,KaTeX可能试图去渲染一个已经被React准备更新但尚未完全提交到DOM的临时节点,或者渲染完成后该节点又被后续的更新覆盖。
解决方案:将KaTeX的渲染过程整合到Markdown解析流水线的最后一步,而不是在React组件更新后独立进行。正如我在markdownParser.js中所做的,在一个脱离React生命周期的临时div中完成Markdown->HTML->净化->KaTeX渲染的全过程,然后将最终安全的HTML字符串一次性交给React去更新DOM。这样保证了渲染源的唯一性和时序的正确性。
5.2 问题:Mermaid图表在内容快速变化时闪烁或错位
现象:编辑包含Mermaid图表的文档时,预览区的图表会频繁重绘,导致闪烁,有时旧的图表元素没有清理干净,和新图表叠加在一起。
根因分析:直接依赖useEffect在每次内容变化后渲染Mermaid,没有防抖。同时,Mermaid在渲染新图表前,没有有效清理旧图表生成的SVG元素。
解决方案:
- 引入防抖:如代码所示,使用
setTimeout和clearTimeout实现一个简单的防抖,只在用户停止输入一段时间(如500ms)后才触发Mermaid渲染。 - 精准定位容器:使用
querySelectorAll(‘pre.mermaid:not([data-processed])’)只选取未被处理过的新图表。在renderMermaidDiagrams函数中,一旦开始处理某个元素,立即为其添加>