正则表达式手搓Markdown转HTML:零依赖轻量级转换器实战 1. 为什么我要用正则表达式手搓一个 Markdown 转 HTML 工具先坦白讲市面上现成的 Markdown 转 HTML 工具一抓一大把从命令行工具到各类编辑器内置的实时预览几乎覆盖了所有使用场景。那我为什么还要专门花时间用正则表达式去手搓一个转换器原因其实很朴素我需要一个完全可控、零依赖、能塞进任何项目里的小型转换模块而不是引入一个动辄几百 KB 的第三方库。这个需求来源于一个实际场景。我手上有一个轻量级的静态内容生成流程输入是一批结构相对固定的 Markdown 文档输出是嵌入到已有页面模板中的 HTML 片段。整个流程跑在一个资源受限的环境里能少一个依赖就少一个依赖。而且这些 Markdown 文档的语法使用范围很窄无非就是标题、段落、加粗、斜体、链接、图片、无序列表、有序列表、行内代码、代码块、引用和分割线这十来种。既然语法子集是可控的那用正则表达式逐条匹配替换反而是最直接、最透明的方案。这篇文章适合谁看如果你是一个对正则表达式有一定了解、想找一个真实项目来练手的开发者那这篇内容会非常适合你。如果你正好也有类似的需求想要一个可以随时改、随时扩展、不依赖任何外部库的 Markdown 转换方案那你可以直接把我下面写的代码拿去用。即便你只是好奇“正则表达式到底能做到什么程度”这篇文章也能给你一个具体的答案。我先把最终效果说清楚输入一段 Markdown 文本输出一段干净的 HTML 字符串。整个过程就是一个函数接收字符串、返回字符串中间全部靠正则表达式的匹配和替换来完成。没有 AST没有解析器没有词法分析就是一条一条正则规则按顺序过。2. 整体设计思路与处理流程拆解2.1 为什么选择正则替换而不是写完整解析器写一个完整的 Markdown 解析器通常的做法是先做词法分析把文本拆成 token 流再做语法分析构建出一棵抽象语法树最后遍历语法树生成 HTML。这条路当然更“正规”能处理的边界情况也更多但它的代码量至少是正则方案的十倍以上。我评估了一下我的实际需求文档结构简单、语法子集固定、不需要处理嵌套列表、不需要处理表格、不需要处理脚注。在这种前提下正则替换方案的优势非常明显。代码量小逻辑一目了然出问题了直接看是哪条正则不对改起来也快。而且正则替换是纯字符串操作没有任何中间数据结构性能上也不会差。当然正则方案也有它的天花板。最典型的问题就是嵌套结构处理不好比如列表里面套列表、引用里面套代码块这些用纯正则处理起来会非常棘手。但我的文档里没有这些情况所以我选择接受这个限制换取代码的简洁性。提示如果你的 Markdown 文档里存在大量嵌套结构或者语法使用非常自由那我还是建议你用成熟的解析库。正则方案适合的是“语法受控”的场景。2.2 处理顺序为什么至关重要这是整个项目里最容易踩坑的地方。正则替换是顺序执行的前一条规则的结果会影响后一条规则的输入。如果顺序不对就会出现各种奇怪的 bug。我举一个最典型的例子代码块的处理必须放在最前面。为什么因为代码块里面的内容是不应该被其他规则处理的。假设你的代码块里有一行# 这是注释如果你先处理了标题规则那这行就会被错误地转换成h1这是注释/h1而它本来应该原封不动地待在precode里面。再比如行内代码的处理必须放在加粗、斜体之前。因为行内代码里可能出现*或_这样的字符如果你先处理了加粗和斜体行内代码里的这些字符就会被误匹配。我最终确定的处理顺序是这样的代码块 包裹的多行代码行内代码 包裹的单行代码标题# 到 ######分割线--- 或 ***引用 开头无序列表- 或 * 开头有序列表数字. 开头图片链接 text 加粗text斜体text段落剩余的非空行这个顺序的核心原则是先处理“保护性”规则再处理“修饰性”规则最后处理“兜底”规则。代码块和行内代码是保护性的它们的内容不应该被后续规则触碰。标题、列表、引用是结构性的它们决定了块级元素的划分。加粗、斜体、链接是修饰性的它们在块级元素内部起作用。段落是兜底的把剩下的散行包起来。2.3 占位符机制保护已处理内容不被二次匹配即便顺序对了还有一个问题需要解决。比如我先处理了行内代码把它替换成了code.../code但后续处理加粗规则时如果code标签里的内容恰好包含**那它还是会被误匹配。我的解决方案是引入占位符机制。每处理完一类“需要保护”的内容就把结果存到一个数组里然后在原文位置放一个特殊标记比如\x00PLACEHOLDER_0\x00。等所有规则都处理完了再把占位符替换回真正的内容。这个机制在代码块和行内代码的处理上尤其重要。我用的占位符格式是\x00加上索引号加上\x00\x00是空字符正常文本里几乎不可能出现所以不会和正文冲突。const placeholders []; function storePlaceholder(html) { const index placeholders.length; placeholders.push(html); return \x00PH${index}\x00; } function restorePlaceholders(text) { return text.replace(/\x00PH(\d)\x00/g, (_, i) placeholders[parseInt(i)]); }这段代码是整个转换器的核心基础设施。有了它我就可以放心地按顺序处理各种语法不用担心后面的规则会破坏前面已经生成好的 HTML。3. 核心正则规则逐条拆解与实操要点3.1 代码块与行内代码的处理代码块的处理是第一步也是最需要小心的一步。Markdown 的代码块用三个反引号包裹可能带有语言标识比如 javascript。我的正则要能同时匹配带语言标识和不带语言标识的情况。text text.replace(/(\w*)\n([\s\S]*?)/g, (_, lang, code) { const langClass lang ? classlanguage-${lang} : ; const escaped escapeHtml(code); return storePlaceholder(precode${langClass}${escaped}/code/pre); });这里有几个关键点。第一[\s\S]*?用的是非贪婪匹配确保匹配到最近的结束标记而不是一路匹配到最后一个 。第二代码内容需要做 HTML 转义把、、这些字符替换成实体否则代码里的 HTML 标签会被浏览器解析。第三整个结果存入占位符后续规则不会碰到它。行内代码的处理类似但用的是单个反引号text text.replace(/([^])/g, (_, code) { return storePlaceholder(code${escapeHtml(code)}/code); });注意行内代码的正则里我用了[^]而不是.?这样能避免跨行匹配的问题。虽然行内代码本来就不应该跨行但用[^]更保险。HTML 转义函数很简单但必须要有function escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); }顺序不能乱必须最先替换否则后面替换生成的lt;里的会被二次转义。3.2 标题的匹配与 id 生成标题的处理看起来简单但有一个细节值得注意我要为每个标题生成一个 id方便页面内锚点跳转。id 的生成规则是把标题文本转成小写、空格替换成连字符、去掉非字母数字字符。text text.replace(/^(#{1,6})\s(.)$/gm, (_, hashes, content) { const level hashes.length; const id content .toLowerCase() .replace(/[^\w\u4e00-\u9fa5]/g, -) .replace(/^-|-$/g, ); return h${level} id${id}${content}/h${level}; });这里的正则用了^和$加上gm标志确保逐行匹配。#{1,6}匹配一到六个井号\s匹配井号后面的空格(.)捕获标题内容。id 生成里我保留了中文字符的范围\u4e00-\u9fa5因为我的文档里有中文标题如果直接去掉所有非 ASCII 字符中文标题的 id 就会变成空字符串。3.3 列表的转换逻辑无序列表的匹配需要处理连续多行的情况。我的做法是先匹配连续的列表项然后统一包裹在ul里。text text.replace(/^([-*])\s(.)$/gm, (_, marker, content) { return li${content}/li; });但这样生成的是一堆散落的li还需要把它们包进ul。我用的方法是再跑一条正则把连续的li序列包起来text text.replace(/(li[\s\S]*?\/li)(\n(?!li)|$)/g, (match) { return ul\n${match.trim()}\n/ul\n; });这条正则的逻辑是匹配一个或多个连续的li元素直到遇到不是li开头的行或者文本结束。然后把整段包在ul里。有序列表同理只是匹配模式变成^\d\.\s(.)$。实操心得列表处理是正则方案里最容易出问题的地方。如果你的文档里列表项内容包含换行或者列表和其他块级元素混在一起纯正则处理起来会非常吃力。我的建议是如果你的列表结构复杂考虑用更结构化的解析方式或者至少把列表项限制为单行。3.4 链接与图片的匹配链接和图片的 Markdown 语法很相似区别只在于前面多了一个感叹号。所以图片规则必须先于链接规则执行否则链接规则会把图片语法里的[alt](url)部分先匹配掉。// 图片 text text.replace(/!\[([^\]]*)\]\(([^)])\)/g, (_, alt, src) { return img src${src} alt${alt} /; }); // 链接 text text.replace(/\[([^\]])\]\(([^)])\)/g, (_, text, href) { return a href${href}${text}/a; });图片的 alt 文本允许为空所以用了[^\]]*而链接的文本不允许为空用了[^\]]。URL 部分用[^)]匹配确保遇到右括号就停止。3.5 加粗与斜体的优先级问题加粗和斜体的正则看起来简单但有一个经典的坑**bold**和*italic*的匹配会互相干扰。如果你先处理斜体那**bold**里的第一个*和第二个*会被当成一对斜体标记结果就乱了。所以加粗必须放在斜体之前处理// 加粗 text text.replace(/\*\*(.?)\*\*/g, strong$1/strong); // 斜体 text text.replace(/\*(.?)\*/g, em$1/em);加粗用了非贪婪匹配.?确保匹配到最近的**。斜体同理。但这里还有一个更隐蔽的问题如果文本里出现了单独的*字符比如数学表达式里的乘号它会被误匹配成斜体标记。这个问题没有完美的正则解法只能靠约定——文档里不要出现孤立的*字符或者用行内代码把它包起来。3.6 引用与分割线引用的处理需要把连续的行合并成一个blockquotetext text.replace(/^\s?(.)$/gm, (_, content) { return blockquote${content}/blockquote; });分割线更简单text text.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, hr /);注意分割线的正则要放在列表规则之后否则---可能会被误匹配成无序列表项。4. 完整实操流程与代码实现4.1 环境准备与项目结构这个项目不需要任何构建工具一个 HTML 文件加一个 JS 文件就能跑起来。如果你想在 Node.js 环境里用直接把转换函数导出就行。我的项目结构是这样的markdown-converter/ ├── index.html ├── converter.js └── style.cssconverter.js里放核心转换逻辑index.html提供一个简单的输入输出界面style.css负责渲染样式。4.2 核心转换函数的完整实现下面是我最终版本的转换函数把前面拆解的各个部分串起来function markdownToHtml(markdown) { const placeholders []; let text markdown; function storePlaceholder(html) { const index placeholders.length; placeholders.push(html); return \x00PH${index}\x00; } function escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;); } // 1. 代码块 text text.replace(/(\w*)\n([\s\S]*?)/g, (_, lang, code) { const langClass lang ? classlanguage-${lang} : ; return storePlaceholder( precode${langClass}${escapeHtml(code)}/code/pre ); }); // 2. 行内代码 text text.replace(/([^])/g, (_, code) { return storePlaceholder(code${escapeHtml(code)}/code); }); // 3. 标题 text text.replace(/^(#{1,6})\s(.)$/gm, (_, hashes, content) { const level hashes.length; const id content .toLowerCase() .replace(/[^\w\u4e00-\u9fa5]/g, -) .replace(/^-|-$/g, ); return h${level} id${id}${content}/h${level}; }); // 4. 分割线 text text.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, hr /); // 5. 引用 text text.replace(/^\s?(.)$/gm, blockquote$1/blockquote); // 6. 无序列表 text text.replace(/^[-*]\s(.)$/gm, li$1/li); text text.replace(/(li.*\/li\n?)/g, (match) { return ul\n${match.trim()}\n/ul\n; }); // 7. 有序列表 text text.replace(/^\d\.\s(.)$/gm, li$1/li); text text.replace(/(li.*\/li\n?)/g, (match) { return ol\n${match.trim()}\n/ol\n; }); // 8. 图片 text text.replace(/!\[([^\]]*)\]\(([^)])\)/g, img src$2 alt$1 /); // 9. 链接 text text.replace(/\[([^\]])\]\(([^)])\)/g, a href$2$1/a); // 10. 加粗 text text.replace(/\*\*(.?)\*\*/g, strong$1/strong); // 11. 斜体 text text.replace(/\*(.?)\*/g, em$1/em); // 12. 段落 text text .split(/\n{2,}/) .map((block) { const trimmed block.trim(); if (!trimmed) return ; if (/^(h[1-6]|ul|ol|pre|blockquote|hr|img)/.test(trimmed)) { return trimmed; } return p${trimmed.replace(/\n/g, br /)}/p; }) .join(\n); // 恢复占位符 text text.replace(/\x00PH(\d)\x00/g, (_, i) placeholders[parseInt(i)]); return text; }这段代码可以直接复制到你的项目里用。我特意把每一步都加了注释方便你对照前面的拆解来理解。4.3 参数选择与边界情况处理有几个参数和边界情况需要特别说明。关于段落分割我用的是\n{2,}也就是两个及以上的换行符作为段落分隔。这是 Markdown 的标准行为。单个换行在段落内会被转换成br /这也是我选择的行为。如果你不想要br /把那一行去掉就行。关于块级元素的判断我在段落处理里加了一个正则检查如果这个块以h、ul、ol、pre、blockquote、hr、img开头就不再包p标签。这是为了避免出现ph1标题/h1/p这种非法嵌套。关于占位符的恢复时机必须在所有规则都执行完之后。如果你提前恢复了后续规则可能会破坏占位符里的内容。注意占位符里的\x00字符在某些编辑器里可能显示异常但这不影响功能。如果你担心兼容性问题可以把\x00换成%%PH%%这样的字符串只要确保正文里不会出现这个字符串就行。4.4 前端界面的搭建为了测试方便我搭了一个简单的前端界面。左边是 Markdown 输入框右边是 HTML 预览区中间加一个转换按钮。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleMarkdown 转 HTML 工具/title style body { font-family: sans-serif; margin: 0; padding: 20px; } .container { display: flex; gap: 20px; height: 80vh; } textarea, .preview { flex: 1; padding: 16px; border: 1px solid #ddd; border-radius: 8px; overflow-y: auto; font-size: 14px; line-height: 1.6; } textarea { resize: none; font-family: monospace; } .preview { background: #fafafa; } button { margin: 12px 0; padding: 8px 24px; font-size: 14px; cursor: pointer; } /style /head body h2Markdown 转 HTML/h2 button onclickconvert()转换/button div classcontainer textarea idinput placeholder在这里输入 Markdown.../textarea div classpreview idoutput/div /div script srcconverter.js/script script function convert() { const md document.getElementById(input).value; const html markdownToHtml(md); document.getElementById(output).innerHTML html; } /script /body /html这个界面虽然简陋但足够用来验证转换逻辑了。你可以把converter.js里的函数直接导出在 Node.js 里用module.exports或者export暴露出去。5. 常见问题与排查技巧实录5.1 代码块内容被二次处理这是最常见的问题。表现是代码块里的#被转成了标题或者*被转成了斜体。原因就是代码块的处理没有放在最前面或者没有用占位符保护。排查方法在代码块处理之后打印一下中间结果看看代码块是否已经被替换成了占位符。如果没有检查正则是否匹配成功。常见的原因是代码块的开头后面没有换行或者结尾的前面有空格。5.2 列表项被错误合并表现是多个独立的列表被合并成了一个大列表或者列表项和段落混在一起。原因是列表的包裹正则(li.*\/li\n?)太贪婪了会把所有连续的li都包进一个ul。解决方法在列表项之间插入一个空行来分隔不同的列表或者在包裹正则里加上更严格的边界判断。我的做法是要求列表项之间不能有空行如果有空行就认为是两个独立的列表。5.3 链接和图片的嵌套问题表现是[![alt](img.png)](link.html)这种嵌套语法处理不正确。原因是我的正则不支持嵌套匹配。图片规则会先匹配![alt](img.png)然后链接规则会匹配剩下的[...](link.html)但此时[...]里的内容已经被替换成了img标签链接规则匹配不到。这个问题没有简单的正则解法。如果你的文档里有这种嵌套语法建议用解析器方案或者手动调整文档结构避免嵌套。5.4 特殊字符的转义遗漏表现是 HTML 里出现了未转义的或导致页面渲染异常。原因是在代码块和行内代码之外的地方我没有做 HTML 转义。比如段落里直接写了div它会被浏览器当成真正的标签解析。解决方法在段落处理的时候对非占位符内容做一次转义。但要注意不能转义已经生成的 HTML 标签。我的做法是在段落处理里先检查这个块是否以 HTML 标签开头如果是就跳过转义否则对内容做转义。5.5 常见问题速查表问题现象可能原因排查方法解决方案代码块内容被转成标题代码块规则未优先执行检查规则顺序把代码块处理放在第一步加粗和斜体互相干扰斜体规则先于加粗执行检查规则顺序加粗放在斜体之前列表被错误合并包裹正则过于贪婪打印中间结果用空行分隔不同列表链接里的图片不显示嵌套语法不支持检查文档结构避免嵌套或改用解析器HTML 标签被转义转义范围过大检查转义逻辑跳过已生成的 HTML 标签中文标题 id 为空id 生成规则过滤了中文检查 id 生成正则保留中文字符范围段落里出现br /过多单换行被转成了br /检查段落处理逻辑按需去掉br /转换5.6 独家避坑技巧第一个技巧在开发过程中每处理完一条规则就打印一次中间结果。这样你能清楚地看到每一步的变化出问题了立刻就能定位到是哪条规则的问题。我一开始图省事所有规则写完才测试结果一个 bug 排查了半个小时。第二个技巧准备一组测试用例覆盖所有支持的语法以及各种边界情况。比如空文档、只有代码块的文档、代码块里包含 Markdown 语法的文档、列表和段落混排的文档。每次改完规则都跑一遍测试用例确保没有回归问题。第三个技巧占位符的索引不要用全局变量用闭包或者局部变量。我一开始把placeholders数组定义在函数外面结果多次调用转换函数时占位符会累积导致内存泄漏和错误的替换结果。后来改成在函数内部定义每次调用都是全新的数组问题就解决了。第四个技巧如果你的文档里有大量中文注意正则里的\w只匹配 ASCII 字符不匹配中文。所以 id 生成的时候要显式加上中文范围\u4e00-\u9fa5。这个坑我踩过中文标题的 id 全是空的锚点跳转完全失效。6. 性能优化与扩展方向6.1 大文档的处理性能正则替换的性能瓶颈主要在于回溯。如果正则写得不好遇到大文档时可能会出现指数级的回溯导致页面卡死。我实测下来对于一万字以内的文档整个转换过程在 50 毫秒以内完全感觉不到延迟。但如果文档超过十万字就需要考虑优化了。优化的方向有几个。第一把多个简单的正则合并成一个减少字符串的遍历次数。第二对于代码块这种大段内容先用字符串查找定位再用正则处理局部。第三如果性能要求极高可以考虑用 Web Worker 把转换放到后台线程避免阻塞主线程。6.2 可以扩展的语法支持目前这个转换器支持的语法是最常用的子集。如果你需要更多功能可以按同样的模式扩展。表格支持匹配连续的|分隔行转换成table结构。这个用正则做起来比较繁琐但可行。任务列表匹配- [ ]和- [x]转换成带复选框的列表项。脚注匹配[^1]这样的标记在文档末尾生成脚注区域。自动链接匹配裸 URL自动转换成a标签。删除线匹配~~text~~转换成del标签。每个扩展都是加一条正则规则的事只要注意好处理顺序和占位符保护就行。6.3 从正则方案迁移到解析器方案的时机如果你发现你的文档开始出现嵌套列表、复杂表格、混合嵌套的引用和代码块那就是时候考虑迁移到解析器方案了。正则方案的天花板就在这里再往下走只会越来越痛苦。迁移的路径也很清晰先用一个成熟的 Markdown 解析库比如 marked、markdown-it 这类替换掉你的正则函数保持输入输出接口不变。然后逐步把自定义的扩展逻辑迁移到解析器的插件体系里。整个过程可以平滑过渡不需要一次性重写。我个人在实际操作中的体会是正则方案最适合的是“语法受控、结构简单、依赖敏感”的场景。一旦你的需求超出了这个范围果断换方案不要跟正则较劲。我见过太多人试图用正则处理嵌套 HTML最后写出来的正则连自己都看不懂维护成本极高。最后再分享一个小技巧如果你只是想在项目里快速验证一个 Markdown 转换的想法不需要完整的解析能力那这个正则方案可以在半小时内搭起来。但如果你是要做一个长期维护的产品功能还是老老实实用成熟的解析库。工具的选择没有优劣只有适不适合当下的场景。