为什么 Markdown 列表是解析器最难处理的块结构之一
Markdown 列表最容易诱导出一种错误实现:匹配行首的 - 、* 或 1. ,生成一个 <li>,遇到不匹配的行就结束列表。
这个实现能通过最简单的示例,却回答不了下面的问题:
10. alpha
beta
beta 属于第 10 个列表项,还是列表外的新段落?如果把三个空格改成四个,结果为什么又会变化?再把整个结构放进引用块、另一个列表项或代码围栏里,缩进应该从哪一列开始计算?
列表的难点不在于识别标记,而在于它是一个可以递归容纳其他块的容器。解析器必须逐行维护仍然打开的容器、计算相对缩进、区分普通段落延续和新块,并在整棵子树形成后决定列表是紧凑还是松散。
本文以 CommonMark 0.31.2 为基准,用 12 组输入对照 commonmark 0.31.2、markdown-it 15.0.0 与 marked 18.0.9 的实际结果,说明这些规则为何不能被一条正则表达式替代。
列表项不是一行文本,而是一组块
在语法树中,列表通常至少有三层:
document
└─ bullet_list
├─ item
│ ├─ paragraph("alpha")
│ └─ paragraph("beta")
└─ item
└─ paragraph("gamma")
对应的源文本可以是:
- alpha
beta
- gamma
第一项含两个段落,第二项含一个段落。列表项内部还可以放引用块、围栏代码块、标题、另一个列表,甚至多个不同类型的块。换句话说,item 不是叶子节点,而是块级容器。
CommonMark 将块分为容器块和叶子块,并在第一阶段逐行确定块结构;行内强调、链接、代码跨度等要等块边界稳定后才解析。列表因此处在块解析最棘手的位置:它既要参与当前行的匹配,又会改变后续行的解释环境。
一个可靠的块解析器处理新行时,思路更接近下面的状态机,而不是“查找列表正则”:
读取下一行
↓
从外到内检查所有已打开容器能否继续
↓
关闭无法继续的容器
↓
按优先级尝试打开新的引用、列表项、代码块等容器
↓
把剩余内容交给当前叶子块
例如当前路径是 blockquote → bullet_list → item → paragraph,下一行首先要证明它仍在引用块中,再证明缩进足以留在列表项中,最后才能判断它是原段落的延续还是一个新块。只检查行首字符会丢掉这条祖先路径。
真正规则是 W + N,不是“固定缩进四格”
CommonMark 列表项规则把列表标记宽度记为 W,把标记后用于分隔内容的空白宽度记为 N。在基本情形中,后续块需要相对列表项缩进 W + N。
对下面的项目:
- alpha
- 的宽度 W = 1,后面一个空格 N = 1,所以新块需要两个空格才能留在项目中:
- alpha
beta
这里 beta 只有一个前导空格。三种解析器都把它放在列表外:
<ul>
<li>alpha</li>
</ul>
<p>beta</p>
增加到两个空格:
- alpha
beta
beta 就成为同一列表项里的第二个段落。由于一个项目直接包含两个被空行分隔的块,输出中的两个段落都保留 <p> 标签。
有序标记会让“固定四格”的经验法则更明显地失效:
10. alpha
beta
10. 的 W = 3,再加一个分隔空格,阈值是四。三个空格不足,因此 beta 在列表外;四个空格才进入第 10 项。
这也是编辑器不能只显示绝对列号的原因。真正有意义的是相对当前列表项内容起点的缩进,而且这个起点会随着标记位数、嵌套层级、引用前缀和制表符展开方式变化。
“懒惰延续”只对段落文本开放
如果所有续行都必须补齐缩进,Markdown 会很难手写。因此 CommonMark 允许段落的懒惰延续:某些本应缩进的普通段落行,可以省略部分或全部缩进。
- alpha
lazy text
三种解析器都把 lazy text 留在列表项的同一段落中:
<ul>
<li>alpha
lazy text</li>
</ul>
但“懒惰”不是任意容错。它只适用于段落延续文本。把下一行换成新的块结构:
- alpha
lazy text
> quote
引用块不会自动钻进列表项。实测结果是列表先结束,然后产生一个与列表同级的 <blockquote>。如果作者希望引用属于列表项,就必须提供足够的相对缩进。
这一条规则迫使解析器同时知道两件事:当前打开的是不是段落,以及新行在当前上下文中能否成为段落延续。单独判断“缩进不足”或“行首是 >”都不够。
列表标记不一定能打断段落
下面两段只差一个数字:
intro
2. item
intro
1. item
三种解析器的结果一致:2. item 留在原段落中,而 1. item 可以打断段落并打开有序列表。
这是 CommonMark 为减少误判设置的限制。正文中以 2.、2026. 等形式开头的续行很常见;若任意数字都能在没有空行时打开列表,普通文本会被意外拆开。能打断段落的有序列表标记必须从 1 开始。
空列表项也不能打断段落:
intro
*
outro
这里的 * 仍属于 intro 段落,而不是一个空列表项。解析器不能看到合法标记就立即提交新节点,还必须结合“当前是否在段落中”“项目是否为空”等上下文验证启动条件。
紧凑与松散是渲染属性,但来自结构
这两段 Markdown 的 AST 都包含列表、列表项和段落;HTML 却不同。
紧凑列表:
- alpha
- beta
<ul>
<li>alpha</li>
<li>beta</li>
</ul>
松散列表:
- alpha
- beta
<ul>
<li><p>alpha</p></li>
<li><p>beta</p></li>
</ul>
空行不只是视觉空白。它可能让整个列表变为松散列表,从而让每个项目中的段落都显式渲染为 <p>。更隐蔽的是,只要某一项直接包含两个被空行分隔的块,同级项目的段落渲染也会受到影响。
我检查了 markdown-it 对“双段落列表项”生成的 token。它保留 paragraph_open 和 paragraph_close,并把这些 token 的 hidden 设为 false。在紧凑列表中,相应段落 token 仍可存在,但渲染器会隐藏包装标签。也就是说,紧凑与松散不等于“有没有段落语义”,而是列表结构形成后决定段落包装是否显示。
嵌套又增加了一层作用域:内层列表可以是松散的,外层列表仍然紧凑。实现若只在文档级维护一个 loose 布尔值,就会把状态错误传播到无关的祖先或兄弟列表。
12 组边界实验说明了什么
我在 2026 年 8 月 9 日固定版本、默认配置运行了以下矩阵。这里比较的是结构结果;Marked 在部分 HTML 的换行和标签排列上不同,不算结构差异。
| 编号 | 输入变量 | 三个解析器的结构结论 |
|---|---|---|
| C01 | 两个连续项目 | 紧凑列表 |
| C02 | 项目之间有空行 | 松散列表,项目段落带 <p> |
| C03 | 一项内有两个段落 | 整个列表松散 |
| C04 | - 项目后的新块缩进 1 格 |
新段落在列表外 |
| C05 | 同一位置缩进 2 格 | 新段落进入列表项 |
| C06 | 10. 项目后的新块缩进 3 格 |
新段落在列表外 |
| C07 | 同一位置缩进 4 格 | 新段落进入列表项 |
| C08 | 2. 紧跟普通段落 |
不打断段落 |
| C09 | 1. 紧跟普通段落 |
打断段落并打开列表 |
| C10 | 子列表相对缩进 2 格 | 形成嵌套列表 |
| C11 | 空 * 紧跟普通段落 |
不打断段落 |
| C12 | 懒惰文本后接未缩进引用 | 文本留在项目中,引用在列表外 |
三种解析器在这 12 组输入的块结构判断全部一致。C02—C07、C10 的原始 HTML 字符串并非逐字相同,差异来自换行、缩进和 <li><p> 的排列。这也说明回归测试不应只做整段 HTML 快照;结构相同的输出可能被误报为失败。
若要复现实验,可安装固定版本后运行:
npm install commonmark@0.31.2 markdown-it@15.0.0 marked@18.0.9
最小对照脚本如下:
import * as commonmark from "commonmark";
import markdownit from "markdown-it";
import { marked } from "marked";
const source = `10. alpha
beta`;
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
console.log("commonmark", writer.render(reader.parse(source)));
console.log("markdown-it", markdownit().render(source));
console.log("marked", marked.parse(source));
如果要手工观察复杂输入在不同输出工具中的表现,可以从 MDFold 的 Markdown 工具目录进入对应页面;但跨解析器结论仍应以固定版本、固定配置和保存下来的测试语料为准。
从源码看:实现维护的是状态,不是匹配结果
CommonMark 参考实现的块解析代码会维护打开块的链条,逐行尝试继续现有容器,再识别新的块开始。列表项需要保存自己的缩进信息,后续行据此决定是否匹配该容器。commonmark.js 的块解析源码直接体现了这种状态式处理。
markdown-it 的公开 token 也保留了这种结构信息:
type区分bullet_list_open、list_item_open、paragraph_open等节点;nesting使用1、0、-1表示打开、独立和关闭;level记录嵌套深度;map保存节点对应的源代码行范围;hidden让紧凑列表隐藏段落包装,而不必删除段落 token。
这些字段不是为了把 API 设计得复杂,而是对应真实需求:渲染器、格式化器、诊断器和编辑器都需要知道节点属于谁、从哪里开始、在哪里结束,以及哪些结构只在渲染阶段折叠。
工程上应该怎样测试列表
只测试 - a 和 - b 几乎没有价值。一个面向生产的列表测试集至少应覆盖以下维度。
1. 检查树,不只检查截图
断言列表数量、每个列表的项目数、项目的直接子块类型以及嵌套层级。HTML 字符串可以作为输出检查,但不应是唯一判断依据。
2. 对缩进做成对测试
每个阈值都应同时包含“少一格”和“刚好足够”两个用例,例如 C04/C05、C06/C07。只有成功用例无法证明边界真的被实现。
3. 分开测试段落延续和新块
同样的缩进不足,对普通文本可能触发懒惰延续,对引用、代码块或子列表却不成立。测试名称应明确写出预期节点归属,而不是笼统写“支持列表缩进”。
4. 验证松紧传播范围
检查一个项目中的空行是否正确影响同级列表,同时确认内层松散列表不会无条件污染外层列表。最终 HTML 中 <p> 是否显示,是很直观的观察点。
5. 固定方言、版本和配置
核心列表规则在三个解析器中表现一致,不代表所有扩展都一致。任务列表、定义列表、自定义容器和编辑器自动缩进都可能改变用户看到的行为。测试报告必须写明解析器版本与选项。
编辑器可以帮助作者,但不能重定义语法
编辑器适合减少错误:按回车时延续标记、按 Tab 时对齐当前内容列、显示不可见空格、把松散列表的空行高亮出来。这些功能能让作者更容易表达意图。
但解析器仍必须独立正确。Markdown 文件可能来自 Git、接口、旧文档、其他编辑器或自动生成程序,不能假设所有输入都经过同一个前端约束。反过来,编辑器也不应凭视觉缩进猜测并偷偷改写源码;当自动修复可能改变节点归属时,应该展示差异并让作者确认。
更合理的职责边界是:规范定义结构,解析器忠实识别结构,编辑器提前暴露歧义,格式化器只在可证明语义不变时自动重排。
结论
Markdown 列表难解析,是因为一个短标记同时启动了递归容器、相对缩进规则、段落中断限制、懒惰延续和松紧传播。解析器必须记住祖先状态,并把当前行放进整棵未完成的树里判断;正则表达式只看局部字符,无法承担这种结构决策。
如果你在实现解析器或文档流水线,最有价值的测试不是增加更多正常列表,而是围绕每个临界缩进、空行位置和容器组合建立成对用例,并断言 AST 不变量。
最后留下一个工程问题:列表语法的容错,应该由解析器承担,还是应该由编辑器在输入阶段阻止?当两者判断不同,哪一方才有权改写作者的源文本?

浙公网安备 33010602011771号