ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用纯前端实现Markdown在线预览编辑器:从解析到安全渲染全解析

用纯前端实现Markdown在线预览编辑器:从解析到安全渲染全解析 做前端项目的时候总有那么几个工具类页面看着不起眼真要动手却发现坑不少。Markdown在线预览编辑器就是典型的例子看起来无非是左边写右边渲染实际做下来涉及解析库选型、XSS过滤、代码高亮、同步滚动、性能防抖一堆问题。这篇博客就围绕“用纯HTML CSS JavaScript实现一个Markdown在线预览编辑器”展开复盘我从零搭建这个工具的技术选型、完整代码实现、遇到的问题和排查思路全程可复现适合正在学前端、或者想在项目里快速集成一个轻量级编辑器的同学。1. 整体架构与设计思路1.1 先搞清楚数据流Markdown在线预览编辑器这个名字摆出来核心功能其实就一句话把用户输入的Markdown文本实时解析成HTML并渲染出来。听起来简单但整个页面所有逻辑都围绕着一条单向数据流展开textarea输入事件 - 获取原始Markdown文本 - 交给解析器转换 - 得到HTML字符串 - 插入预览区DOM这个数据流看似直白真正写起来有几个绕不开的分支输入频率太快会导致渲染卡顿需要做防抖Markdown允许嵌入原始HTML不处理会被XSS利用预览区新内容高度变化后同步滚动的逻辑要重新计算。所以架构上不能只写一个“监听input然后替换innerHTML”的脚本需要把解析、渲染、安全过滤、交互联动分解成独立模块。我在动手前先列了功能清单按照优先级排了序实时预览输入改动后数百毫秒内更新预览区代码块语法高亮让代码块有基本的语言着色安全过滤只解析可信的Markdown结构阻断脚本注入同步滚动编辑区滚动时预览区按比例跟随滚动本地草稿刷新页面不丢失当前编辑内容这份清单决定了技术选型的方向也决定了后面写代码的复杂度边界。1.2 解析库选型marked.js还是markdown-itMarkdown转HTML这一步业界常见的方案有几个从手写正则到成熟库都有。手写正则的缺点太明显Markdown语法边界情况极多嵌套列表、引用块、行内代码、链接标题正则写深了基本是维护灾难。所以第一步就放弃了手写方案在成熟库里挑了又挑。用得比较多的是marked.js和markdown-it。这两者我实际都试过各自的特点很鲜明。marked.js最大的优势是极简文件体积小API只有一行marked.parse(text)直接返回HTML字符串。它对CommonMark规范的支持比较完整绝大多数常用的Markdown语法都能正确解析。缺点是默认不开源安全的HTML清理需要自己接DOMPurify另外它几乎没有内置的扩展机制想自定义语法规则会比较麻烦。markdown-it相比之下可配置性高很多官方插件生态成熟比如markdown-it-emoji、markdown-it-container都是现成的。它底层用Ruler机制管理解析规则想改某个语法的行为可以精确到rule级别做替换。代价是上手成本稍高配置项和插件体系需要一点时间熟悉。考虑到这个项目的定位是轻量级工具页面我选择了marked.js搭配DOMPurify。理由很直接我们不需要插件扩展只求解析快、API简单marked.js45kb左右的体积足够轻。如果你想做一个功能更重的复杂编辑器后续要增加自定义语法可以换到markdown-it两者的接入方式几乎一样都是输入字符串产出HTML字符串底层切换不太影响上层逻辑。1.3 页面布局与交互取舍在线编辑器常见的布局有两种左右分栏实时预览和编辑/预览切换模式。左右分栏适合桌面端写作场景边写边看效果切换模式则适合移动端或小屏设备省空间。我做的版本选了左右分栏左侧是textarea右侧是渲染区。这样做的原因是我们项目里经常要一边写文档一边核对渲染效果频繁切换视图会打断写作节奏。分栏虽然压缩了编辑区宽度但配合适当的CSS弹性布局也能保持不错的可视面积。布局确定后交互上还有一个容易忽略的点textarea的wrap属性。默认是soft软换行长行会自动视觉折行但折行位置可能和Markdown的换行语义混淆。我把它设成了wrapoff配合横向滚动保证编辑区每一行对应一个真实换行这样写代码块和长列表时不会看到奇怪的自动折行。代价是体验上多一条横向滚动条但换来的语义清晰我认为值得。2. 核心功能拆解与实现细节2.1 编辑区为什么用textarea而不是contenteditable很多人做这类工具会想用contenteditable实现“所见即所得”的富文本编辑觉得更高级。但Markdown编辑器的核心恰恰是纯文本输入。用textarea的好处太明显了原生支持光标定位、选中、复制粘贴不需要处理复杂的selection API输入内容就是纯文本字符串不需要从DOM反向提取天然规避了contenteditable在浏览器间的兼容性差异不用费心处理粘贴格式、拼写检查、IME组合输入这些顽固问题所以我始终选textarea作为编辑区。如果你留意过Typora这种重量级软件它实际上也是内嵌了CodeMirror或Monaco这类底层画布本质上还是在渲染纯文本模型而不是直接依赖contenteditable。textarea在写法上有一个小细节Markdown源码里的Tab缩进建议用tab-size属性做视觉控制而不是依赖默认的Tab跳转行为。我页面里加上了一句.editor-input { tab-size: 4; }Tab在textarea里默认会让光标跳到下一个控件这在编辑器里是很糟糕的体验所以我也顺手拦截了Tab键改成插入四个空格。2.2 渲染区从Markdown到HTML的解析原理理解了textarea作为输入源之后实现实时预览的核心逻辑就是三个步骤读取输入、解析转换、安全清洗后插入DOM。解析转换交给marked.js后值得展开说一下的是清洗这一步。Markdown语法里允许嵌入原始HTML比如img srcx onerroralert(1)如果解析库原样输出浏览器就会执行这段脚本典型的存储型XSS。marked.js默认不做清洗所以我在解析结果后面套了一层DOMPurify过滤。const rawHTML marked.parse(text); const cleanHTML DOMPurify.sanitize(rawHTML); previewElement.innerHTML cleanHTML;这一层过滤不能省后面常见问题部分会展开讲。渲染区还需要注意的一点是用innerHTML插入内容后如果里面包含script标签且没有经过清洗会被浏览器执行。即使你信任所有输入来源也建议保留DOMPurify因为Markdown中转义、嵌套、容错处理产生的意外结构远比想象的多。2.3 语法高亮与代码块样式Markdown中的代码块是高频场景一个编辑器没有代码高亮价值大打折扣。语法高亮我用的是highlight.js通过CDN引入只加载常用的语言子集避免整个包体积过大。接入方式不复杂。marked.js解析完成后在HTML插入预览区之前先让highlight.js在DOM中找到代码块并着色。有两种做法第一种是解析后直接调用hljs.highlightElement遍历所有code标签第二种是在marked的渲染器层覆盖code方法在生成HTML时就包好高亮的类和样式。我采用的是第一种在渲染后统一处理因为逻辑更集中排查问题也更直观。核心代码大概是这样的function renderPreview(text) { const rawHTML marked.parse(text); const cleanHTML DOMPurify.sanitize(rawHTML); previewElement.innerHTML cleanHTML; previewElement.querySelectorAll(pre code).forEach((block) { hljs.highlightElement(block); }); }highlight.js默认会识别代码块的语言如果识别不了会走纯文本。为了让作者能手动指定语言保留Markdown的标准写法在代码块开头标注语言名即可。比如写javascripthighlight.js就会按JavaScript着色。2.4 安全问题XSS过滤这一步不能省这个点单独拿出来讲是因为太多人在本地工具里忽略了。很多人觉得“编辑器在本地跑我自己写的Markdown还能对自己发起攻击不成”这种想法有一个漏洞Markdown文本可能来自粘贴、下载、协作分享你永远不知道一份文档里嵌了什么。Firefox、Chrome这类浏览器对innerHTML插入的script标签有严格的执行限制但img onerror、svg onload这类payload照样能触发。一旦你把这个编辑器集成到后台管理系统、团队wiki、甚至邮件生成工具里安全风险就会被放大很多倍。DOMPurify本身也值得多说两句。它内部维护了一套白名单规则只放行安全的标签和属性对javascript:协议链接、事件属性、iframe沙箱这些做了默认拦截。使用它时不需要额外配置就可以覆盖大多数场景。如果后续允许用户上传图片要单独给图片域名加白名单否则会误伤正常引用。3. 从零搭建一个可用的在线预览编辑器3.1 初始化页面结构与基础样式HTML结构很简洁一个容器包住左右两块区域div classeditor-container textarea ideditorInput placeholder请输入 Markdown 内容 spellcheckfalse/textarea div idpreviewArea classpreview-area/div /div要注意textarea的spellcheckfalse。写作场景下拼写检查经常在中文和代码混排的段落里弹出红色波浪线视觉干扰很大关掉之后清爽很多。样式部分重点在分栏布局和滚动条美观度。我用了flex布局左右各占50%中间留2px的分隔线.editor-container { display: flex; width: 100vw; height: 100vh; } #editorInput { width: 50%; height: 100%; border: none; outline: none; resize: none; padding: 16px; font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; line-height: 1.7; } .preview-area { width: 50%; height: 100%; overflow-y: auto; padding: 16px 24px; }编辑区字体推荐等宽字体关键词对齐和代码块渲染的观感都会好很多。预览区字体可以用常规阅读字体和编辑区形成明显区分。3.2 引入解析依赖并实现实时预览依赖我选择通过CDN引入方便直接打开HTML文件就能用。如果后续要打包发布换成npm引入方式即可。CDN版本如下link relstylesheet hrefhttps://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css script srchttps://cdn.jsdelivr.net/npm/marked12.0.2/marked.min.js/script script srchttps://cdn.jsdelivr.net/npm/dompurify3.1.6/dist/purify.min.js/script script srchttps://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js/script引入依赖后核心逻辑就是一个监听函数const editorInput document.getElementById(editorInput); const previewArea document.getElementById(previewArea); function renderPreview() { const markdownText editorInput.value; const rawHTML marked.parse(markdownText); const cleanHTML DOMPurify.sanitize(rawHTML); previewArea.innerHTML cleanHTML; previewArea.querySelectorAll(pre code).forEach((block) { hljs.highlightElement(block); }); } editorInput.addEventListener(input, renderPreview);没有加防抖之前每次按键都会触发一次完整解析。实测下来在几千字的文档里marked.js的解析性能相当不错几十毫秒内能完成肉眼感觉不到卡顿。但如果文档量级上升到几万字或者渲染区有大量代码块高亮过程会成为瓶颈所以还是建议补上防抖。初次打开页面时如果从本地存储或URL参数恢复了草稿也要手动触发一次渲染避免预览区空白。3.3 防抖优化与渲染时机实时预览最怕的是输入抖动导致频繁重型渲染。防抖和节流都有人用但语义场景完全不同节流是固定频率执行防抖是停止输入后延迟执行。对Markdown预览来说显然是防抖更合适——用户连续敲击时不要有任何渲染动作停下200毫秒后再渲染一次。一个通用的防抖封装function debounce(fn, delay 200) { let timer null; return function (...args) { if (timer) clearTimeout(timer); timer setTimeout(() fn.apply(this, args), delay); }; } const debouncedRender debounce(renderPreview, 200); editorInput.addEventListener(input, debouncedRender);还有一个细节防抖延迟时间不建议超过300毫秒否则快速输入时预览区会像慢半拍一样交互体验下降。我实测200毫秒左右最合适既覆盖了打字停顿又不会有明显延迟感。如果后续想进一步优化渲染性能可以考虑多级防抖输入时先用快路径做一个简单纯文本预览停止后再做完整的高亮渲染。但对这个项目量级来说是过度设计暂时够用。3.4 同步滚动与光标定位分栏布局的一个体验痛点就是右侧预览区不跟着左侧编辑滚动。实现同步滚动最朴素的思路是根据编辑区滚动比例去设置预览区滚动距离。editorInput.addEventListener(scroll, () { const scrollRatio editorInput.scrollTop / (editorInput.scrollHeight - editorInput.clientHeight); previewArea.scrollTop scrollRatio * (previewArea.scrollHeight - previewArea.clientHeight); });这个做法的逻辑是编辑区当前滚动位置占可滚动距离的比例和预览区应该滚动到的位置占预览区可滚动距离的比例相等。实际用下来有一定效果但不精确。原因是Markdown渲染后的行高结构和纯文本的行高结构非常不一样段落、标题、代码块在预览区的高度差距明显单纯按比例映射会有偏差。进阶方案是锚点映射。给编辑区每一行计算它在预览区对应内容块的位置滚动时找到当前光标所在行的锚点再把预览区定位到对应锚点。这种方案更精准但实现复杂度高不少需要在编辑区换行时同步维护一份“行号到内容块ID”的映射关系。考虑到这个项目的定位是轻量级工具我保留了比例映射方案加上一个门槛条件当内容的可视行数不超过一屏幕时禁用同步滚动避免短文档场景下滚动条来回跳动。3.5 自动保存草稿Markdown编辑器一个非常实际的痛点是刷新丢内容。我用localStorage做了自动保存策略是输入停止后把Markdown原文存到本地刷新页面时再恢复。function saveDraft() { localStorage.setItem(md-editor-draft, editorInput.value); } const saveDraftDebounced debounce(saveDraft, 500); editorInput.addEventListener(input, saveDraftDebounced); window.addEventListener(DOMContentLoaded, () { const draft localStorage.getItem(md-editor-draft); if (draft) { editorInput.value draft; renderPreview(); } });同步滚动和自动保存这两个增强功能合在一起这个编辑器才算真正达到“日常能用”的标准。localStorage容量在5MB左右存几十万字的Markdown文档没有任何压力但要注意localStorage只能在同源下共享用file://协议打开时不同路径会共享同源空间为了避免草稿互相污染可以在key里加入文档名或路径区分。4. 常见问题与排查技巧实录4.1 预览区不更新这是最常遇到的现象输入文字后预览区空白。排查顺序一般先看浏览器控制台有没有JS报错如果有优先检查marked、DOMPurify、highlight.js脚本是否都正确加载了。CDN脚本加载失败是高频原因尤其是网络环境不稳定时本地HTML直接打开会碰到跨域或版本路径失效问题。如果没有报错再看事件监听是否被意外覆盖。比如在初始化脚本之后又给同一个textarea绑定了其他input监听某段逻辑报错后中断了后续执行。我建议在renderPreview函数第一行加一个console.log确认函数确实被调用了再用二分法逐步注释代码定位问题出在解析、清洗还是高亮环节。还有一种坑是DOMPurify把内容全部过滤了预览区显示空白。这种情况多半是文档里包含了大量不安全的标签清洗后不剩什么东西。可以在清洗前后分别打印一次HTML对比能很快判断出来。4.2 代码高亮不生效代码高亮不生效通常有两种原因highlight.js的样式文件没引入或者脚本在渲染完成之前执行了highlightElement。先检查link引入的CSS是否有效预览区代码块如果显示的是普通单色文本多半就是样式文件缺失。另一个原因是highlightElement遍历的时机太早此时innerHTML虽然已经插入但浏览器还没完成重绘某些情况下子树里的code元素拿不到。解决方案是把高亮调用放到一个requestAnimationFrame或setTimeout里确保画面前真正完成。此外highlight.js要生效代码块必须带语言类名。Markdown语法解析出来的代码块语言信息marked.js默认会输出成language-xxx的类名highlight.js靠这个类名识别语言。如果你的渲染结果里代码块不带语言类名说明marked.js没有正确解析代码围栏语法注意检查代码块前后是否有空行、围栏符号是否是三个反引号。4.3 换行不生效和表格排版问题新手经常遇到Markdown源码里明明换行了预览却还是连在一行。这是Markdown规范本身决定的普通文本的单个换行会被渲染成空格只有两个换行才会分段行尾加两个空格或反斜杠才会强制换行。这不是bug而是CommonMark的行为。但很多非技术用户不习惯网上各种教程也在争论这个设计。如果你的编辑器面向普通用户可以这么处理在初始化marked.js时关闭忽略空白选项或者直接给renderPreview函数加一个预处理把单换行替换成双空格加换行。不过我不推荐改标准行为因为一旦改掉粘贴进来的规范Markdown文档渲染结果就和原意不符了。表格排版问题通常出在列对齐和分隔行。Markdown表格语法要求表头下面必须有---|---|这种分隔行且列数要匹配。有些解析器对列数不匹配的表宽容处理有些则直接不渲染。建议在文档开头写清楚表格语法格式减少使用歧义写法。4.4 图片无法显示图片不显示的原因比文字问题更多。最常见的是路径问题本地相对路径图片在HTML页面直接打开时浏览器安全策略可能限制file://协议下访问本地图片或者Markdown里写的路径和实际文件位置不一致。另外DOMPurify默认过滤了不安全的图片来源onerror这类事件属性会被清理但正常的src不受影响。如果图片引用了站外链接可能是HTTP/HTTPS混合内容被浏览器拦截。我调试时习惯在控制台执行一条命令把清洗后的HTML里的img标签全部列出来检查src值document.querySelectorAll(#previewArea img).forEach(img console.log(img.src));这样很快就知道是路径错了还是内容被过滤了。4.5 XSS与安全风险最后专门讲一遍XSS。innerHTML直接插入用户输入的HTML本身就是危险操作即使这个输入来自本地。真正的隐患在于这个编辑器如果嵌入到管理系统、论坛后台、wiki系统任何用户提交的Markdown长文都可能携带恶意负载。DOMPurify能拦截绝大多数payload事件属性、javascript:伪协议、iframe嵌套等。但安全防护是纵深防御不能只靠一层。我的建议是在存储层对Markdown原文做长度限制防止超大文档拖垮渲染线程在服务端同样执行清洗逻辑不要信任任何客户端传过来的HTML预览区渲染使用iframe srcdoc配合sandbox属性隔离脚本执行环境这最后一条是进阶做法把预览区变成沙箱iframe脚本即使注入也无法操作父页面是比DOMPurify更硬的隔离手段。这个项目的其实场景还没有那么强但如果你要做一个在线代码演示平台或者允许嵌入第三方视频、地图类内容建议直接上iframe沙箱方案。5. 还能怎么扩展这个项目5.1 抽离成可复用组件这个编辑器做好之后我第一件事就是把核心渲染模块抽出来以支持后续复用。逻辑上可以拆成三个部分parser负责Markdown转HTML、sanitizer清洗、renderer插入DOM并高亮。这三个模块之间不直接依赖DOM元素只需要暴露一个统一入口函数window.MarkdownPreview { render(text, targetElement) { const html DOMPurify.sanitize(marked.parse(text)); targetElement.innerHTML html; targetElement.querySelectorAll(pre code).forEach((block) { hljs.highlightElement(block); }); } };这样在任何页面里只要引入依赖和这段模块代码就能在任意容器中渲染一份Markdown。在Vue或React项目里也只需封装成组件内部调用这个入口。抽离的好处是后续维护解析逻辑与页面交互解耦升级解析库时不会影响其他部分。5.2 和Electron结合做桌面工具网页版能用但一些朋友更喜欢桌面软件的体验。通过Electron封装这个页面很轻松主体代码零改动只需要在主进程里创建一个BrowserWindow加载本地HTML文件再补上文件读写菜单即可。Electron的好处是能原生处理文件对话框可以直接打开.md文件、保存成.md或导出.html不需要依赖浏览器下载行为体验顺滑很多。之前我们还做过一个内部版本用webContents.printToPDF实现了直接导出PDF用户反馈比复制到Word再转格式省事太多。缺点是打包体积变大如果只是日常写文档轻量网页版完全够用。5.3 配合LLM场景作为内容阅读端这个方向是我近期觉得最实用的。写作工具本身是输入与呈现的桥梁而当LLM参与内容生产后这个桥梁就变得更加关键。很多基于大语言模型的写作工作流都会要求模型输出结构化内容而Markdown正是LLM最稳定、最常用的输出格式之一——一方面Markdown本身就是纯文本结构清晰对Token占用也友好另一方面模型的输出更容易被解析成列表、表格或段落。Markdown在线预览编辑器在这种场景里可以作为LLM生成结果的定向阅读端模型输出Markdown后直接交给渲染层查看比看一坨纯文本舒服很多。比如在做知识管理工具时可以用脚本从LLM接口拿到Markdown文本交给本编辑器渲染配合代码高亮和表格展示输出效果会提升一大截。我个人实际体验下来这种组合比让LLM直接输出富文本HTML要稳得多一是Markdown结构固定、解析错误率低二是清洗和转换在客户端做灵活性更高。从结构设计、依赖选型、核心实现到问题排查这套流程全部走下来一个可用的Markdown在线预览编辑器就落地了。实际开发过程中的一句心得不要在一开始追求所有高级功能先把核心链路跑通再逐步叠加增强能力踩坑时定位问题也会快很多。
返回列表