您当前的位置:首页 > 互联网教程

markdown-it 原理解析

发布时间:2025-05-20 09:36:46    发布人:远客网络

markdown-it 原理解析

一、markdown-it 原理解析

在《一篇带你用 VuePress+ Github Pages搭建博客》中,我们使用 VuePress搭建了一个博客,最终的效果查看: TypeScript中文文档。

在搭建博客的过程中,我们出于实际的需求,在《VuePress博客优化之拓展 Markdown语法》中讲解了如何写一个 markdown-it插件,本篇我们将深入markdown-it的源码,讲解 markdown-it的执行原理,旨在让大家对 markdown-it有更加深入的理解。

引用 markdown-it Github仓库的介绍:

Markdown parser done right. Fast and easy to extend.

可以看出markdown-it是一个 markdown解析器,并且易于拓展。

其演示地址为: markdown-it.github.io/

我们查看markdown-it的入口代码,可以发现其代码逻辑清晰明了:

从render方法中也可以看出,其渲染分为两个过程:

跟 Babel很像,不过 Babel是转换为抽象语法树(AST),而markdown-it没有选择使用 AST,主要是为了遵循 KISS(Keep It Simple, Stupid)原则。

那 Tokens长什么样呢?我们不妨在演示页面中尝试一下:

可以看出# header生成的 Token格式为(注:这里为了展示方便,简化了):

具体 Token里的字段含义可以查看 Token Class。

通过这个简单的 Tokens示例也可以看出 Tokens和 AST的区别:

可以看到其具体执行的代码,应该是写在了./parse_core里,查看下 parse_core.js的代码:

可以看出,Parse过程默认有 6条规则,其主要作用分别是:

在 CSS中,我们使用normalize.css抹平各端差异,这里也是一样的逻辑,我们查看 normalize的代码,其实很简单:

我们知道\n是匹配一个换行符,\r是匹配一个回车符,那这里为什么要将\r\n替换成\n呢?

我们可以在阮一峰老师的这篇《回车与换行》中找到\r\n出现的历史:

在计算机还没有出现之前,有一种叫做电传打字机(Teletype Model 33)的玩意,每秒钟可以打10个字符。但是它有一个问题,就是打完一行换行的时候,要用去0.2秒,正好可以打两个字符。要是在这0.2秒里面,又有新的字符传过来,那么这个字符将丢失。于是,研制人员想了个办法解决这个问题,就是在每行后面加两个表示结束的字符。一个叫做"回车",告诉打字机把打印头定位在左边界;另一个叫做"换行",告诉打字机把纸向下移一行。这就是"换行"和"回车"的来历,从它们的英语名字上也可以看出一二。后来,计算机发明了,这两个概念也就被般到了计算机上。那时,存储器很贵,一些科学家认为在每行结尾加两个字符太浪费了,加一个就可以。于是,就出现了分歧。 Unix系统里,每行结尾只有"",即"\n";Windows系统里面,每行结尾是"",即"\r\n";Mac系统里,每行结尾是""。一个直接后果是,Unix/Mac系统下的文件在Windows里打开的话,所有文字会变成一行;而Windows里的文件在Unix/Mac下打开的话,在每行的结尾可能会多出一个^M符号。

之所以将\r\n替换成\n其实是遵循规范:

A line ending is a newline(U+000A), a carriage return(U+000D) not followed by a newline, or a carriage return and a following newline.

其中 U+000A表示换行(LF),U+000D表示回车(CR)。

除了替换回车符外,源码里还替换了空字符,在正则中,\0表示匹配 NULL(U+0000)字符,根据 WIKI的解释:

空字符(Null character)又称结束符,缩写 NUL,是一个数值为 0的控制字符。在许多字符编码中都包括空字符,包括ISO/IEC 646(ASCII)、C0控制码、通用字符集、Unicode和EBCDIC等,几乎所有主流的编程语言都包括有空字符这个字符原来的意思类似NOP指令,当送到列表机或终端时,设备不需作任何的动作(不过有些设备会错误的打印或显示一个空白)。

而我们将空字符替换为\uFFFD,在 Unicode中,\uFFFD表示替换字符:

