markdown-it 原理解析

前言

在 《一篇帶你用 VuePress + Github Pages 搭建博客》[1] 中,我們使用 VuePress 搭建了一個博客,最終的效果查看:TypeScript 中文文檔 [2]。

在搭建博客的過程中,我們出於實際的需求,在《VuePress 博客優化之拓展 Markdown 語法》[3] 中講解了如何寫一個 markdown-it插件,本篇我們將深入markdown-it的源碼,講解 markdown-it的執行原理,旨在讓大家對 markdown-it有更加深入的理解。

介紹

引用 markdown-it Github 倉庫 [4] 的介紹:

Markdown parser done right. Fast and easy to extend.

可以看出 markdown-it是一個 markdown 解析器,並且易於拓展。

其演示地址爲:https://markdown-it.github.io/[5]

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

使用

// 安裝
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 的入口代碼 [9],可以發現其代碼邏輯清晰明瞭:

// ...
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 長什麼樣呢?我們不妨在演示頁面 [10] 中嘗試一下:

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

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

具體 Token 裏的字段含義可以查看 Token Class[11]。

通過這個簡單的 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 "i");
  }
};

可以看出,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 呢?

我們可以在阮一峯老師的這篇 《回車與換行》[12] 中找到\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其實是遵循規範 [13]:

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) 。

除了替換回車符外,源碼裏還替換了空字符,在正則 [14] 中,\0表示匹配 NULL(U+0000)字符,根據 WIKI[15] 的解釋:

空字符(Null character)又稱結束符,縮寫 NUL,是一個數值爲 0 的控制字符。

在許多字符編碼中都包括空字符,包括 ISO/IEC 646(ASCII)、C0 控制碼、通用字符集、Unicode 和 EBCDIC 等,幾乎所有主流的編程語言都包括有空字符

這個字符原來的意思類似 NOP 指令,當送到列表機或終端時,設備不需作任何的動作(不過有些設備會錯誤的打印或顯示一個空白)。

而我們將空字符替換爲 \uFFFD,在 Unicode 中,\uFFFD表示替換字符:

之所以進行這個替換,其實也是遵循規範,我們查閱 CommonMark spec 2.3 章節 [16]:

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 章節 [17] 找到答案:

We can think of a document as a sequence of blocks[18]—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[19] content—text, links, emphasized text, images, code spans, and so on.

翻譯一下就是:

我們認爲文檔是由一組 blocks 組成,結構化的元素類似於段落、引用、列表、標題、代碼區塊等。一些 blocks (像引用和列表)可以包含其他 blocks,其他的一些 blocks(像標題和段落)則可以包含 inline 內容,比如文字、鏈接、 強調文字、圖片、代碼片段等等。

當然在markdown-it中,哪些會識別成 blocks,可以查看 parser_block.js[20],這裏同樣定義了一些識別和 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[21]:

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

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

backticks 規則用於識別反引號:

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

4. linkify

自動識別鏈接

5. replacements

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

6. smartquotes

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

Render

Render 過程其實就比較簡單了,查看 renderer.js[22] 文件,可以看到內置了一些默認的渲染 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 插件的關鍵,這些後續我們會講到。

參考資料

[1]

《一篇帶你用 VuePress + Github Pages 搭建博客》: https://github.com/mqyqingfeng/Blog/issues/235

[2]

TypeScript 中文文檔: http://ts.yayujs.com/

[3]

《VuePress 博客優化之拓展 Markdown 語法》: https://github.com/mqyqingfeng/Blog/issues/251

[4]

markdown-it Github 倉庫: https://github.com/markdown-it/markdown-it

[5]

https://markdown-it.github.io/: https://markdown-it.github.io/

[6]

CommonMark spec: http://spec.commonmark.org/

[7]

插件: https://www.npmjs.com/search?q=keywords:markdown-it-plugin

[8]

其他包: https://www.npmjs.com/search?q=keywords:markdown-it

[9]

入口代碼: https://github.com/markdown-it/markdown-it/blob/master/lib/index.js

[10]

演示頁面: https://markdown-it.github.io/

[11]

Token Class: https://github.com/markdown-it/markdown-it/blob/master/lib/token.js

[12]

《回車與換行》: https://www.ruanyifeng.com/blog/2006/04/post_213.html

[13]

遵循規範: https://spec.commonmark.org/0.29/#line-ending

[14]

正則: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_Expressions#special-null

[15]

WIKI: https://zh.wikipedia.org/wiki/%E7%A9%BA%E5%AD%97%E7%AC%A6

[16]

CommonMark spec 2.3 章節: https://spec.commonmark.org/0.30/#insecure-characters

[17]

CommonMark spec 中的 Blocks and inlines 章節: https://spec.commonmark.org/0.30/#blocks-and-inlines

[18]

blocks: https://spec.commonmark.org/0.30/#blocks

[19]

inline: https://spec.commonmark.org/0.30/#inline

[20]

parser_block.js: https://github.com/markdown-it/markdown-it/blob/master/lib/parser_block.js

[21]

parser_inline.js: https://github.com/markdown-it/markdown-it/blob/master/lib/parser_inline.js

[22]

renderer.js: https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.js

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KuEX0hyYv611Dp9S6hKMXQ