做技术写作这几年,我电脑里的.md文件多到数不清。真正让我难受的从来不是“写”,而是“看”:有时候只是想快速打开一个 Markdown 文档确认渲染效果,检查代码高亮有没有问题,既不想启动动不动几百 MB 的编辑器,也不想把本地笔记粘贴到在线站点上——数据是自己的,不想到处乱传。于是我用纯前端方式做了个基于浏览器的 Markdown 预览工具:打开一个 HTML 文件,通过文件导入方式载入.md文档,浏览器本地完成 Markdown 解析和代码高亮渲染,不依赖网络、不安装程序,双击就能用。
这个工具不试图成为编辑器,只专注“预览”这一个动作。适合谁?适合高频处理 README、技术笔记、接口文档的人,适合电脑配置不高但天天要开一堆文件的人,也适合那些只想让 Markdown 以最舒服的姿势显示出来的轻度用户。下面把整套思路、实现细节和踩过的坑都拆开讲,你照着做也能在半小时内拥有一份属于你自己的轻量预览器。
1. 项目定位:为什么我还需要一个浏览器端的 Markdown 预览工具
1.1 解决的核心痛点:快速预览与轻量打开
市面上的 Markdown 方案不少,Typora 体验确实好,但它是编辑器,会给你一堆排版、主题、文件树的东西;VS Code 也很强,但为了看一个文件启动整个 IDE,总觉得有点小题大做。更关键的是,我经常要把文档发给没有装任何编辑器的人看,对方只需要“打开一个文件,把.md拖进去就能看到内容”,不需要理解什么叫 Markdown 语法,也不需要装环境。
这个工具的核心定位就一句话:打开浏览器,把文件拖进去,立刻看到渲染结果。它只在本地运行,不上传任何内容,关闭页面就什么都没了。对隐私敏感的场景来说,这是一个很实在的加分项。它还可以配合打印功能做 PDF 导出,算是一个“临时文档查看器”。
1.2 技术选型对比:marked、markdown-it 与 highlight.js、Prism 怎么选
做浏览器端 Markdown 渲染,第一关就是选解析库。我最早考虑的候选有三个:marked、markdown-it、remark。简单说下区别:
| 解析库 | 体积 | 特点 | 适合场景 |
|---|---|---|---|
| marked | 约 40KB | 老牌、默认支持 GFM、配置简单、解析速度快 | 轻量工具、追求极简 |
| markdown-it | 约 100KB+ | 插件生态丰富、可高度定制 | 需要扩展语法、复杂渲染 |
| remark | 更大 | Node 生态更完整,浏览器端引入成本较高 | 需要做 AST 级处理 |
我做这个工具的原则是“轻量”,所以选了 marked。它默认就支持 GFM(GitHub Flavored Markdown),表格、任务列表、删除线这些日常高频语法开箱即用,不需要额外写插件。如果你以后想扩展自定义容器、脚注这些高级语法,再考虑 markdown-it 也不迟,但那是另一个项目了。
代码高亮的选择更直接:highlight.js 和 Prism.js 二选一。highlight.js 的好处是开箱即用,一个 bundle 里带了上百种语言,直接引入全量版本基本不会漏语言;Prism 则需要手动挑选语言组件,定制性强但第一次上手要折腾。我这种“能少配置就少配置”的诉求,直接用 highlight.js 更省心。实际用下来 highlight.js 全量版不到 400KB,对于本地工具来说可以接受,换来的是省心。
1.3 文件导入的三种实现路径:FileReader、拖拽与粘贴
用户要预览一个.md文件,至少有三条路可以走:
- 通过
<input type="file">选择文件,这是最稳妥的保底方案,任何浏览器都支持。 - 把文件直接拖进浏览器窗口,体验最好,其实也只是监听
drop事件,一次preventDefault就能接住文件。 - 从剪贴板粘贴 Markdown 文本,适合电商运营、公众号小编这类“从聊天窗口复制一段带格式文本”的场景。
三条路最终都汇聚到同一个入口:拿到一段 Markdown 字符串,交给解析库去渲染。我在工具里把前两种都做了,第三种只留了一个文本框,方便直接粘贴文本内容。这里有个容易被忽略的点:拖拽文件时浏览器默认会在新标签页打开这个文件,必须给dragover和drop都加上preventDefault(),否则你辛辛苦苦拖进去的文件会变成浏览器直接显示源码,等于白做了。
2. 核心功能拆解与关键代码实现
2.1 页面布局:左右分栏、工具栏与移动端适配
布局我用了最朴素的两栏结构:左边是一个textarea放原始 Markdown 源码,右边是一个div放渲染后的 HTML。上面一条细工具栏,放导入按钮、打印按钮和一个状态提示。别看结构简单,细节都在 CSS 里。
两栏之间我用 flex 弹性布局,左栏右栏各占一半,中间加一条 1 像素的分隔线。窄屏时自动切成上下结构,textarea在上、预览在下,各自高度不少于 40vh。这里我特别处理了一个细节:textarea使用等宽字体、关闭拼写检查、关闭自动换行,避免源文件内容在编辑器里被折得乱七八糟,渲染端则用正常比例字体。
工具栏不要做得太重,一个纯色条 + 几个按钮就够了。我在打印时把工具栏和源码输入区全部隐藏,只保留渲染后的内容,配合@media print样式,可以直接通过浏览器“打印为 PDF”输出干净的文档。
2.2 文件读取:input 事件、FileReader 与编码处理
文件读取这块的核心 API 是FileReader.readAsText(),代码并不复杂,但容易踩坑的是编码。现在绝大多数.md文件都是 UTF-8,readAsText默认按 UTF-8 解析没问题。如果碰到从旧 Windows 系统传过来的 GBK 编码文件,读出来就是一堆乱码。
document.getElementById('openBtn').addEventListener('click', function () { document.getElementById('fileInput').click(); }); document.getElementById('fileInput').addEventListener('change', function (e) { const file = e.target.files[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = function (ev) { document.getElementById('source').value = ev.target.result; render(); }; reader.readAsText(file); });考虑到这是一个轻量工具,我不想引入 jschardet 这种字符集检测库,所以方案是:默认按 UTF-8 读,如果检测到替换字符\uFFFD或内容里出现大量乱码特征,就在状态栏提示“文件可能不是 UTF-8 编码”。实际使用中,遇到 GBK 文件的概率真的很低,而且我后来把常用文件都转成 UTF-8 了,这不算什么问题。
拖拽导入的核心代码差别不大,只是文件来源不同:
document.addEventListener('dragover', function (e) { e.preventDefault(); }); document.addEventListener('drop', function (e) { e.preventDefault(); const file = e.dataTransfer.files && e.dataTransfer.files[0]; if (!file) { return; } const reader = new FileReader(); reader.onload = function (ev) { document.getElementById('source').value = ev.target.result; render(); }; reader.readAsText(file); });有一点要特别注意:drop事件获取到的文件虽然能读取内容,但浏览器出于安全考虑不会返回这个文件的完整路径。这意味着预览器在处理“相对路径图片”时会遇到麻烦,这点我后面单独讲。
2.3 Markdown 解析:marked 配置、GFM 扩展与 XSS 安全过滤
marked 的解析调用本身只有一行marked.parse(text),真正值得花心思的是配置。为了贴合大多数人的写作习惯,我开了两个关键选项:gfm: true和breaks: true。前者让表格、任务列表、自动链接生效;后者把单个换行也渲染成<br>,毕竟很多人在聊天软件和文档工具里被培养出的习惯是“回车就是想换行”,不开 breaks 会让人觉得渲染结果和源码对不上。
安全过滤是我强烈建议做的,否则这个工具就是个大坑。如果直接把marked.parse(text)的结果塞进innerHTML,而用户拖入的文档里包含恶意构造的 HTML,比如<img src=x onerror=alert(1)>或者一段<script>,脚本就会在当前页面执行。文件是本地导入的,可防不可防,总归是个风险。我在渲染后加了一层DOMPurify.sanitize()白名单过滤,这样只保留安全的标签和属性,事件属性一律清除。
function render() { const raw = document.getElementById('source').value; const html = marked.parse(raw); document.getElementById('preview').innerHTML = DOMPurify.sanitize(html); }2.4 代码高亮:marked 的 highlight 回调与 language 识别
代码高亮的接入点选在 marked 的highlight回调里,这里的关键是别把顺序搞反:一定是“先解析 Markdown、再对代码块内容高亮”,而不是“先高亮、再解析”。我见过很多新手先用 highlight.js 处理整段文本,结果代码里的<和>被转义之后,Markdown 解析器又对 HTML 标签做了处理,最后页面显示全乱。
marked.setOptions({ gfm: true, breaks: true, highlight: function (code, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(code, { language: lang }).value; } catch (e) { // 语言识别失败则回退到自动检测 } } return hljs.highlightAuto(code).value; } });如果代码块指定了语言,就直接按这个语言高亮;没指定语言才走highlightAuto。为什么不全部用自动检测?因为自动检测在大段代码上比较慢,而且偶尔会误判,比如把 Python 识别成其他语言。显式语言是最准确的,自动检测只是兜底。这里还藏了一个小坑:python3这种语言名 highlight.js 并不识别,很多人在代码块里写```python3,结果高亮失效而且不报错,排查起来特别隐蔽。遇到这种情况,可以在回调里对语言名做一次别名映射。
2.5 预览体验优化:滚动同步、防抖与打印导出
静态预览做到这里已经能用了,但体验上还差两件事:滚动同步和打印导出。
滚动同步的思路很直观:左侧源码区域滚动的时候,按滚动比例同步右侧预览区域。因为两栏内容高度不同,不能直接按像素值相等来做,要计算滚动百分比:
const sourceEl = document.getElementById('source'); const previewEl = document.getElementById('preview'); sourceEl.addEventListener('scroll', function () { const ratio = sourceEl.scrollTop / (sourceEl.scrollHeight - sourceEl.clientHeight); previewEl.scrollTop = ratio * (previewEl.scrollHeight - previewEl.clientHeight); });单向同步就够了,不要做成双向同步,否则很容易产生死循环抖动。至于输入防抖,虽然现在是文件导入,但导入后用户还是可能在文本框里改内容,我加了 300ms 的防抖,减少连续输入的重复渲染。
打印导出的实现是给按钮绑一个window.print(),配合前面提到的@media print样式,把工具栏和源码区隐藏,只留渲染内容。这样浏览器自带的“打印为 PDF”功能就成了免费的导出器,不需要额外做 PDF 生成库。如果你想生成独立的 HTML 文件再分享给别人,可以用Blob+a.download导出当前预览区的 HTML。
3. 完整实操过程:从零搭建一个可用的预览器
3.1 目录结构:单文件方案还是多文件方案
先决定代码组织方式。我推荐一个文件搞定,把 CSS 和 JavaScript 全部内联到index.html里。这样整个工具就是一个 HTML 文件,拷到任何电脑、任何目录都能双击打开,没有相对路径依赖。
如果你喜欢维护性更好的方式,也可以拆成index.html + style.css + app.js,但注意本地通过file://协议打开时,部分浏览器对 ES Module 方式加载有安全限制,用普通<script src="app.js">反而最省事。考虑到文章里讲的是轻量工具,我直接以单文件为例。
目录结构非常简单:
. ├── index.html ├── marked.min.js ├── highlight.min.js ├── highlight.github.min.css └── purify.min.js这里我特意没有用 CDN 链接,原因很现实:如果是本地工具,一旦断网或者 CDN 域名被劫持,整个工具就废了。把几个库下载到本地,总大小也只有几百 KB,换来的稳定性和隐私性非常划算。
3.2 编写页面骨架与核心 CSS
页面骨架是一个 header 工具栏加一个 main 两栏容器:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Markdown 预览工具</title> <link rel="stylesheet" href="highlight.github.min.css"> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; } .toolbar { display: flex; gap: 8px; align-items: center; padding: 8px 16px; background: #f8f9fa; border-bottom: 1px solid #e9ecef; } .container { display: flex; height: calc(100vh - 56px); } #source { width: 50%; height: 100%; resize: none; border: none; padding: 16px; font-family: "JetBrains Mono", Consolas, monospace; font-size: 14px; line-height: 1.6; outline: none; } #preview { width: 50%; height: 100%; overflow-y: auto; padding: 24px 32px; border-left: 1px solid #e9ecef; } @media (max-width: 768px) { .container { flex-direction: column; } #source, #preview { width: 100%; height: 50%; } } @media print { .toolbar, #source { display: none; } #preview { width: 100%; border: none; } } </style> </head> <body> <div class="toolbar"> <input type="file" id="fileInput" accept=".md,.markdown,.txt,text/markdown" hidden> <button id="openBtn">导入 .md 文件</button> <button id="printBtn">打印 / 导出 PDF</button> <span id="status" style="color:#6c757d;font-size:13px;"></span> </div> <div class="container"> <textarea id="source" placeholder="将 .md 文件拖入窗口,或点击左上角导入按钮"></textarea> <div id="preview" class="markdown-body"></div> </div> <script src="marked.min.js"></script> <script src="highlight.min.js"></script> <script src="purify.min.js"></script> <script> // ... 核心逻辑 </script> </body> </html>同时给#preview补了一段 Markdown 专属样式,主要处理标题层级间距、表格边框、代码块背景和行内代码底色。直接用全站统一样式会出问题,比如段落间距太挤、表格没有边框、代码块里的字号和背景突兀。这套样式我建议固定下来,不要省。
3.3 核心 JavaScript:导入、解析、渲染三件事串联
下面把关键 JS 逻辑完整串起来。文件导入部分前面已经写过,不再重复,这里讲三个容易被忽略的细节。
第一个细节是accept属性的写法。<input>的accept我写了.md,.markdown,.txt,text/markdown,这样在文件选择窗口里会默认过滤出 Markdown 相关文件,减少用户误选。但注意accept只是“建议”,用户依然能切换到所有文件,所以读取时要判断文件扩展名,不符合的直接提示。
第二个细节是“重复导入同一个文件”的场景。用户选中 A.md 预览完,又选了 A.md,由于<input type="file">的change事件只在值变化时触发,连续选同一个文件可能不触发事件。解决办法是在读取结束后把fileInput.value置空,或者每次用input.click()前重置。
第三个细节是渲染失败的兜底。我用try...catch把marked.parse包裹起来,万一某个异常构造的 Markdown 触发了解析器的 bug,页面不至于白屏,而是在状态栏显示“解析失败”并打印错误信息。这些细节乍一看不起眼,实际用起来才是决定一个工具好不好用的关键。
3.4 本地双击使用时的浏览器限制与应对
直接双击index.html用file://协议打开,绝大多数功能都是正常的,但有几个限制你必须知道:
第一,fetch本地文件会被大部分浏览器拦截。如果你试图用fetch去读同目录的某个文件,Chrome 会直接报跨域错误。所以这个工具的敏捷之处就在于用FileReader读用户导入的文件,绕开了这个限制。
第二,内联脚本和本地脚本都可以执行,但 ES Module 的导入导出在file://下同样受 CORS 限制,所以保持普通<script>标签是最安全的。
第三,CDN 资源在离线状态下不可用。这也是我建议把第三方库下载到本地的原因。如果你决定用 CDN 版本,也要清楚这个工具会变得依赖网络,和“轻量本地”的初衷就相悖了。
4. 常见问题与排查技巧实录
4.1 图片不显示:真机预览和开发者工具表现不一致
这个问题的典型现象是:“我在开发者工具里看页面,图片正常;一到真机预览,图片全挂了。”放在我们浏览器预览工具的场景里,本质是同一件事——相对路径失效。
Markdown 里常见的图片写法是。如果通过文件导入方式加载,浏览器出于安全限制不会告诉我们原始文件的完整路径,只知道文件内容。所以当你把.md拖进预览器时,图片的./images/demo.png不知道该相对于谁解析——它不是相对于原始.md文件,而是相对于当前index.html所在目录。
我的解决思路是先给预览区统一设置一个“图片基础路径”,甚至用一个输入框让用户指定图片前缀,然后在渲染前对图片路径做一次正则替换:把,看看语言列表里有没有你需要的语言。没有就重新生成定制包,或者直接换全量版。
第三种,渲染顺序错误。有些人先把整个 Markdown 文本丢给hljs.highlightAuto,得到的结果再交给 marked 解析,结果代码块本身的高亮逻辑被 marked 当成了普通文本处理。记住前面说的:在 marked 的highlight回调里处理代码块,而不是在render之前处理整个文本。
4.3 换行与你想象的完全不同
Markdown 语法里有个经典坑:段落中间的单个换行,默认不渲染成<br>。很多人写完发现渲染结果里句子全挤在一行,还以为是自己代码写错了。
这个问题的根源是 CommonMark 规范和大众直觉的冲突。为了兼容 GitLab、GitHub、Typora 等平台的常见行为,我在 marked 配置里开了breaks: true。如果你用的是别的解析库,可能叫linebreak或hardWrap,配置名不同但目的一样。还有一个习惯建议:如果确实要实现强制换行,用两个空格加回车,或直接空一行分段,这是语法层面最稳妥的方式。
4.4 大文件卡顿与内存占用优化
有一次我拖了一个接近 1MB 的 Markdown 文件进去,那个文件是把整本书的章节拼在一起,渲染瞬间页面直接卡了两三秒。原因很简单:marked 一次性把整个文档解析成 HTML 字符串,浏览器一次性插入这么多 DOM 节点,主线程当然扛不住。
我的临时优化方案是在渲染函数外面包了一层 300ms 防抖,配合requestIdleCallback把非关键渲染延后。更彻底的做法是把解析丢进 Web Worker,但这样代码复杂度会上升,对轻量工具来说有点过度设计。所以我给状态栏加了一个“文件较大,渲染中……”的提示,同时在代码里对大文件做了分批渲染的尝试:先渲染前 2000 行,再在下一个空闲间隔渲染剩余内容。实测 1MB 的文档从卡顿降到基本可接受。记住一点:轻量工具的目标不是扛住所有极端场景,而是给出一个合理的边界。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 图片全部不显示 | 相对路径基于当前 HTML 解析,与原始 md 文件路径不一致 | 设置图片基础路径,或让 HTML 与 md 同目录 |
| 文件打开全是乱码 | 文件是 GBK 等非 UTF-8 编码 | 另存为 UTF-8 后重新导入 |
| 代码块没有高亮 | 语言名错误、语言包缺失、处理顺序错误 | 检查hljs.listLanguages(),修正语言名 |
| 表格显示成一堆竖线 | 没有启用 GFM | 配置里开启gfm: true |
| 单换行不生效 | CommonMark 默认不渲染<br> | 开启breaks: true |
| 打印出来没有样式 | @media print没有正确隐藏源码区 | 检查打印样式,隐藏.toolbar和#source |
| 大文件渲染卡顿 | DOM 节点过多 | 防抖、分批渲染、提示等待 |
5. 扩展建议:这个预览器还能往哪些方向走
5.1 公式渲染与目录大纲
如果你经常用 Markdown 写技术文档,大概率会用到数学公式。$x^2 + y^2 = z^2$这种行内公式在纯 marked 里无法识别,会原样输出。一个低成本的增强方案是引入 KaTeX,渲染前用正则识别$...$和$$...$$,把公式部分替换成 KaTeX HTML。注意这个正则要写得足够克制,避免把美元金额也误判成公式。
目录大纲功能同样是高频需求。可以在渲染后扫描预览区里的h1到h6,给每个标题插入带锚点的id,再在页面左侧或顶部生成一个可点击的目录列表。技术上不复杂,核心就是document.querySelectorAll('h1,h2,h3')加scrollIntoView()。这对动辄几千行的技术文档特别实用。
5.2 与现有工作流结合:导出 Word、PDF 与一键分享
预览器虽然只负责“看”,但“看”完之后的下一步通常是“发”。我自己的常规操作是内容确认没问题后直接window.print()导出 PDF,发给同事或传到文档系统。如果你的工作流里要求最终输出 Word,可以先把预览内容导出成完整 HTML,再用 WPS 或 Word 打开另存为.docx。这里不展开具体转换工具的用法,但思路和热搜词里的“markdown 转 word 工作流”是同一个方向。
整个工具是纯静态的,所以部署起来也极其便宜:放到任意一个静态站点托管服务上,或者直接内网共享,其他人都能通过一个 URL 使用,根本不用教他们怎么安装下载。
5.3 从预览工具到笔记工作台
最后分享一个我后来自己加的小功能:把当前预览内容自动保存到localStorage。这样即使在浏览器里关了页面,下次打开时上次的内容还在,不会因为误关丢排版。再加上一个“复制为 Markdown”的按钮,把渲染后的表格、引用内容反向复制成 Markdown 文本,基本等于一个轻量笔记工作台了。
我个人在实际操作中的体会是,工具越轻就越难割舍。它不像大编辑器那样给你一堆面板和快捷键,但你真正需要完整编辑器的时候,往往是写长文或者做复杂排版,不是只看一眼。如果你也受够了为了预览一个 Markdown 文件就启动几百 MB 的应用,不妨按这个思路做一个属于自己的版本。整个过程不用什么高级技巧,把文件读进来、解析、高亮,三件事做好,就已经解决了我日常八成的需求。