Markdown-it 原理解析

冴羽發表於2022-01-25

前言

《一篇帶你用 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 解析器,並且易於擴充。

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

markdown-it具有以下幾個優勢:

  • 遵循 CommonMark spec 並且新增了語法擴充和語法糖(如URL 自動識別,針對印刷做了特殊處理)
  • 可配置語法,你可以新增新的規則或者替換掉現有的規則
  • 預設安全
  • 社群有很多的外掛或者其他包

使用

// 安裝
npm install markdown-it --save
// node.js, "classic" way:
var MarkdownIt = require('markdown-it'),
    md = new MarkdownIt();
var result = md.render('# markdown-it rulezz!');

// browser without AMD, added to "window" on script load
// Note, there is no dash in "markdownit".
var md = window.markdownit();
var result = md.render('# markdown-it rulezz!');

原始碼解析

我們檢視 markdown-it入口程式碼,可以發現其程式碼邏輯清晰明瞭:

// ...
var Renderer     = require('./renderer');
var ParserCore   = require('./parser_core');
var ParserBlock  = require('./parser_block');
var ParserInline = require('./parser_inline');

function MarkdownIt(presetName, options) {
  // ...
  this.inline = new ParserInline();
  this.block = new ParserBlock();
  this.core = new ParserCore();
  this.renderer = new Renderer();
  // ...
}

MarkdownIt.prototype.parse = function (src, env) {
  // ...
  var state = new this.core.State(src, this, env);
  this.core.process(state);
  return state.tokens;
};

MarkdownIt.prototype.render = function (src, env) {
  env = env || {};
  return this.renderer.render(this.parse(src, env), this.options, env);
};

render方法中也可以看出,其渲染分為兩個過程:

  1. Parse:將 Markdown 檔案 Parse 為 Tokens
  2. Render:遍歷 Tokens 生成 HTML

跟 Babel 很像,不過 Babel 是轉換為抽象語法樹(AST),而 markdown-it 沒有選擇使用 AST,主要是為了遵循 KISS(Keep It Simple, Stupid) 原則。

Tokens

那 Tokens 長什麼樣呢?我們不妨在演示頁面中嘗試一下:

可以看出 # header生成的 Token 格式為(注:這裡為了展示方便,簡化了):

[
  {
    "type": "heading_open",
    "tag": "h1"
  },
  {
    "type": "inline",
    "tag": "",
    "children": [
      {
        "type": "text",
        "tag": "",
        "content": "header"
      }
    ]
  },
  {
    "type": "heading_close",
    "tag": "h1"
  }
]

具體 Token 裡的欄位含義可以檢視 Token Class

通過這個簡單的 Tokens 示例也可以看出 Tokens 和 AST 的區別:

  1. Tokens 只是一個簡單的陣列
  2. 起始標籤和閉合標籤是分開的

Parse

檢視 parse 方法相關的程式碼:

// ...
var ParserCore   = require('./parser_core');

function MarkdownIt(presetName, options) {
  // ...
  this.core = new ParserCore();
  // ...
}

MarkdownIt.prototype.parse = function (src, env) {
  // ...
  var state = new this.core.State(src, this, env);
  this.core.process(state);
  return state.tokens;
};

可以看到其具體執行的程式碼,應該是寫在了./parse_core 裡,檢視下 parse_core.js 的程式碼:

var _rules = [
  [ 'normalize',      require('./rules_core/normalize')      ],
  [ 'block',          require('./rules_core/block')          ],
  [ 'inline',         require('./rules_core/inline')         ],
  [ 'linkify',        require('./rules_core/linkify')        ],
  [ 'replacements',   require('./rules_core/replacements')   ],
  [ 'smartquotes',    require('./rules_core/smartquotes')    ]
];

function Core() {
    // ...
}

Core.prototype.process = function (state) {
    // ...
  for (i = 0, l = rules.length; i < l; i++) {
    rules[i](state);
  }
};

可以看出,Parse 過程預設有 6 條規則,其主要作用分別是:

1. normalize

在 CSS 中,我們使用normalize.css 抹平各端差異,這裡也是一樣的邏輯,我們檢視 normalize 的程式碼,其實很簡單:

// https://spec.commonmark.org/0.29/#line-ending
var NEWLINES_RE  = /\r\n?|\n/g;
var NULL_RE      = /\0/g;


module.exports = function normalize(state) {
  var str;

  // Normalize newlines
  str = state.src.replace(NEWLINES_RE, '\n');

  // Replace NULL characters
  str = str.replace(NULL_RE, '\uFFFD');

  state.src = str;
};

我們知道 \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).

我們測試下這個效果:

md.render('foo\u0000bar'), '<p>foo\uFFFDbar</p>\n'

效果如下,你會發現原本不可見的空字元被替換成替換字元後,展示了出來:

2. block

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 中:

3. inline

inline 規則的作用則是解析 markdown 中的 inline,然後生成 tokens,之所以 block 先執行,是因為 block 可以包含 inline ,解析的規則可以檢視 parser_inline.js

關於這些規則我挑幾個不常見的說明一下:

newline規則用於識別 \n,將 \n 替換為一個 hardbreak 型別的 token

backticks 規則用於識別反引號:

entity 規則用於處理 HTML entity,比如 &#123;`&quot;等:

4. linkify

自動識別連結

5. replacements

(c)` (C) 替換成 ©,將 ???????? 替換成 ???,將 !!!!! 替換成 !!!`,諸如此類:

6. smartquotes

為了方便印刷,對直引號做了處理:

Render

Render 過程其實就比較簡單了,檢視 renderer.js 檔案,可以看到內建了一些預設的渲染 rules:

default_rules.code_inline
default_rules.code_block
default_rules.fence
default_rules.image
default_rules.hardbreak
default_rules.softbreak
default_rules.text
default_rules.html_block
default_rules.html_inline

其實這些名字也是 token 的 type,在遍歷 token 的時候根據 token 的 type 對應這裡的 rules 進行執行,我們看下 code_inline 規則的內容,其實非常簡單:

default_rules.code_inline = function (tokens, idx, options, env, slf) {
  var token = tokens[idx];

  return  '<code' + slf.renderAttrs(token) + '>' +
          escapeHtml(tokens[idx].content) +
          '</code>';
};

自定義 Rules

至此,我們對 markdown-it 的渲染原理進行了簡單的瞭解,無論是 Parse 還是 Render 過程中的 Rules,markdown-it 都提供了方法可以自定義這些 Rules,這些也是寫 markdown-it 外掛的關鍵,這些後續我們會講到。

系列文章

部落格搭建系列是我至今寫的唯一一個偏實戰的系列教程,講解如何使用 VuePress 搭建部落格,並部署到 GitHub、Gitee、個人伺服器等平臺。

  1. 一篇帶你用 VuePress + GitHub Pages 搭建部落格
  2. 一篇教你程式碼同步 GitHub 和 Gitee
  3. 還不會用 GitHub Actions ?看看這篇
  4. Gitee 如何自動部署 Pages?還是用 GitHub Actions!
  5. 一份前端夠用的 Linux 命令
  6. 一份簡單夠用的 Nginx Location 配置講解
  7. 一篇從購買伺服器到部署部落格程式碼的詳細教程
  8. 一篇域名從購買到備案到解析的詳細教程
  9. VuePress 部落格優化之 last updated 最後更新時間如何設定
  10. VuePress 部落格優化之新增資料統計功能
  11. VuePress 部落格優化之開啟 HTTPS
  12. VuePress 部落格優化之開啟 Gzip 壓縮
  13. 從零實現一個 VuePress 外掛

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

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。