
如果你经常写技术文档、记笔记或者维护项目的 README应该对 Markdown 不陌生。Markdown Editor 这个称呼下有成百上千个选择但真正能让我觉得“顺手”的往往不是体积最大、功能最多的那一个而是轻量级编辑器加一个可靠的预览器。最近我完整地做了一个能在浏览器里直接运行的 Markdown 编辑器与预览器过程中踩了不少坑也理清了很多设计选择。这篇文章就把核心设计、实现路径和常见问题从头讲一遍既是记录也算给想动手做类似工具的人一个参考。1. 从场景出发为什么会需要一个轻量级 Markdown 编辑器1.1 重量级工具的痛点我在很久之前也是 IDE 党写什么都要先打开一个大型编辑器。后来发现大部分时候我只是想写几条临时记录、整理一个会议摘要或者给一个开源项目补一段 README。这时候打开一个需要加载插件市场、同步工作区配置的编辑器成本明显高过收益。尤其是电脑配置一般的时候光是等待窗口出现、等待语法高亮生效的那几秒就足够让写作的兴致凉半截。这类重量级工具的核心问题不是功能多而是“入口重”。每次启动都要经过项目加载、扩展扫描、代码智能分析等一系列流程。Markdown 文档本质上就是纯文本它不应该需要那么重的基础设施。我自己后来常备一个非常轻量的 Markdown 编辑器双击打开就能写等同于把纸和笔放在触手可及的地方。等到真的需要改代码、跑脚本时再去开 IDE 也不迟。重量级的另一个问题是数据格式和平台绑定。很多笔记软件、在线文档看起来方便但导出格式不开放数据被锁在里面。一旦想迁移到别处就要面对复制粘贴、格式丢失的烦恼。Markdown 的好处是纯文本任何编辑器都能打开。如果连编辑器本身都足够轻量那整套写作流程就会变得非常清爽。1.2 功能贵精不贵多很多人会把“轻量级”理解为功能简陋、这也没有那也没有其实不是这样。轻量级真正强调的是心智负担轻而不是单纯砍功能。一个好的轻量级 Markdown 编辑器应该把高频场景做扎实把低频需求全部让路。高频场景包括快速输入、代码块与列表的格式操作、图片插入、实时预览、导出。低频场景包括多人协同、云同步、数据库管理、复杂模板、自动化脚本这些都不该出现在轻量工具的第一版里。我给自己做编辑器时第一版的功能列表非常克制支持 GFM 语法、支持代码高亮、支持数学公式、左右分栏同步滚动、可以把全文复制成 HTML。其他功能统统不做。这种克制带来一个好处开发周期短闭环很快跑通。跑通之后再根据实际使用需求逐个加功能而不是一开始就陷入“什么功能都想要”的泥潭。拿日常做类比瑞士军刀功能很多但你真到了削水果的时候一把折叠水果刀反而更轻便好用。Markdown 编辑器也是一样它是在写作现场使用的工具不是用来展示功能的博物馆。判断一个功能要不要加最好的标准是我过去一周有没有用到过如果一次都没用过大概率以后也不会用。1.3 没有预览器的 Markdown 编辑器是半个残废纯文本编辑区只能看到一堆#、*、[]大脑需要额外做一次“编译”才能在脑海里想象出最终排版。Markdown 设计的初衷是易读易写但它毕竟不是最终的呈现形态。层级嵌套有没有放对表格的列数是否一致图片路径写错了没有这些问题在纯文本模式下很难一眼发现预览器就是用来解决这个矛盾的。预览器的价值除了“看到效果”更在于帮用户快速验证语法。我在写嵌套列表时经常出现空行不对导致渲染失败的问题如果只看源码可能半天都查不出来。一旦开启预览问题立刻暴露。所以在我看来编辑器和预览器应该是成对出现的它们共同组成一个完整的 Markdown 写作环境。单纯只有输入区、没有反馈的工具只能算文本编辑器不能算 Markdown 编辑器。这里也回应一下很多人的疑问我是不是必须用 Typora、Obsidian 或者 VS Code 插件不是。只要有一个编辑区、一个预览区、能处理常见语法它就是一个合格的 Markdown 编辑器与预览器。工具形态真的不重要重要的是编辑和预览之间有没有形成一个顺畅的反馈闭环。2. 核心功能拆解编辑区、预览区与联动机制2.1 编辑区看起来是个文本框实际要处理不少细节编辑器最底层确实是一个 textarea但把它做顺手需要处理很多细节。首先是 Tab 键。Markdown 列表嵌套和代码块缩进都依赖 Tab默认情况下 Tab 会把焦点移走所以必须在 keydown 事件里拦截并手动插入两个空格或一个制表符。其次是自动闭合符号输入(就自动补)、输入[就自动补]能减少不少来回切换方向键的操作。快捷键也不能漏。选中一段文本按CtrlB应该用**包起来按CtrlI用*包起来插入链接时自动生成[文本](url)的模板。代码块快捷键则可以生成带语言标记的 fenced code block例如输入js后面跟一个换行。这些操作虽然小但对纯文本输入体验的提升非常明显。编辑区还有一个容易被忽略的点光标位置保持。有些实现会在每次输入后把整个 markdown 源文本重新赋值给 textarea然后再尝试恢复光标位置结果经常恢复不准。我的做法很简单textarea 只负责接收输入渲染结果只流向预览区绝对不让渲染结果反向覆盖输入区。这样光标永远待在它该在的地方用户不会感到异常跳转。图片粘贴和拖拽插入属于增强功能。如果你希望把图片直接粘进 md 文档需要监听 paste 事件检查剪贴板里是否有图片文件。拿到的图片可以转成 base64 后插入 Markdown 图片语法也可以保存到本地目录并写相对路径。轻量级工具里我推荐 base64这样最终的 md 文件仍然是单文件迁移起来很方便。2.2 预览区从 Markdown 到 HTML到底经历了什么预览区的本质不是“把文本变大变漂亮”而是完成一次格式转换原始 Markdown 文本经过解析器变成 HTML 字符串再注入到页面的 DOM 里最后套上 CSS 样式。整个流程看起来简单但每个环节都有讲究。解析器我选择 markdown-it而不是从零手写一个。原因很简单Markdown 语法细节非常多手写解析器大概率会踩中各种边界情况而 markdown-it 兼容 CommonMark也支持 GFM 的大部分语法它本身就是经过多年打磨的库。这里稍微提一句如果用浏览器直接加载可以通过 CDN 引入非常省事。具体处理流程可以拆成这几步读取编辑区的文本调用 markdown-it 的render()方法得到 HTML 字符串根据设置决定是否允许原始 HTML 标签透传对渲染结果做代码高亮、数学公式等二次处理将最终 HTML 注入预览容器如果开启同步滚动再调整双方的滚动位置。这里面最容易出问题的就是“信不信任用户输入”。如果设置html: true那么 Markdown 里的script标签也可能直接进入 DOM存在 XSS 风险。我在本地自己用可以开但一旦要给别人用或者用于渲染不可信内容就必须先经过 DOMPurify 一类的过滤库清洗再插入页面。代码高亮也要注意顺序markdown-it 渲染代码块时已经做了 HTML 转义高亮库需要在其基础上操作不要反过来先去高亮再把字符串交给 markdown-it那样很容易把代码块里的给解析坏。2.3 同步滚动让编辑区和预览区“对齐”同步滚动是 Markdown 预览器里最影响体验的功能之一但实现方式天差地别。最简单的做法是算滚动百分比把编辑区的滚动百分比和预览区的滚动百分比对应起来。但问题在于编辑区每一行的高度一致预览区则因为标题、列表、图片、代码块的存在高度差异极大。直接按百分比同步往往开头是对的越到后面越偏。我实际用的方法是按块映射把 Markdown 原文按空行拆成“块”每个块对应一段逻辑内容比如一个标题、一个段落、一个代码块。预览区渲染完成后同样按这些块去对应 DOM 节点。滚动编辑区时先找到当前可见区域的起始块索引再通过 DOM 节点的offsetTop找到预览区对应位置最后设置预览区的scrollTop。反向同步同理。实现时要注意防止两个滚动事件互相触发死循环。比较简单的方式是设置一个标志位当程序正在执行“由编辑区带动预览区”的操作时暂时忽略预览区的滚动回调。等这个操作结束再恢复监听。实测下来块映射法在绝大多数写作场景都能做到稳定对齐只有超长表格或超高代码块会让定位有点偏但这类内容平时并不多。对轻量级工具来说这个精度已经足够了。2.4 样式与主题预览器不只是“渲染出来就行”很多人以为预览器只要能显示出 HTML 就完事了其实排版样式直接决定阅读体验。同样的 Markdown在一套糟糕的 CSS 下可能让人完全不想读而在精心调整的样式下却赏心悦目。预览区至少要处理好几个基础项目字体、行高、标题层级、段落间距、代码块样式、表格边框。我自己的偏好是把正文最大宽度设置在 720 像素到 860 像素之间太宽的文本行会让目光很难换行。字体用系统字体栈标题适当加大加粗让层级一目了然。代码块要保证等宽字体背景色与正文有明显区分行内代码也要有底色和边框否则容易和普通文本混在一起。表格边框线要清晰表头背景略深这样扫起来才快。深色模式也很重要。写东西经常是深夜白底高亮屏特别刺眼。可以同时支持浅色和深色两套主题通过按钮切换并把选择存到localStorage里。如果不想做按钮也可以直接用 CSS 的prefers-color-scheme跟随系统。主题切换本质上就是换一套预览区的 CSS 变量成本不高但带来的舒适感非常明显。3. 实操用 Web 技术快速搭建一个轻量版编辑器与预览器3.1 技术选型为什么先做纯浏览器版在动手做编辑器时最容易犯的错误是一上来就选 Electron 或 Tauri搞一个“真桌面应用”。但真正核心的编辑与预览逻辑在浏览器里就能验证。纯浏览器版的好处很多不用安装、跨平台、分享方便。你可以把一个 HTML 文件发给同事他用浏览器打开就能用不需要装任何运行时。依赖方面可以全走 CDN例如 markdown-it、highlight.js、KaTeX。开发调试的时候打开浏览器控制台就能看到报错效率很高。等核心闭环稳定了再根据需求套壳。如果要做桌面端我倾向于 Tauri 而不是 Electron因为 Tauri 的二进制体积小、内存占用低更符合轻量级的定位。不过这些都是后话第一步永远是先把编辑和预览跑通。这里也给一个建议不管最终目标是桌面应用还是网页应用都先做一个不需要构建工具的原型。直接用原生 HTML/CSS/JavaScript能跑通再迁移到框架。很多人一上来就用 Vue、React结果在工具链配置上消耗大量时间反而把核心需求拖慢了。3.2 核心雏形一个 textarea 一个 div 就够了先说最小可用的核心一个 textarea 作为编辑区一个 div 作为预览区然后监听 textarea 的input事件把内容交给 markdown-it 渲染成 HTML再塞进预览 div。这段逻辑连 50 行都不到但已经具备编辑器与预览器的骨架。我直接贴一个可以本地保存为 HTML 文件运行的示例!DOCTYPE html html langzh-CN head meta charsetUTF-8 title轻量级 Markdown 编辑器与预览器/title script srchttps://cdn.jsdelivr.net/npm/markdown-it13.0.1/dist/markdown-it.min.js/script style body { margin: 0; font-family: system-ui; } #container { display: flex; height: 100vh; } #editor { width: 50%; height: 100%; font-family: monospace; padding: 16px; box-sizing: border-box; border: none; outline: none; resize: none; } #preview { width: 50%; height: 100%; padding: 16px; overflow-y: auto; box-sizing: border-box; border-left: 1px solid #ddd; } /style /head body div idcontainer textarea ideditor placeholder# 在这里输入 Markdown/textarea div idpreview/div /div script const md window.markdownit({ html: false, linkify: true, typographer: true }); const editor document.getElementById(editor); const preview document.getElementById(preview); const render () { preview.innerHTML md.render(editor.value); }; editor.addEventListener(input, render); render(); /script /body /html代码里我设置html: false意味着 Markdown 中写的原始 HTML 标签不会被渲染。这样做可以避免很多安全隐患。如果你确认只渲染可信内容也可以开成true但后面必须补 DOMPurify。linkify: true则会把裸链接自动转成可点击链接这个功能很实用。3.3 渐进增强代码高亮、数学公式、同步滚动把最基础的编辑和预览跑通后就可以按需添加功能。第一个要加的是代码高亮毕竟技术文档里少不了代码块。推荐 highlight.js在 render 完成之后遍历pre code元素并调用hljs.highlightElement()。为了性能可以给已经高亮过的元素加一个hljs类下次不再处理。接入方式大概是这样hljs.configure({ ignoreUnused: true }); document.querySelectorAll(#preview pre code).forEach(block { if (!block.classList.contains(hljs)) { hljs.highlightElement(block); } });数学公式则稍微麻烦一点。markdown-it 本身不识别$...$需要加载插件比如markdown-it-texmath然后把它的渲染结果接到 KaTeX 上。引入 KaTeX 的 CSS 之后公式排版才会正常。配置代码大致如下const tm window.texmath; md.use(tm, { engine: window.katex, delimiters: dollars, katexOptions: { throwOnError: false } });同步滚动按上一节说的块映射思路实现。核心逻辑其实不复杂关键是找到当前光标或滚动位置对应的“块”。你可以把编辑区的文本先按空行拆成块数组预览区渲染时给每个块包一层带>