我为什么自己写了个 Markdown 渲染器(以及你多半不应该)

这个博客的 Markdown 渲染器是我自己写的两百行代码。这篇文章讲它怎么来的、怎么踩坑的、为什么我不建议你模仿,以及这段经历真正值钱的地方在哪。
这个博客的后端和前端都是我自己写的,其中有一部分的来历我一直没讲过:Markdown 渲染器。就是你现在看到的这篇文章,它从一段 ## 标题 和几个反引号变成页面上的标题和代码块,中间没有经过 marked,没有经过 markdown-it,没有经过任何带版本号的东西,只经过了我自己写的两百来行 JavaScript,躺在 frontend/src/utils/content.js 里。
先把结论放在前面,省得你读一万字才发现上当:这件事我不建议任何人模仿。它给我的回报主要是过程本身,以及一些关于转义和解析的肌肉记忆,而不是结果比开源方案更好。如果今天有人问我"我要做个博客,Markdown 渲染该用什么",我的答案永远是 marked 加 DOMPurify,五分钟完事。那为什么我还写?往下看。
事情的起因很俗
2024 年底我搭这个博客的时候,前端是 Vue 3 加 Vite,内容存后端的 JSON 文件里。要在文章页渲染 Markdown,第一反应当然是装库。npm install marked,然后 marked.parse(content),十行代码的事。
我卡住的地方说出来有点丢人:我想控制渲染出来的每一个标签。
当时的心路大概是这样。我读了几篇 XSS 相关的文章,知道 Markdown 渲染是注入的重灾区,用户的文章内容里可以塞 <script>,可以塞 javascript: 链接。虽然这个博客只有我一个人写文章,攻击者约等于我自己,但我就是想亲手处理这件事。另一个原因是体积执念,那时候我对首屏体积有一种不健康的焦虑,看到 marked 压缩后还有三十多 KB,心里盘算的是"我就用五种语法,为什么要为一个功能背整个库"。
现在回头看,这两个理由都站不住。三十几 KB 的库 gzip 之后不到十五 KB,对一个个人博客根本不是问题。至于安全,自己写渲染器恰恰是更危险的选择,因为开源库的安全补丁是几百万人帮你盯着,我这两百行的安全漏洞只有我自己盯着,而我又那么容易自信。这话说给当时的我听,多半听不进去。人有时候就是要自己撞一次。
于是某个周六下午,我开始写。原计划三小时,实际写了三天,之后又陆续修了一个月。
渲染器长什么样
先说整体思路,因为思路比代码重要。我的做法是最笨的那种:按行扫描,先用状态机把代码围栏切出来,剩下的行再按前缀分类,段内的行内语法用正则替换。没有 AST,没有 tokenizer,没有 CommonMark 规范书里那一千多个测试用例。用编译原理的话说,这不是解析,这是字符串处理,只是处理得比较系统。
第一版核心大概是这样:
export function renderMarkdown(src) {
const lines = src.replace(/\r\n/g, "\n").split("\n");
const out = [];
let inFence = false;
let fenceLang = "";
let fenceBuf = [];
for (const line of lines) {
const fence = line.match(/^```(\w*)/);
if (fence) {
if (inFence) {
out.push(flushFence(fenceBuf, fenceLang));
inFence = false;
} else {
inFence = true;
fenceLang = fence[1];
fenceBuf = [];
}
continue;
}
if (inFence) {
fenceBuf.push(line);
continue;
}
out.push(renderBlock(line));
}
if (inFence) out.push(flushFence(fenceBuf, fenceLang)); // 没闭合的围栏也兜住
return out.join("\n");
}注意最后那个 if (inFence)。这是我修的第一个 bug:文章里写了三个反引号开头却忘了闭合,结果页面从那行开始全部乱掉,代码块后面的正文被当成代码原文显示,字号突然变小,还没了段落间距。我当时盯着屏幕想,谁会犯这种错?第二天我自己又犯了一次,于是加了这个兜底。教训是写解析器的时候,永远假设输入是恶意的,哪怕唯一的作者就是你。尤其是你。
renderBlock 就是个前缀分派:
function renderBlock(line) {
if (/^###\s/.test(line)) return heading(3, line.slice(4));
if (/^##\s/.test(line)) return heading(2, line.slice(3));
if (/^-\s/.test(line)) return listItem(line.slice(2));
if (/^>\s/.test(line)) return blockquote(line.slice(2));
if (line.trim() === "") return "";
return paragraph(line);
}看出来了?不支持表格,不支持有序列表,不支持嵌套列表,不支持一级标题。这些不是技术做不到,是我刻意的。每支持一种语法,就要多一种状态、多一组边界、多一个转义场景。我按"我自己写文章真会用到的"划了线,划完发现不需要表格也不需要有序列表,就想这么过了。这个决定后来看是半对半错,对的部分后面讲,错的部分也后面讲。
关于一级标题多说一句。为什么不支持 #?因为博客的每篇文章都有标题字段,存在 JSON 里,页面模板会在正文上方单独渲染一个 <h1>。如果正文里再允许一级标题,一篇文章就有两个 h1,对屏幕阅读器和搜索引擎都不友好。所以渲染器碰到 # 开头的行,直接当普通段落丢给转义,页面上显示一个字面的井号,提醒我"这里写错了"。这种把格式错误变成可见信号的做法我挺喜欢,比静默修正诚实,也比抛异常温柔。
行内语法更粗糙,一串 replace 下来:反引号行内代码、粗体、斜体、链接、图片。顺序有讲究,行内代码必须最先处理,处理完先换成占位符藏起来,等其他替换都做完了再放回来,不然代码里的星号和方括号会被误伤。这个"先藏后放"的把戏,是我那三天里最满意的时刻,后来才知道是每个手写渲染器的人都会经历的同款得意。
得意没维持多久。有天我写了一句"我 Prefer 方案 A,但 B 也没错"之类的话,具体措辞记不清了,反正一句话里出现了两组粗体。渲染出来整句话变成了一个巨大的粗体,包括中间的",但"。
原因是正则贪婪。我写的是 \\(.+)\\,+ 默认贪婪匹配,碰到 A,但 B 时,它从第一个 一路吃到最后一个 ,中间全部成了粗体内容。修法是加问号改成非贪婪 \\(.+?)\\,一行的事,但我盯着这个 bug 足足懵了十分钟,因为渲染结果看起来"太合理了",一眼根本想不到是正则越界。这是我第一次对"贪婪匹配"这四个字产生体感,以前它只是教程里的一个名词。
顺手又挖出个孪生问题:斜体 的正则会把粗体标记里的星号也吃掉,所以斜体的替换必须排在粗体后面,而且要小心 ** 里的星号被斜体规则误配。我的解法很土,粗体先换成占位符,再跑斜体,最后统一放回。土是土了点,但每一步的行为都肉眼可验证,出了问题调试五分钟就能定位,这点我到今天都很感激当时的自己。
转义是最重要的部分,没有之一
如果这篇文章只有一个东西值得你带走,就是这一节。
手写渲染器死在哪儿?绝大多数死在转义顺序上。正确的心智模型是:先转义,后渲染。也就是说,任何用户输入进来的 HTML 特殊字符,& < > ",要在它有机会被当成 HTML 之前就变成 & < 之类的东西,然后渲染器产出的所有标签都必须是渲染器自己拼的、白名单里的。用户的 < 永远以 < 的形态出现在输出里,页面上显示成一个字面的尖括号,而不是一个标签。
function escapeHtml(s) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}四个 replace,顺序不能乱,& 必须第一个,不然 < 会被二次转义成 &lt;,页面上显示一串乱七八糟的实体码。我在第一版里就写反过一次,测试的时候页面出现 <script>,我还兴奋地以为拦截成功了,细看才发现是转义了两次,真实标签早就在更早的路径上漏过去了。
链接这里我单独写了个 safeUrl,这段代码值得全文抄录:
function safeUrl(url) {
const trimmed = url.trim().toLowerCase();
if (trimmed.startsWith("javascript:") || trimmed.startsWith("vbscript:")) {
return "#";
}
if (/^(https?:|mailto:|\/|#)/.test(trimmed)) {
return url.trim();
}
return "#";
}它防的是这类输入:点我)。Markdown 语法本身不拦协议,链接目标是什么全看渲染器。marked 这类库会做类似的事,但当时我不放心"类似",就自己写了个白名单:只放行 http、https、mailto、站内相对路径和锚点,其他一律换成 #。黑名单写法(只拦 javascript:)是危险的,因为你永远想不到浏览器还认哪些协议变体,比如大小写混排、前导空白、控制字符,每一样都是一篇 CVE。白名单的好处是我不用知道全部的坏,只要认得有限的好。
图片走的也是 safeUrl。这里有个当时没想到、后来补的场景:站内路径 /file/xxx.jpg 要放行,因为博客的配图都在这个路径下。放行规则从 ^(https?:|mailto:) 扩成带 \/ 的版本,就一行,但它是我在真实使用里撞出来的需求,不是在设计阶段想出来的。手写工具有一点好,需求是长出来的,不是想出来的。
代码块里的东西,别碰
转义还有个反直觉的反面:代码块里的内容恰恰不能做行内转义渲染,但又必须做 HTML 转义。这两个"不转义/要转义"搅在一起,是我踩的最深的一个坑。
场景是这样。我在文章里写:
在 HTML 里写转义要这么来:<div>我的渲染器把它送进 fence,fence 的职责是原样输出。但"原样"是什么意思?如果直接把字符串塞进 <pre><code>,浏览器会把 < 解析回 <,于是它又变成标签了,正文里那个"展示转义写法"的代码示例自己就坏掉了。正确做法是 fence 内容也要过一遍 escapeHtml,这样 < 在源码里变成 &lt;,浏览器解析一次回到 <,页面上显示的才是我想要的字面文本。
也就是说,"原样输出"的真实含义是"转义后输出",对用户如此,对展示转义的教学文本也如此。转义不是一次性的动作,是每一层输出边界都要重做的事情。这个道理书上有,但书上没说的是:你会在晚上十一点,因为一篇讲 HTML 转义的文章在渲染器里显示错乱,而对它产生生理层面的理解。
还有个更小的坑顺便提一下。行内代码 ` 不是粗体 应该显示字面的星号,我的实现因为"先藏后放",天然就是对的,藏起来的占位符躲过了粗体的正则。但占位符本身要用一个不可能出现在原文里的字符串,我当时用了 \u0000{i}\u0000 这种不可打印字符,后来才想到如果有人真的在文章里打了 \u0000`……不会有人打的,我查了半年,没人打。但"没人打"和"不可能出现"是两回事,这个区别我在做安全加固的时候又体会了一遍,下文会提。
它不能干什么,以及一次差点投降的夜晚
用了一年多,这个渲染器欠的账开始显形。
最疼的是嵌套列表。写操作步骤的时候,我经常想在一个步骤下挂两三个子项,但我的 renderBlock 是按行处理的,根本没有层级概念。要想支持嵌套,得记录缩进深度,维护栈,处理列表项之间夹段落的情况。每次遇到这个需求,我就用扁平列表硬凑,把子项写成破折号开头的普通段落,缩两个空格。凑出来的东西能看,但语义是错的,复制出去贴到别处就散架。
然后是那个夜晚。有篇文章我想放一张对比表,模型 A 和模型 B 的几个维度,表格是唯一合理的表达。我的渲染器没有表格,渲染出来就是一排竖线开头的文本。我坐在那想,就为这个放弃两百行自己写的、已经完全摸清脾气的代码,去换一个三十 KB 的库、再补一套 sanitize 方案吗?
那个晚上我认真做了迁移评估,认真到把 marked 和 DOMPurify 的接入代码都写出来了。最后没换的原因说出来不体面:不是技术判断,是我发现自己真正抗拒的是"未知"。我的渲染器每一个怪癖我都知道,哪里的正则贪婪,哪里的顺序敏感,出问题先查哪里。换库意味着把这些确定性换成文档和 issue 列表。我在评估表上给自己写了句实话:"你是舍不得,不是它更好。"写完之后心情复杂地关了编辑器,回去把表格写成了两组对比列表。
但我不想把这段讲成"坚持初心"的鸡汤。客观说,那次评估暴露了一个真问题:我的"确定性"只有我一个人持有。哪天这个博客换人维护,或者哪天我把渲染需求交出去,接手的人面对的是一个没有测试、没有规范符合性、行为靠记忆传承的黑盒。开源库的"未知"背后是文档、社区和规范,我的"已知"背后只有我的脑子。这两者的账,短期看我的划算,长期看未必。
至于表格,我最后用了一个折中方案,在渲染器里加了对简单表格的支持,仅限 | 分隔、无对齐标记、无单元格内竖线的形态,实现四十来行,覆盖我九成的需求。这是手写路线独有的优势:需求出现时,你可以选择只解决自己的那份,而不是接受一个库的全部复杂度。这个优势是真实的,只是它只属于"唯一用户就是作者"的项目。
关于测试,我承认我拖了半年
这个博客没有测试框架,渲染器也没有测试用例,质量保障方式是"写完新文章后打开页面肉眼检查"。这套流程运转了一年多,居然没出过大事故,主要因为唯一的输入源是我,而我写文章的语法范围非常固定。风险被低使用频率掩盖着,不等于不存在。
转折点是那次贪婪匹配 bug。它把我的盲区暴露出来了:我只检查当天的文章怎么渲染,从不回头检查旧文章。旧文章里躺着大量"上次能渲染"的输入,任何一次改动都可能打破其中某一条,而我根本不会发现。这就是回归测试存在的理由,道理我都懂,就是一直没动手。
半年后终于补的方案很寒酸,没有框架,就是一个文件里排着几十个"输入加期望输出"的断言,用 node 跑:
const cases = [
["## 标题", '<h2>标题</h2>'],
["a **b** c", "a <strong>b</strong> c"],
["**a** 和 **b**", "<strong>a</strong> 和 <strong>b</strong>"],
["[x](javascript:alert(1))", '<a href="#">x</a>'],
["<img src=x onerror=alert(1)>", "<img src=x onerror=alert(1)>"],
["```\n<div>\n```", "<pre><code><div>\n</code></pre>"],
];
let fail = 0;
for (const [input, want] of cases) {
const got = renderMarkdown(input);
if (got !== want) { fail++; console.log("FAIL:", JSON.stringify(input), "->", got); }
}
console.log(fail === 0 ? "all pass" : fail + " failed");特别说明倒数第二行那个用例。它是整个列表里我最看重的一个:完整复刻了一次真实的攻击输入,断言期望输出是转义后的字面文本。这个用例存在的意义不是防我写错正则,是防止未来的我在某次"优化"里把转义顺序调乱。安全属性必须用测试钉死,靠记忆和自觉守不住,这是我做安全加固时反复验证过的一条经验,渲染器这里再验了一次。
从补测试到全部通过花了不到一小时,因为几十个用例里有四个是失败的,全是些我早忘了的边角行为。也就是说,在肉眼检查体系下,这份代码带着四个已知错误运行了半年。这事给我的触动比任何一篇讲测试重要性的文章都大。工具不重要,用例三十行也够,重要的是"改动之后有个东西会替你回头检查旧世界"。
性能是这些小工具里最不用焦虑的事
写渲染器的时候我一度很纠结性能,还认真考虑过要不要做增量解析。后来算了笔账,这个博客最长的文章一万多字,整篇渲染在我那台老笔记本上是毫秒级的,而且渲染结果可以缓存,内容不变就不重算。也就是说,我把纠结的时间乘以十,也换不回用户可感知的一毫秒。
这个结论后来被我到处复用。个人项目的性能问题几乎从不出在算法上,出在资源上:一张没压缩的图是一百次低效渲染的代价。我后来给博客定首屏预算,最大的敌人是几 MB 的原图直出,渲染器从头到尾没上过嫌疑人名单。方向找错了,优化做得再漂亮都是在错误的道路上加速。
当然这不代表可以闭着眼睛写。我的行内替换是 O(n) 的连续扫描,嵌套列表如果用栈实现是 O(n),但如果有天我脑抽写成每行都全量重扫前文,复杂度会悄悄变成 O(n²),长文章会真的卡。区别在于,O(n) 和 O(n²) 之间的差距值得关注,毫秒和微秒之间的差距不值得。性能直觉的价值在于分清这两类差距,而不是无差别地追求极致。
如果你正在做博客,听我一句
绝大多数人,包括一年后的我在内,都应该直接用 marked 加 DOMPurify,或者 markdown-it 加它的 sanitize 插件。理由很简单,这三条加起来几乎没有反驳余地。
渲染是表层的,你的时间应该花在内容上。库的安全补丁不需要你盯着 changelog 之外的东西,就算盯着,社区里有几千双眼睛比你一个人盯得牢。规范符合性这种事,CommonMark 的边界情况多到反人类,你自制的解析器一定会在某个你没想过的输入上表现怪异,区别只是你遇到没遇到。
手写只剩三种情况说得过去。一是渲染需求真的极小且封闭,比如只支持加粗和换行的评论区,几十行封顶,复杂度可控。二是你在做的是学习项目,目标就是理解解析器和转义,那目的本身就不是产出。三是我这种,项目已经建成了,沉没成本加上低需求频率,维持现状比迁移划算,但你要诚实地知道这是惰性,不是优点。
还有一条务必记住,不管用不用库:输出的边界永远是你的责任。DOMPurify 清洗的是 DOM,但如果你把渲染结果往 innerHTML 一塞就完事,URL 处理、CSP 配置、响应头这些外部边界还是要自己管。我在后端给 /file 目录加了 Content-Security-Policy: sandbox,上传侧拦 SVG 里的内联事件,那些工作跟前端用哪个渲染器没有关系,跟"这个网站安不安全"关系更大。
手写教会我的三件事
排除掉所有劝退因素,这三个月修修补补还是给了我一些别处不好买的东西,捋出来三条。
第一件,对"转义"建立了条件反射。现在的我看到任何字符串拼接进模板的代码,第一反应不是"这行对不对",是"这层边界转义了吗"。后来给博客做安全加固,上传白名单、SVG 拦截、CSP,很多决策几乎是直觉级的快,因为底层逻辑是同一套:不信任输入,收紧出口,白名单优先。这套直觉的实际价值超过渲染器本身。
第二件,理解了"规范"为什么值钱。Markdown 看起来简单,实际有大量模糊地带:列表项里夹代码块算什么、连续引用之间要不要空行、链接标题里的引号怎么处理。CommonMark 规范花了几十页解决这些问题,我以前觉得是过度设计,现在明白那是把"每个实现者自己拍板"换成"所有人遵守同一份契约"。互操作性的成本很高,但缺乏互操作性的成本更高,只是后者常常隐藏着。
第三件,学会了给自己的项目划需求红线。我写文章就那几个语法诉求,那渲染器就只做那几个语法,多一个都不加。这个克制的习惯后来蔓延到了整个博客项目:不上数据库,JSON 文件够用;不上框架路由,手写 pushState 够用;不上状态管理库,一个 composable 够用。有人会说这是造轮子癖,我不完全否认,但每次"要不要引入 X"的决策,我都会先问"我的真实需求落在 X 功能面的哪一小块",这个习惯让整个项目保持了很小的心智负担,也让我睡得着觉。
第三件事的另一面也要说。克制有保质期。JSON 存储的红线画在单人低频写场景下是合理的,一旦并发写多了就必须升级,我后来越来越清楚这一点。红线不是信仰,是当前规模下的最优解,规模变了要敢撕。渲染器这块,如果哪天我需要数学公式、脚注、任务列表,而且使用频率超过一年三次,我会换库,不纠结。这句话写在这里,也算给未来的自己立个字据。
对了,还有个实际的小问题留给你判断。这个博客的文章列表页要在卡片里显示摘要,摘要取正文前一百来个字符的纯文本。为了拿到纯文本,我在渲染之后再做一次去标签,等于先渲染再剥掉。更聪明的做法是渲染前就抽取纯文本,一次遍历完事。我没改,因为摘要只在列表页用,每次列表刷新多算的那几毫秒没人感觉得到。但你能看出来,这个项目里到处都是这种"不聪明但够用"的选择,它们单看都无伤大雅,堆多了会不会变成债,我还没活到能回答的那天。
结尾
渲染器还在 content.js 里躺着,两百来行,最近一次改动是半年前修一个链接解析的边界。它不优雅,不完整,没有任何一个规范测试用例是为它设计的,但它把我这篇文章和这个博客过去的每一篇文章变成了你看到的样子。
前几天我翻到最早那篇测试文章,里面有一行 <script>alert(1)</script>,是我当时测转义留下的。页面上显示的就是这行字面文本,一个能点的alert都没有。看到的时候还挺高兴的,就像检查自家门锁,锁是好锁,虽然装锁的过程笨了点。
要是你也在犹豫要不要手写个什么,我的完整建议是:渲染器、加密算法、HTTP 客户端、日期处理,这些别写,用成熟的。但总有一类东西适合你自己造一遍,就是那些"你每天用、但从未拆开看过"的零件。拆一遍不一定让你做得更好,但会让你在它出问题的时候,知道该往哪个方向骂。