Loading...

Markdown 渲染原理

2025-06-09 · 11min

Markdown 可以说是我们开发人员最喜欢的写作工具了。只需要简单直观的语法,就可以用纯文本的形式书写内容,非常轻松就能实现文本格式化。

随着 AI 类产品的普及,Markdown 也开始在不同平台以不同形式进行展示。这背后是离不开其编译与渲染的过程。

最常见的就是将 Markdown 转换为 HTML 元素进行渲染,本篇主要了解 Markdown 渲染的原理。

Markdown 的编译与渲染原理

Markdown 的语法就不多说了,网上搜一大把。

Markdown 的编译与渲染过程可以分为三个阶段:

  • 解析(Parsing)
  • 转换(Transformation)
  • 渲染(Rendering)

解析(Parsing)

解析阶段是 Markdown 编译的第一步,负责将纯文本转成一种结构化的数据表示形式,这个也称作为抽象语法树(AST,Abstract Syntax Tree) 。

解析过程可以分成两个步骤:

  1. 词法分析:将 Markdown 文本分解为最小的语法单元(tokens)。例如,# 会识别为标题的开始、*会被识别成列表项标记,以及[文本](链接)会被识别成完整链接标记。
  2. 语法分析:语法分析负责将词法分析输出的 tokens 组织成语法树。这个过程不仅仅是简单将标记按照顺序去进行排列,还会分析标记之间的结构关系,最终会生成对应的树形结构。

为什么不用正则表达式解析

因为 Markdown 的语法是可以嵌套的,例如嵌套列表、链接加粗等。正则表达式在处理复杂嵌套结构、上下文依赖和边界情况时存在固有缺陷:

  • 无法处理嵌套:
    • Markdown 允许元素无限嵌套:列表中可以嵌套列表、引用块中可以嵌套代码块、代码块中可以包含其他元素的原始文本等。
    • 正则表达式基于有限状态自动机,本质上无法完美匹配任意深度的嵌套结构(如 (a(b(c))))。尝试用正则处理嵌套会导致表达式极其复杂、脆弱且难以维护。
  • 状态保持困难:
    • 解析 Markdown 需要跟踪状态:
      • 当前是否在一个代码块内?(在代码块内,所有其他 Markdown 语法都应被忽略,按原样输出)。
      • 当前是否在一个链接文本内?
      • 当前列表的级别和类型是什么?
      • 是否在一个段落中?遇到空行需要结束当前段落。
    • 正则表达式本身是无状态的。虽然可以通过编程语言的状态机配合多个正则来模拟,但这本质上就是在重新实现词法分析器+解析器,而且通常不如专门的解析库设计得优雅和健壮。
  • 边界处理复杂:
    • Markdown 规范存在一些歧义和边界情况。
    • 例子: 如何精确区分 *** 是 <strong><em> 还是 <em><strong> 还是 <hr>?链接和图片标签的嵌套和转义?表格对齐符的解析?脚注的关联?
    • 用正则处理这些细微差别会导致表达式集爆炸式增长,且极难调试和维护。
  • 性能和可维护性:一个试图覆盖所有 Markdown 语法的巨大正则表达式,通常:
    • 性能差: 复杂的正则匹配可能非常耗时,尤其对于长文档。
    • 可读性差: 难以理解和修改。
    • 稳定性差: 对输入文本的微小变化或未考虑到的边界情况容易出错。

转换(Transformation)

从前端开发的角度,这一步可以理解成,这一步就是从后端接口拿到原始数据了,但是我需要将数据变成方便渲染界面的格式。

具体转换的实现取决于使用的 Markdown 渲染库,自定义扩展也是在这一步进行。

转换主要涉及两个步骤:

  1. 语法树转换:这个阶段可能会对 AST 进行例如自定义语法扩展(如表格)等各种优化,还可能会插入特定的节点或重写现有的节点结构。
  2. 插件与扩展处理:不同的 Markdown 引擎会使用不同的插件或扩展来增强其功能,例如代码块高亮处理。自定义扩展处理也是这里进行。

渲染(Rendering)

这个步骤对前端开发来说就很好理解了,就是将转换好的 AST 通过遍历渲染成对应的格式,对我们前端开发来说就是渲染成 HTML 元素。

不同的 Markdown 引擎还可以输出成 PDF 或其他格式。

一般来说,渲染阶段还会生成一个 Source Map(源映射) 用于给开发者进行调试,查看渲染结果与源代码之间的关系。

Tokens & AST

从前端开发的角度来讲,上面三步骤最关键的就是解析步骤,转换跟渲染,其实在前端日常开发过程中就经常做。

