DEFAULTKAVY

Illustrator, Web Developer and VTuber

从零构建一个 Markdown 语法分析器

Published on 8/21/25 · Updated on 9/2/25

Markdown,对开发者而言是个简洁优雅的文本格式,网上现成的 Markdown to HTML 转换库非常多。最近在构建个人网站的博客页面,我选择了 Markdown 作为撰写博客文章的方式。这一切听上去是如此自然,一定已经有很多现成的解决方案了,对吧?

呵呵,对,但也不对。

在过往的个人项目中,我已经和各类 Markdown-HTML 转换器库打过不少交道。例如 ShowdownMarked 都是人气很高的库。但是在那时,我并不会去考虑这些库打包进网页代码中需要占用多大的体积。直到这次……

  • Showdown:约75.46 kB(gzip 24.99 kB)
  • Marked:约39.22 kB(gzip 12.18 kB)

光是导入这些库就已经让我的网页代码体积翻了好几倍……

我认真想了想,一个 Markdown 转换器真的需要这么大的体积吗?当然,也许这个大小对大部分人而言不痛不痒。但我在写了这么多轻量化的工具库之后(几乎都在5 kB以下),真的很难接受自己的网站代码里涌入一个大胖子了。

这些库无疑都是优秀的工具,它们都有着极高的人气并且让很多网站开发者都带来了便利,我相信它们都能在非常刁钻的环境要求下正常工作。但是我会思考这个问题:我只是要一个简单的转换功能,真的有必要让用户下载那多余的代码吗?

我不禁思考,如果我能只导入语法分析核心,并且能自定义任何语法模块,只把有需要的模块导入进来,或是自己撰写自己需要的特殊语法处理器,这是不是会更加优雅呢?

粗糙的第一版

忘了是在哪个项目里,我为了实现自定义的 Markdown 语法转换,用 String.replace() 函数扫描了符合 Regex 规则的字符并以 HTML 格式的覆盖原内容。例如:

const str = '# 这是一个标题'

str.replace(/^(#+?) (.+)/gm, (_, $1, $2) => {
	const level = $1.length; // $1 是标题开头的#,#的数量会决定标题的等级
	const content = $2; // $2 便是标题内容
	return `<h${level}>${content}</h${level}>` // 返回一个转换完成的HTML格式文本
})

如果你熟练掌握了 Regex 的规则,透过这样的方式处理文本转换其实非常简单。因此我在很短的时间内就做好了第一版的 Markdown 转换器,这个转换器乍看之下似乎已经没有什么问题,但其实我在写的当下便知道它背后潜藏了许多隐患。

  1. Regex 并不等于效率

    Regex 虽然是很便利的工具,但它并非是最有效率的。网上总能看到一些视频或文章针对这点指出,有时候使用 For Loop 反而比 Regex 处理得更快。

  2. 复杂的 Regex

    Markdown 中还有很多进阶的格式,它们拥有更加复杂的语法结构,筛选出这些文本会让 Regex 写得越来越复杂。

  3. 语法优先级

    如果单纯用 String.replace() 函数覆盖文本的方式去处理,一定会碰到各种难以处理的语法优先级问题,甚至可能会造成语法间覆盖冲突。

这些隐患很快就在我发布第一篇博客文章后就出现了。在我的测试中,都只是用简单的段落加上标准语法的方式来检测文本有没有顺利地被转换成HTML。但是一到真正的实战中,各种问题便接踵而至。用一句话描述结果:它根本做不好这些工作。

看到这样的结果,多少有些气馁了。但是这也让我更加确定我的判断没有错误,这些隐患必须用更加妥善的方式去解决,使用 String.replace() 终究是一种取巧方式,我必须要将文本分析做得更加透彻。

解构:语法分析器

作为一个兴趣使然的网页开发者,我其实对 Lexer 并没有足够的了解。不如说我对文本处理这件事是完全没有概念的,这一切对我来说是完全的新领域。我在网上翻找了许多关于 Lexer 的文章和定义,逐渐摸清轮廓后,便开始着手我的第一个 Lexer 项目。

Lexer 最主要的目的,是把语法转换的工作中的分析语法抽离,作为一个独立的分析工具将所有有意义的文本转化为 Token,然后才把这些 Token 交给 Parser 进行下一步的转换工作,这样会让分析转换的代码变得清晰且有效率得多。

举例,如果我将这段文本传入 JavaScript 的 Lexer:

const x = 1;

那么我得到的 Token 将会是:

TypeStartEndText
CONST04const
VARIABLE67x
EQUAL910=
NUMBER12131
SEMICOLON1415;

