Markdown 渲染原理
2025-06-09 · 11minMarkdown 可以说是我们开发人员最喜欢的写作工具了。只需要简单直观的语法,就可以用纯文本的形式书写内容,非常轻松就能实现文本格式化。
随着 AI 类产品的普及,Markdown 也开始在不同平台以不同形式进行展示。这背后是离不开其编译与渲染的过程。
最常见的就是将 Markdown 转换为 HTML 元素进行渲染,本篇主要了解 Markdown 渲染的原理。
Markdown 的编译与渲染原理
Markdown 的语法就不多说了,网上搜一大把。
Markdown 的编译与渲染过程可以分为三个阶段:
- 解析(Parsing)
- 转换(Transformation)
- 渲染(Rendering)
解析(Parsing)
解析阶段是 Markdown 编译的第一步,负责将纯文本转成一种结构化的数据表示形式,这个也称作为抽象语法树(AST,Abstract Syntax Tree) 。
解析过程可以分成两个步骤:
- 词法分析:将 Markdown 文本分解为最小的语法单元(tokens)。例如,
#会识别为标题的开始、*会被识别成列表项标记,以及[文本](链接)会被识别成完整链接标记。 - 语法分析:语法分析负责将词法分析输出的 tokens 组织成语法树。这个过程不仅仅是简单将标记按照顺序去进行排列,还会分析标记之间的结构关系,最终会生成对应的树形结构。
为什么不用正则表达式解析
因为 Markdown 的语法是可以嵌套的,例如嵌套列表、链接加粗等。正则表达式在处理复杂嵌套结构、上下文依赖和边界情况时存在固有缺陷:
- 无法处理嵌套:
- Markdown 允许元素无限嵌套:列表中可以嵌套列表、引用块中可以嵌套代码块、代码块中可以包含其他元素的原始文本等。
- 正则表达式基于有限状态自动机,本质上无法完美匹配任意深度的嵌套结构(如
(a(b(c))))。尝试用正则处理嵌套会导致表达式极其复杂、脆弱且难以维护。
- 状态保持困难:
- 解析 Markdown 需要跟踪状态:
- 当前是否在一个代码块内?(在代码块内,所有其他 Markdown 语法都应被忽略,按原样输出)。
- 当前是否在一个链接文本内?
- 当前列表的级别和类型是什么?
- 是否在一个段落中?遇到空行需要结束当前段落。
- 正则表达式本身是无状态的。虽然可以通过编程语言的状态机配合多个正则来模拟,但这本质上就是在重新实现词法分析器+解析器,而且通常不如专门的解析库设计得优雅和健壮。
- 解析 Markdown 需要跟踪状态:
- 边界处理复杂:
- Markdown 规范存在一些歧义和边界情况。
- 例子: 如何精确区分
***是<strong><em>还是<em><strong>还是<hr>?链接和图片标签的嵌套和转义?表格对齐符的解析?脚注的关联? - 用正则处理这些细微差别会导致表达式集爆炸式增长,且极难调试和维护。
- 性能和可维护性:一个试图覆盖所有 Markdown 语法的巨大正则表达式,通常:
- 性能差: 复杂的正则匹配可能非常耗时,尤其对于长文档。
- 可读性差: 难以理解和修改。
- 稳定性差: 对输入文本的微小变化或未考虑到的边界情况容易出错。
转换(Transformation)
从前端开发的角度,这一步可以理解成,这一步就是从后端接口拿到原始数据了,但是我需要将数据变成方便渲染界面的格式。
具体转换的实现取决于使用的 Markdown 渲染库,自定义扩展也是在这一步进行。
转换主要涉及两个步骤:
- 语法树转换:这个阶段可能会对 AST 进行例如自定义语法扩展(如表格)等各种优化,还可能会插入特定的节点或重写现有的节点结构。
- 插件与扩展处理:不同的 Markdown 引擎会使用不同的插件或扩展来增强其功能,例如代码块高亮处理。自定义扩展处理也是这里进行。
渲染(Rendering)
这个步骤对前端开发来说就很好理解了,就是将转换好的 AST 通过遍历渲染成对应的格式,对我们前端开发来说就是渲染成 HTML 元素。
不同的 Markdown 引擎还可以输出成 PDF 或其他格式。
一般来说,渲染阶段还会生成一个 Source Map(源映射) 用于给开发者进行调试,查看渲染结果与源代码之间的关系。
Tokens & AST
从前端开发的角度来讲,上面三步骤最关键的就是解析步骤,转换跟渲染,其实在前端日常开发过程中就经常做。
而解析的目的就是将原始文本解析输出成 Tokens,组织成 AST。
不同的 Markdown 库其解析输出的 Tokens 可能会存在不同,但都遵循了共同原则:
Tokens
- 基础属性定义 所有Token至少包含以下字段:
| 属性 | 说明 | 示例 |
|---|---|---|
type | Token类型标识(如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] |
- 块级与内联分离
- 块级 Token (如标题、段落)在顶层 Tokens 流中独立存在。
- 内联 Token (如加粗、链接)必须嵌套在块级 Token 的 children 中。
- 解析优先级
- 块级 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> 标签注入。