之所以进行这个替换,其实也是遵循规范,我们查阅 CommonMark spec 2.3章节:

For security reasons, the Unicode character U+0000 must be replaced with the REPLACEMENT CHARACTER(U+FFFD).

效果如下,你会发现原本不可见的空字符被替换成替换字符后,展示了出来:

block这个规则的作用就是找出 block,生成 tokens,那什么是 block?什么是 inline呢?我们也可以在 CommonMark spec中的 Blocks and inlines章节找到答案:

We can think of a document as a sequence of blocks—structural elements like paragraphs, block quotations, lists, headings, rules, and code blocks. Some blocks(like block quotes and list items) contain other blocks; others(like headings and paragraphs) contain inline content—text, links, emphasized text, images, code spans, and so on.

我们认为文档是由一组 blocks组成,结构化的元素类似于段落、引用、列表、标题、代码区块等。一些 blocks(像引用和列表)可以包含其他 blocks,其他的一些 blocks(像标题和段落)则可以包含 inline内容,比如文字、链接、强调文字、图片、代码片段等等。

当然在markdown-it中,哪些会识别成 blocks,可以查看 parser_block.js,这里同样定义了一些识别和 parse的规则:

关于这些规则我挑几个不常见的说明一下:

code规则用于识别 Indented code blocks(4 spaces padded),在 markdown中:

fence规则用于识别 Fenced code blocks,在markdown中:

hr规则用于识别换行,在 markdown中:

reference规则用于识别 reference links,在 markdown中:

html_block用于识别 markdown中的 HTML block元素标签,就比如div。

lheading用于识别 Setext headings,在 markdown中:

inline规则的作用则是解析 markdown中的 inline,然后生成 tokens,之所以 block先执行,是因为 block可以包含 inline,解析的规则可以查看 parser_inline.js:

关于这些规则我挑几个不常见的说明一下:

newline规则用于识别\n,将\n替换为一个 hardbreak类型的 token

