前言
在 《一篇帶你用 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
方法中也可以看出,其渲染分為兩個過程:
- Parse:將 Markdown 檔案 Parse 為 Tokens
- 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 的區別:
- Tokens 只是一個簡單的陣列
- 起始標籤和閉合標籤是分開的
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,比如 {
`¯`"
等:
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、個人伺服器等平臺。
- 一篇帶你用 VuePress + GitHub Pages 搭建部落格
- 一篇教你程式碼同步 GitHub 和 Gitee
- 還不會用 GitHub Actions ?看看這篇
- Gitee 如何自動部署 Pages?還是用 GitHub Actions!
- 一份前端夠用的 Linux 命令
- 一份簡單夠用的 Nginx Location 配置講解
- 一篇從購買伺服器到部署部落格程式碼的詳細教程
- 一篇域名從購買到備案到解析的詳細教程
- VuePress 部落格優化之 last updated 最後更新時間如何設定
- VuePress 部落格優化之新增資料統計功能
- VuePress 部落格優化之開啟 HTTPS
- VuePress 部落格優化之開啟 Gzip 壓縮
- 從零實現一個 VuePress 外掛
微信:「mqyqingfeng」,加我進冴羽唯一的讀者群。
如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。