在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-flankingright-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)

图片事件处理

![](x"onerror="alert('XSS'))

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结构可能是不完整的:

**正在生成的加粗文本

此时解析器还没有看到闭合的**,如何处理?

常见的策略是:

  1. 延迟解析:等待语法结构完整后再渲染
  2. 保守渲染:将未闭合的分隔符当作字面量
  3. 智能补全:自动插入缺失的闭合符号

React Native中的解决方案是维护一个解析状态机,在每次收到新token时更新状态,而不是重新解析整个文档。

权衡的艺术

Markdown解析器的设计充满了权衡:

  • 性能 vs 可扩展性:pull parser快但难以扩展,AST-based慢但便于操作
  • 兼容性 vs 正确性:追求与历史实现兼容,还是严格执行规范?
  • 安全 vs 功能:禁用HTML更安全,但失去嵌入能力

CommonMark项目花了十年时间,至今没有发布1.0版本——因为还有一些边界情况没有达成共识。Markdown的简单外表下,隐藏着编译器级别的复杂性。

理解这些技术细节,不是为了炫技。当你下次遇到"Markdown渲染不一致"的问题时,你会知道:这不是bug,而是规范未定义的灰色地带。当你选择Markdown解析器时,你会根据实际需求做出权衡:安全优先?性能优先?还是可扩展性优先?

一行文本的旅程,远比想象中漫长。


参考文献

  1. CommonMark Specification. https://spec.commonmark.org/current/
  2. GitHub Flavored Markdown Spec. https://github.github.com/gfm/
  3. mdast: Markdown Abstract Syntax Tree format. https://github.com/syntax-tree/mdast
  4. micromark documentation. https://github.com/micromark/micromark
  5. markdown-it architecture. https://markdown-it-py.readthedocs.io/en/latest/architecture.html
  6. John Gruber. Markdown: Syntax. https://daringfireball.net/projects/markdown/syntax
  7. John MacFarlane. Why is a spec needed? CommonMark Spec Section 1.2
  8. Jeff Atwood. Standard Markdown is now Common Markdown. Coding Horror, 2014
  9. OWASP. Cross Site Scripting Prevention Cheat Sheet
  10. DOMPurify documentation. https://github.com/cure53/DOMPurify
  11. CVE-2017-16006, CVE-2023-2317, CVE-2024-41662, CVE-2025-24981. NIST National Vulnerability Database
  12. pulldown-cmark documentation. https://docs.rs/pulldown-cmark
  13. Talk CommonMark. Performance of CommonMark reference implementations, 2014
  14. HackTricks. XSS in Markdown. https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting/xss-in-markdown
  15. Chrome Developers. Best practices to render streamed LLM responses, 2025