你会看到得出的 Token 结果中,空格字符都会被忽略,筛选出具有含义的字符并为其定义类型,这让后续的处理变得更加轻松。

越是底层的概念,实现起来确实是挺麻烦的。一开始我想要逐字扫描 Markdown 文本,后来发现这个方式用在代码文本可能会比较方便,但用在像 Markdown 这种连空格都具有含义的标记语言里,我认为逐行扫描会更加有效率得多。

我以字符 \r\n 作为分离字符,使用 String.split() 函数进行分离就能方便的得到每一行的文本,并逐行进行扫描处理。

# 这是一个标题

这是一段文本内容,以及一段(链接文字)[https://example.com]

在上面这段文本中,经过 Lexer 的处理后应该要得出以下结果:

TypeContentData
HEADING[TEXT]{ level: 1 }
EMPTY_LINE[]
PARAGRAPH[TEXT, LINK]

这里的 Token 我称作 Block Token,顾名思义就是以每个段落区块作为基本单位的语法类型,比如标题类型、空行类型、段落类型、列表类型等。每一个 Block Token 可包含多个 Inline Token,比如文字类型、链接类型、粗字类型等。

有了这些明确的结构数据后,就能够进行下一步的转换工作了。

解构:语法转换器

有了 Lexer 的帮助,Parser 的部分就变得非常轻松。绝大部分的语法转换都很简单,比如标题、粗体、斜体之类的,只需要在内容前后补上对应的HTML标签就能解决。

处理上较为麻烦的是列表,也就是 ulol 标签的语法转换。这东西的复杂程度真的不一般,追根究底是因为单纯从 Markdown 列表的语法结构和HTML列表的结构并不是能够直接转换的关系。

例如:

- Item 1
- Item 2
  - Item 2-1
	- Item 2-1-1
	Item 2-1 paragraph
  Item 2 paragraph
- Item 3

上面这段例子是 Markdown 列表中较为复杂的情况,我们需要分别处理以下几种问题:

  1. 这段列表内容是否包含子内容?
    • 如果有,子内容的 ul 元素必须包含在这段内容的 li 元素中。
    • 并且接下来的内容同样要判断是否是该 li 元素的子内容,或者是前一级 li 元素的子内容。
  2. 这段内容是否包含多个段落?
    • 如果有,那么这个 li 元素的内容必须被 p 元素包裹,并且接下来的每一行都需要判断该内容是否属于当前 li 元素的子内容。
    • 如果没有,那么就无需 p 元素。
  3. 判断父子级关系的唯一标识就是空格或 Tab,空格和 Tab 又是两种不同的字符,要多少个空格才算一个阶级?
    • 如果2个空格是一个阶级,用户输入了5个空格,那要算作是2级还是3级?

也许是HTML标准让这件事变得复杂起来,我需要判断的变数非常多。并且其实不同的 Markdown 编辑器支持的格式也稍有不同,这些问题都让我的处理过程变得更加痛苦。

不过,当你看到这个文章时,就代表我已经完美解决了这个问题了。因为现在这个页面的内容就是用我自己写的 Markdown 转换器构建出来的。

不是在重构,就是在重构的路上

折腾了这么多,虽然这个 Lexer 和 Parser 都能正确工作,但其核心概念仍有许多模糊之处。根据我的经验,一旦出现这种迹象就代表未来的某一天我一定会再重写一遍,直到我真的理解为止。

至少现在,我能说说目前这个库有哪些特性:

  • 与主流库相比,极其轻量化的体积。
  • 高度可自定义,所有语法处理都能模块化。
  • 代码核心逻辑非常简单,读一遍源代码就能明白。

如果想要在标准语法之上添加自定义的处理器,你可以从 Lexer 下手:

import { Markdown } 'amateras/markdown';
const markdown = new Markdown(); // Markdown 预设导入了所有基本语法
// 添加文本到 Token 的转换器
markdown.lexer.blockTokenizers.set('CUSTOM_TYPE', {
	regex: /#! (.+)/,
	handle: matches => {
		content: lexer.inlineTokenize(matches[1]!)
	}
})
// 添加 Token 到 HTML 的转换器
markdown.parser.processors.set('CUSTOM_TYPE', token => {
	return `<custom>${markdown.parser.parse(token.content!)}</custom>`
})

markdown.parseHTML('#! This is custom element')

或者你完全可以自己写一套自己的 Markdown 格式转换器,这个 Lexer 的核心逻辑就只是逐行扫描而已,非常易于理解。