而解析的目的就是将原始文本解析输出成 Tokens,组织成 AST。

不同的 Markdown 库其解析输出的 Tokens 可能会存在不同,但都遵循了共同原则:

Tokens

  1. 基础属性定义 所有Token至少包含以下字段:
属性说明示例
typeToken类型标识(如heading、paragraph){ type: "heading_open" }
tag对应的HTML标签名{ tag: "h1" }
nesting嵌套层级(1=开标签,-1=闭标签,0=自闭合){ nesting: 1 }
attrs属性键值对(如链接URL、图片alt文本)[["href", "https://example.com"]]
content原始文本内容(仅文本类Token){ content: "Hello" }
children子Token数组(仅容器类Token如inline)children: [textToken]
  1. 块级与内联分离
    • 块级 Token (如标题、段落)在顶层 Tokens 流中独立存在。
    • 内联 Token (如加粗、链接)必须嵌套在块级 Token 的 children 中。
  2. 解析优先级
    • 块级 Token ** 优先:先识别文档块(如段落、列表),再处理块内内联内容**。
    • 块级 Token 当中还有容器块与叶子块:
      • 容器块(如引用块)可嵌套其他块。
      • 叶子块(如代码块)不可嵌套其他块。

其实输出完 Tokens 后,想直接拿去进行转换跟渲染也不是不行,之所以还要转成 AST 是因为 Markdown 库的 Tokens 会存在一些冗余信息, 不适合描述文档语义结构。

转成 AST 就是解耦解析与渲染,提供扩展性和后续的转换能力。

像 Markdown-it 就没转成 AST 来进行 HTML 渲染,而是对 Token 流进行了轻量化设计,直接基于 Token 流进行转换渲染。

在 Markdown-it Demo 的 debug 模式下看到, # h1 Heading 8-) 会被解析成以下结构:

[
  {
    "type": "heading_open",
    "tag": "h1",
    "attrs": null,
    "map": [
      0,
      1
    ],
    "nesting": 1,
    "level": 0,
    "children": null,
    "content": "",
    "markup": "#",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  },
  {
    "type": "inline",
    "tag": "",
    "attrs": null,
    "map": [
      0,
      1
    ],
    "nesting": 0,
    "level": 1,
    "children": [
      {
        "type": "text",
        "tag": "",
        "attrs": null,
        "map": null,
        "nesting": 0,
        "level": 0,
        "children": null,
        "content": "h1 Heading ",
        "markup": "",
        "info": "",
        "meta": null,
        "block": false,
        "hidden": false
      },
      {
        "type": "emoji",
        "tag": "",
        "attrs": null,
        "map": null,
        "nesting": 0,
        "level": 0,
        "children": null,
        "content": "😎",
        "markup": "sunglasses",
        "info": "",
        "meta": null,
        "block": false,
        "hidden": false
      }
    ],
    "content": "h1 Heading 8-)",
    "markup": "",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  },
  {
    "type": "heading_close",
    "tag": "h1",
    "attrs": null,
    "map": null,
    "nesting": -1,
    "level": 0,
    "children": null,
    "content": "",
    "markup": "#",
    "info": "",
    "meta": null,
    "block": true,
    "hidden": false
  }
]

这样做的好处是:

  • 块级 Token ** 和内联 Token** 可以分开处理,如先解析所有块、再并行解析内联。
  • 插件可以直接操作 Token 数组,有的 Markdown 库的 AST 设计可能比较复杂,不易于开发者理解进行扩展。
  • 扁平化数组相比于深嵌套的 AST 更加节省内存。

AST

虽然 Markdown-it 直接通过 Token 流的方式是有不错的优点,但 Token 本身的缺点依然没解决。

以 remark 的 AST 为例:

{
  type: "heading",
  depth: 1,
  children: [ { type: "text", value: "Hello" } ] // 直接嵌套内容
}

仅需一个节点包含完整语义,无需额外推断。

两种方案对比各有优劣,对性能敏感的环境优先使用 Markdown-it。

特性Token 流AST
结构形式线性数组(如 [Token1, Token2, ...])嵌套树形(如 { type: 'root', children: [...] })
节点关系通过 nesting 等属性隐式表达层级关系通过 children 属性显式嵌套子节点
开/闭标签处理分拆为独立 Token(如 heading_open/close)合并为单一节点(如 heading 包含开闭语义)
设计目标记录解析过程(含冗余信息)描述文档语义结构(去冗余)

注意事项

因为 Markdown 渲染最终都是输出 HTML 字符串,我们最终都会通过 HTML 注入的方式渲染到页面上,所以获取到 HTML 字符串后,最好先进行一次安全监测,避免存在 <script> 标签注入。