entity规则用于处理 HTML entity,比如{``¯``"等:

将(c)``(C)替换成©,将????????替换成???,将!!!!!替换成!!!,诸如此类:

为了方便印刷,对直引号做了处理:

Render过程其实就比较简单了,查看 renderer.js文件,可以看到内置了一些默认的渲染 rules:

其实这些名字也是 token的 type,在遍历 token的时候根据 token的 type对应这里的 rules进行执行,我们看下 code_inline规则的内容,其实非常简单:

至此,我们对 markdown-it的渲染原理进行了简单的了解,无论是 Parse还是 Render过程中的 Rules,markdown-it都提供了方法可以自定义这些 Rules,这些也是写 markdown-it插件的关键,这些后续我们会讲到。

博客搭建系列是我至今写的唯一一个偏实战的系列教程,讲解如何使用 VuePress搭建博客,并部署到 GitHub、Gitee、个人服务器等平台。

微信:「mqyqingfeng」,加我进冴羽唯一的读者群。

如果有错误或者不严谨的地方,请务必给予指正,十分感谢。如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。

二、怎样在markdown中写js代码可以被执行

在Markdown中编写能够执行的JS代码,主要通过两种方式实现,以达到渲染界面或展示计算结果的目的。一种方法是利用MDX,将Markdown与ReactJS组件结合,允许在引用的ReactJS组件中执行任何JS代码,实现动态渲染效果。另一种方法是借助Nteract,提供一个高级计算体验平台,能展示复杂计算结果。

三、使用Codemirror打造Markdown编辑器

1、前几天突然想给自己的在线编译器加一个Markdown编辑和预览功能,于是花了两三天敲敲打打初步实现了这个功能。

2、看上去想实现这些功能有点复杂,但是Codemirror提供了很多API可以更方便地修改编辑内容。

3、在阐述我是如何实现这些功能前,我先将实现时用到的API列出来。

4、cm.getRange(from:{line,ch},to:{line,ch},?separator:string)

5、在编辑器中的给定点之间获取文本。

6、cm.replaceRange(replacement:string,from:{line,ch},to:{line,ch},?origin:string)

7、用replacement替换给定点之间的文本。

8、cm.setCursor(pos:{line,ch}|number,?ch:number,?options:object)

9、cm.setSelection(anchor:{line,ch},?head:{line,ch},?options:object)

10、上面的API中,cm为Codemirror实例,也就是编辑器实例。line为行数,ch为列数(该行第几个字符)。

11、首先是粗体,斜体,中划线和代码,这四个功能实现的方法是相同的。

12、当用户触发添加粗体、斜体、中划线或代码事件时,流程如下:

13、如上图所示,先来说说光标没选中文本时的处理:

14、使用cm.getCursor()找到光标位置

15、使用cm.getRange()判断前后是否有匹配字符串(匹配字符串代表粗体、斜体、中划线或和代码的字符串:**、*、~~和``)。

16、使用cm.replaceSelection()添加匹配字符串

17、使用cm.replaceRange()清除匹配字符串

18、在光标选中文本的情况下,处理过程相对来说要复杂一些:

19、使用cm.listSelections()[0]获取第一组选中的文本,返回光标的起始位置与结束位置

20、判断所选文字的开头和结尾的位置,因为光标的起始位置是相对位置而不是绝对位置,也就是说当你从上到下,从左到右来选择文本的时候,光标起始位置所选文本开头,否则就是末尾。

21、使用cm.getRange()判断前后是否有匹配字符串

22、使用cm.replaceSelection()添加匹配字符串

23、使用cm.replaceRange()清除匹配字符串

24、接下来我说说如何实现引用,无序列表和有序列表。

25、我是按照VSCode的markdown插件的机制来处理这三种格式。当用户操作引用,无序列表和有序列表时的处理流程如下:

26、循环将每行前面加上>、-或数字.使其变为列表项

27、至于有序列表,需要先去除当前行前面的空格和制表符,再判断是否以数字.开头,如果有,便取出数字,下一行的数字逐步递增。其他的地方和无序列表差不多。

28、说完了编辑,再说说预览功能,一个支持markdown的编辑器怎么能没有同步滚动呢?

29、均匀滚动就是计算编辑窗口和预览窗口的滚动条高度以及这两个高度的比例,比方说编辑窗口的滚动条高度为2000px,预览窗口滚动条高度为4000px。

30、那么每当编辑窗口的滚动条滚动1px,那么预览窗口就滚动2px。

31、这样写的好处是方便且性能好,但缺陷很明显:

32、如果我在文档中加入了图片,并且图片很大,那么就会导致编辑窗口中显示的代码和预览窗口中的元素是不对应的,在代码很多,滚动条拉的越下的时候效果越明显。我这里拿Editor.md这个工具来举个例子:

33、我把滚动条拉的很下,我们可以发现编辑窗口显示的是Emoji的内容,但预览窗口显示的却是与之无关的公式和流程图。而公式和流程图所对应的代码实际上在Emoji的下面,这就比较影响编辑体验了。

34、考虑到这个弊端,我决定使用元素顶部定位方法。

35、Codemirror可以利用编辑器滚动条的位置来获取当前显示在最顶部的行的行号:

36、获取了行号,就可以获取该行及以上的所有代码。

37、然后我们使用marked工具将代码编译成HTML字符串,并使用DOMParser将其转换成真正的DOM元素:

38、为了考虑性能,我们不能匹配所有标签,因此需要制定一个匹配标签的集合:

39、上面的这些就是我们必须要匹配的标签和选择器,我们将doc中符合匹配条件的元素选出来:

40、那么matchEleList中最后的元素就是我们在预览视口中看到的第一个元素。

41、上述代码只是为了帮助理解,实际代码比上面的要复杂。

42、到此我们就实现了滚动编辑窗口,预览窗口也滚动到对应位置的效果了:

43、这样,一个简单但好用的markdown编辑器就完成啦!

44、Codemirror是一个扩展性很强的代码编辑工具,通过Codemirror提供的api我们可以实现很多功能。

45、这是我的在线编辑器地址和github仓库地址:

46、我还是个前端小白,如果觉得那些地方需要优化和改进,望指教!