在Stack Overflow上搜索"Markdown解析",会出现超过2万个问题。其中最高赞的一个问题是:“为什么***foo***会被解析成斜体加粗,而***foo**却变成了粗体加星号?“这个看似荒谬的问题,实际上触及了Markdown解析器最核心的设计困境。
一行文本,从输入到渲染,要经历怎样的旅程?这个问题的答案,远比大多数人想象的要复杂。
从Perl脚本到行业标准
2004年3月,John Gruber在他的博客Daring Fireball上发布了Markdown。与他一同参与设计的还有Aaron Swartz——那位后来成为互联网活动家、在26岁英年早逝的程序员天才。他们的设计目标非常明确:创建一种"易读易写"的纯文本格式,让人在阅读源文件时不会感觉像在看代码。
Gruber用Perl写了一个参考实现Markdown.pl。这个脚本只有几百行,但它引发的连锁反应改变了互联网的内容创作方式。到2014年,Reddit每天处理数百万条Markdown评论,GitHub上有数亿个Markdown文件,Stack Overflow的问答几乎全部用Markdown编写。
然而,Gruber的原始实现存在一个致命缺陷:规范不完整。
Gruber在语法说明中留下大量空白。例如:
- 子列表需要缩进多少空格?规范说段落需要4个空格,但对子列表语焉不详
- 引用块或标题前是否需要空行?文档没有明确说明
- 列表项之间的
* * *是一条分隔线还是两个列表项?不同实现给出不同答案
更糟糕的是,Markdown.pl本身充满了bug。Hacker News上一位资深开发者直言:“这是我见过的最糟糕的小程序之一,充满了明显的bug和设计缺陷。”
结果就是:每个实现者都按自己的理解去填补空白,导致生态系统碎片化。同一个Markdown文件,在GitHub、Reddit和Pandoc上可能渲染出三种不同的结果。Babelmark工具被创建出来专门对比不同实现的输出差异,结果显示同一个输入在20多个解析器中产生五花八门的HTML。
CommonMark:标准化的艰难之路
2012年,Jeff Atwood(Stack Overflow联合创始人)和John MacFarlane(Pandoc作者)发起了一个标准化项目。他们邀请Gruber加入,但没有收到回复。
2014年9月,项目以"Standard Markdown"的名义发布。Gruber随即表示强烈反对,认为使用"Markdown"名称需要他的许可。项目被迫更名为CommonMark。
CommonMark规范的核心贡献不是重新设计Markdown,而是消除歧义。规范文档长达400多节,每一节都明确定义了特定输入的预期输出,并附带示例代码。规范还提供了一个包含近万条测试用例的测试套件,任何实现都必须100%通过才能声称"CommonMark兼容”。
CommonMark的解析策略可以概括为两阶段解析:
第一阶段:块级解析 → 构建文档的块级结构
第二阶段:行内解析 → 处理每个块内的文本内容
这种分离的设计不是随意的。块级结构决定了文档的整体骨架——哪些是标题、哪些是列表、哪些是代码块。而行内解析则处理强调、链接、代码等文本级元素。分离的好处是:行内解析可以并行化,因为一个段落内的强调标记不会影响另一个段落的解析。
强调解析:一个算法噩梦
Markdown中最令人头疼的解析问题之一,是强调(emphasis)和粗体(strong)的处理。看起来简单的*和**符号,背后隐藏着一个精心设计的算法。
考虑这个例子:
*foo *bar* baz*
这应该解析成什么?
- 一个斜体文本
foo *bar* baz? - 还是嵌套的斜体
foo *bar* baz(其中bar是斜体中的斜体)?
CommonMark规范花了整整一节来定义强调解析算法。核心机制是delimiter stack(分隔符栈):
sequenceDiagram
participant Input as 输入文本
participant Scanner as 扫描器
participant Stack as 分隔符栈
participant Output as 输出AST
Input->>Scanner: 逐字符扫描
loop 遇到 * 或 _
Scanner->>Stack: 压入分隔符
Note over Stack: 记录位置、长度<br/>能否打开/关闭
end
Scanner->>Stack: 扫描完成,开始匹配
Stack->>Stack: 从后向前查找匹配
Stack->>Output: 生成强调节点
算法的关键概念是left-flanking和right-flanking delimiter run:
- left-flanking:分隔符后面跟着非空白字符,前面是空白或标点
- right-flanking:分隔符前面是非空白字符,后面是空白或标点
一个*可以打开强调,当它是left-flanking;可以关闭强调,当它是right-flanking。但规则的细节远比这复杂——还要考虑分隔符的长度(* vs **)、是否被空白包围、前面的字符类型等。
这就是为什么***foo**会被解析成粗体加星号:前两个**形成粗体的开始,第三个*无法匹配任何关闭分隔符,最终成为字面量。而***foo***则形成斜体加粗——前三个***同时打开了粗体和斜体,后三个则依次关闭它们。
Token还是AST?
不同的Markdown解析器采用了截然不同的内部表示方式。
markdown-it选择了Token流而非传统的抽象语法树(AST)。作者在文档中解释:“我们遵循KISS原则。AST不是必需的。”
Token流是一个扁平的数组,每个Token代表一个语法元素:
// *hello world* 的Token流
[
{ type: 'paragraph_open' },
{ type: 'inline', children: [
{ type: 'em_open' },
{ type: 'text', content: 'hello world' },
{ type: 'em_close' }
]},
{ type: 'paragraph_close' }
]
这种设计的优点是渲染效率高——直接遍历数组,依次调用对应的渲染函数即可。缺点是难以进行复杂的结构变换。
micromark则采用了状态机模型。它将解析过程分解为一系列状态和转换,每个字符都触发状态变化。这种方式更接近传统的编译器前端设计,能够精确定位每个字符的位置信息。
pulldown-cmark(Rust实现)引入了拉取式解析器(pull parser)的概念。它不构建完整的中间表示,而是返回一个迭代器,按需产生解析事件:
let parser = Parser::new("Hello *world*");
for event in parser {
match event {
Event::Text(text) => { /* 处理文本 */ },
Event::Emphasis => { /* 处理强调 */ },
// ...
}
}
这种设计支持零拷贝(zero-copy)——解析过程中不复制输入数据,只是产生指向原始数据的引用。在处理大文件时,内存占用极低。
mdast:一个统一的AST标准
无论解析器内部采用何种表示,最终用户往往需要一种标准化的AST格式来操作Markdown。这就是mdast(Markdown Abstract Syntax Tree)规范的用武之地。
mdast是unist(Universal Syntax Tree)规范的一种实现。unist定义了通用的AST节点接口:
interface Node {
type: string
data?: object
position?: Position
}
mdast在此基础上定义了Markdown特有的节点类型:
// *hello* 的AST
{
type: 'paragraph',
children: [
{
type: 'emphasis',
children: [
{ type: 'text', value: 'hello' }
]
}
]
}
mdast规范的核心价值是生态互通。基于mdast的工具链可以自由组合:
remark:Markdown处理器rehype:HTML处理器retext:自然语言处理器
一个典型的工作流:
Markdown → mdast → hast → HTML
↓
变换/分析
这使得开发者可以在AST层面进行各种操作:添加标题锚点、检查拼写、转换链接格式、提取目录等——而不需要关心底层解析器的实现细节。
安全漏洞:当Markdown成为攻击入口
Markdown的灵活性和HTML嵌入能力,使其成为XSS(跨站脚本攻击)的常见入口。
经典攻击向量
HTML标签注入是最直接的方式:
<script>alert('XSS')</script>
<img src="x" onerror="alert(1)">
javascript:协议在链接中:
[click me](javascript:alert('XSS'))
[click me](JaVaScRiPt:alert('XSS')) <!-- 大小写绕过 -->
data:URI:
[click](data:text/html;base64,PHNjcmlwdD5hbGVydCgnWFNTJyk8L3NjcmlwdD4K)
图片事件处理:
)
CVE案例
- CVE-2017-16006:remarkable解析器的data URI链接XSS漏洞
- CVE-2023-2317:Typora编辑器DOM型XSS,恶意Markdown文件可执行任意JavaScript
- CVE-2024-41662:Markdown XSS导致远程代码执行
- CVE-2025-24981:MDC Markdown解析器URL解析逻辑缺陷,可绕过安全检查
防护策略
白名单协议:只允许http:、https:、mailto:等安全协议:
const safeProtocols = ['http:', 'https:', 'mailto:']
function isSafeUrl(url) {
try {
const parsed = new URL(url, 'http://example.com')
return safeProtocols.includes(parsed.protocol)
} catch {
return false
}
}
HTML消毒:使用DOMPurify等库过滤危险标签:
import DOMPurify from 'dompurify'
const cleanHtml = DOMPurify.sanitize(markdownHtml, {
ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'code'],
ALLOWED_ATTR: ['href']
})
禁用HTML:部分解析器提供选项完全禁用HTML:
// markdown-it 配置
const md = new MarkdownIt({ html: false })
性能之战:毫秒必争
Markdown解析器的性能差异可能令人惊讶。在CommonMark社区的基准测试中,最快的实现比最慢的快了100倍以上。
影响性能的关键因素
内存分配:每次创建对象都有开销。Token-based实现需要为每个Token分配内存,而pull parser几乎不分配内存。
字符串处理:JavaScript的字符串是不可变的,每次操作都会创建新字符串。高效的解析器会尽量使用索引和切片,避免不必要的复制。
正则表达式:marked.js早期版本大量使用正则表达式,虽然代码简洁,但在大文件上性能不佳。正则表达式的回溯可能导致指数级复杂度。
流式解析
对于超大文件,流式解析是唯一可行的方案。micromark提供了流式接口:
import { createReadStream } from 'node:fs'
import { stream } from 'micromark/stream'
createReadStream('large.md')
.pipe(stream())
.pipe(process.stdout)
流式解析的核心挑战是处理不完整的语法结构。当解析器收到**hello但还没看到**时,它需要暂时保留这个状态,等待更多输入。
LLM时代的Markdown
ChatGPT和类似的大语言模型几乎全部使用Markdown格式输出。这给Markdown解析器带来了新的挑战:流式渲染。
当LLM逐token输出时,Markdown结构可能是不完整的:
**正在生成的加粗文本
此时解析器还没有看到闭合的**,如何处理?
常见的策略是:
- 延迟解析:等待语法结构完整后再渲染
- 保守渲染:将未闭合的分隔符当作字面量
- 智能补全:自动插入缺失的闭合符号
React Native中的解决方案是维护一个解析状态机,在每次收到新token时更新状态,而不是重新解析整个文档。
权衡的艺术
Markdown解析器的设计充满了权衡:
- 性能 vs 可扩展性:pull parser快但难以扩展,AST-based慢但便于操作
- 兼容性 vs 正确性:追求与历史实现兼容,还是严格执行规范?
- 安全 vs 功能:禁用HTML更安全,但失去嵌入能力
CommonMark项目花了十年时间,至今没有发布1.0版本——因为还有一些边界情况没有达成共识。Markdown的简单外表下,隐藏着编译器级别的复杂性。
理解这些技术细节,不是为了炫技。当你下次遇到"Markdown渲染不一致"的问题时,你会知道:这不是bug,而是规范未定义的灰色地带。当你选择Markdown解析器时,你会根据实际需求做出权衡:安全优先?性能优先?还是可扩展性优先?
一行文本的旅程,远比想象中漫长。
参考文献
- CommonMark Specification. https://spec.commonmark.org/current/
- GitHub Flavored Markdown Spec. https://github.github.com/gfm/
- mdast: Markdown Abstract Syntax Tree format. https://github.com/syntax-tree/mdast
- micromark documentation. https://github.com/micromark/micromark
- markdown-it architecture. https://markdown-it-py.readthedocs.io/en/latest/architecture.html
- John Gruber. Markdown: Syntax. https://daringfireball.net/projects/markdown/syntax
- John MacFarlane. Why is a spec needed? CommonMark Spec Section 1.2
- Jeff Atwood. Standard Markdown is now Common Markdown. Coding Horror, 2014
- OWASP. Cross Site Scripting Prevention Cheat Sheet
- DOMPurify documentation. https://github.com/cure53/DOMPurify
- CVE-2017-16006, CVE-2023-2317, CVE-2024-41662, CVE-2025-24981. NIST National Vulnerability Database
- pulldown-cmark documentation. https://docs.rs/pulldown-cmark
- Talk CommonMark. Performance of CommonMark reference implementations, 2014
- HackTricks. XSS in Markdown. https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/xss-in-markdown
- Chrome Developers. Best practices to render streamed LLM responses, 2025