vue-markdown-loader原始碼解析

雯子ATHENA發表於2018-12-06

專案中遇到了需要單獨載入某個 markdown 檔案顯示在頁面中,類似於操作指引的感覺,於是找到了 vue-markdown-loader 這個工具,覺得很好用,於是我打算開個專題看一下里面都做了些什麼,有助於對 webpack loader 的理解。

準備工作

這裡需要先了解 webpack loader 的原理,後面的程式碼需要配合 webpack 的官網對照著來看。中文版在這裡

首先我們需要知道 loader 是什麼,那麼 loader 其實就是一個 JavaScript module,匯出的是一個函式。loader runner 會呼叫這個函式,將前一個 loader 執行的結果或者原始檔作為引數傳遞進來。函式中的 this 上下文由 webpack 填充,loader runner 有一些實用的方法可以允許 loader 改變其觸發方式為非同步,或者獲取 query 引數。

在這裡摘錄一下 loader 的特性:

  • loader 支援鏈式傳遞。loader 鏈中每個 loader,都對前一個 loader 處理後的資源進行轉換。loader 鏈會按照相反的順序執行。第一個 loader 將(應用轉換後的資源作為)返回結果傳遞給下一個 loader,依次這樣執行下去。最終,在鏈中最後一個 loader,返回 webpack 所預期的 JavaScript。
  • loader 可以是同步的,也可以是非同步的。
  • loader 執行在 Node.js 中,並且能夠執行任何可能的操作。
  • loader 接收查詢引數。用於對 loader 傳遞配置。
  • loader 也能夠使用 options 物件進行配置。
  • 除了使用 package.json 常見的 main 屬性,還可以將普通的 npm 模組匯出為 loader,做法是在 package.json 裡定義一個 loader 欄位。
  • 外掛(plugin)可以為 loader 帶來更多特性。
  • loader 能夠產生額外的任意檔案。

瞭解原理之後,我們還要再看一篇文件,如何編寫一個loader

除錯工具

如果你想更方便的除錯程式碼,需要配置一下除錯環境,這裡我用的 VSCode 自帶的除錯工具。

在 debug 模式下點選配置按鈕,就會生成一個 .vscode/launch.json 的檔案,裡面的配置改成如下即可:

{ 
"type": "node", "request": "launch", "name": "啟動程式", "program": "${workspaceFolder
}/node_modules/webpack/bin/webpack.js"
, "cwd": "${workspaceFolder
}/example"

}複製程式碼

其中 program 設定成 webpack 本身的檔案執行的位置,cwd 設定成執行 webpack 所在的根目錄。點選啟動程式按鈕就可以斷點除錯了,想看啥看啥。

下面就可以開始看原始碼了。

目錄結構

首先看下目錄結構:

.├── README.md├── example│ 
 
├── index.html│ 
 
├── src│ 
 
│ 
 
├── app.vue│ 
 
│ 
 
├── custom.css│ 
 
│ 
 
├── entry.js│ 
 
│ 
 
└── markdown.md│ 
 
└── webpack.config.js├── index.js├── lib│ 
 
├── core.js│ 
 
└── markdown-compiler.js├── package-lock.json└── package.json複製程式碼

結構很清晰,東西也不算太多,涉及到的原始碼就是 index.jslib 下的兩個 js 檔案,example 裡面的是示例。

那我們就先從 index.js 開始看起吧。

就一句話:

module.exports = require('./lib/core');
複製程式碼

這個檔案是你引入這個包的入口,這裡直接去找的 ./lib/core,於是我們繼續去 ./lib/core 看看。

core.js

在宣告階段:

var path = require('path');
var loaderUtils = require('loader-utils');
var markdownCompilerPath = path.resolve(__dirname, 'markdown-compiler.js');
複製程式碼

這裡引用了 loader-utils 和同級目錄下的 markdown-compiler.js

在這裡我們逐行解析 core.js 裡面的程式碼,遇到什麼就去查什麼(帶序號的註釋是我自己加的,對應下面的解釋)。

module.exports = function(source) { 
// (1)是否可快取 this.cacheable();
// (2)獲取 options var options = loaderUtils.getOptions(this) || {
};
// (3)為 Compilation 物件新增 __vueMarkdownOptions__ 屬性 Object.defineProperty(this._compilation, '__vueMarkdownOptions__', {
value: options, enumerable: false, configurable: true
}) // (4) 獲取資原始檔的路徑 var filePath = this.resourcePath;
// (5) 生成 result var result = 'module.exports = require(' + loaderUtils.stringifyRequest( this, '!!vue-loader!' + markdownCompilerPath + '?raw!' + filePath + (this.resourceQuery || '') ) + ');
'
;
console.log(result) return result;

};
複製程式碼

首先可以看到這個檔案最後是匯出了一個 result,引數傳進來的是 source,也就是之前 loader 產生過的結果或者是原始檔,有個上下文 this。

(1)是否可快取

this.cacheable();
對應的是是否可快取

預設情況下,loader 的處理結果會被標記為可快取。呼叫這個方法然後傳入 false,可以關閉 loader 的快取。

一個可快取的 loader 在輸入和相關依賴沒有變化時,必須返回相同的結果。這意味著 loader 除了 this.addDependency 裡指定的以外,不應該有其它任何外部依賴。

(2)獲取 options

參考 loader-utils 的文件

是檢索呼叫 loader 的 options 的推薦方式。

本文中為{
}

(3)為 Compilation 物件新增__vueMarkdownOptions__ 屬性

(4)獲取資原始檔的路徑

獲取到的路徑是

"vue-markdown-loader/example/src/markdown.md"複製程式碼

(5)生成 result

"module.exports = require("!!vue-loader!../../lib/markdown-compiler.js?raw!./markdown.md");
"
複製程式碼

引數 source

這裡傳進來的 source 就是原始檔案:

"# Hello`<
span>
{{sss
}
}<
/span>
`>
This is test.- How are you?- Fine, Thank you, and you?- I'm fine, too. Thank you.- ?```javascriptimport Vue from 'vue'Vue.config.debug = true```<
div class="
test">
{{
model
}
} test<
/div>
<
compo>
{{
model
}
}<
/compo>
<
div class="
abc" @click="show = false">
啊哈哈哈<
/div>
>
All script or style tags in html mark will be extracted.Script will be excuted, and style will be added to document head.>
Notice if there is a string instance which contains special word "
&
lt;
/script>
", it will fetch a SyntaxError.>
Due to the complexity to solve it, just don't do that.```html<
style scoped>
.test {
background-color: green;

}<
/style>
<
style scoped>
.abc {
background-color: yellow;

}<
/style>
<
script>
let a=1<
2;
let b="
<
-forget it-/script>
";
console.log("
***This script tag is successfully extracted and excuted.***") module.exports = {
components: {
compo: {
render(h) {
return h('div', {
style: {
background: 'red'
}
}, this.$slots.default);

}
}
}, data () {
return {
model: 'abc'
}
}
}<
/script>
jjjjjjjjjjjjjjjjjjjjjj<
template>
<
div>
<
/div>
<
/template>
```<
div>
<
/div>
sadfsfs大家哦哦好啊誰都發生地方上的馮紹峰s>
sahhhh<
compo>
{{
model
}
}<
/compo>
```html<
compo>
{{model
}
}{{model
}
}{{model
}
}{{model
}
}{{
model
}
}<
/compo>
```<
style src="
./custom.css">
<
/style>
## 引入 style 檔案<
div class="
custom">
原諒色<
/div>
"
複製程式碼

通過 require 裡的引數,我們知道,這個 vue-markdown-loader loader 首先會載入 .md 檔案,然後通過 markdown-compiler.js?raw 來處理該檔案,再通過 vue-loader 處理。

所以我們需要繼續去 markdown-compiler.js 裡一探究竟。

markdown-compiler.js

首先我們看一下引入宣告部分:

var loaderUtils = require('loader-utils');
var hljs = require('highlight.js');
var cheerio = require('cheerio');
var markdown = require('markdown-it');
var Token = require('markdown-it/lib/token');
複製程式碼

再看下面的程式碼之前,有必要了解一下上面引入的東西

markdown-it

將 markdown 轉換成 html 的本尊。

有兩種使用方式:render函式和傳遞 options

// node.js, "classic" way:var MarkdownIt = require('markdown-it'),    md = new MarkdownIt();
var result = md.render('# markdown-it rulezz!');
// node.js, the same, but with sugar:var md = require('markdown-it')();
var result = md.render('# markdown-it rulezz!');
複製程式碼
// full options list (defaults)var md = require('markdown-it')({ 
html: false, // Enable HTML tags in source xhtmlOut: false, // Use '/' to close single tags (<
br />
).
// This is only for full CommonMark compatibility. breaks: false, // Convert '\n' in paragraphs into <
br>
langPrefix: 'language-', // CSS language prefix for fenced blocks. Can be // useful for external highlighters. linkify: false, // Autoconvert URL-like text to links // Enable some language-neutral replacement + quotes beautification typographer: false, // Double + single quotes replacement pairs, when typographer enabled, // and smartquotes on. Could be either a String or an Array. // // For example, you can use '«»„“' for Russian, '„“‚‘' for German, // and ['«\xA0', '\xA0»', '‹\xA0', '\xA0›'] for French (including nbsp). quotes: '“”‘’', // Highlighter function. Should return escaped HTML, // or '' if the source string is not changed and should be escaped externaly. // If result starts with <
pre... internal wrapper is skipped.
highlight: function (/*str, lang*/) {
return '';

}
});
複製程式碼

highlight.js

語法高亮工具

cheerio

Fast, flexible &
lean implementation of core jQuery designed specifically for the server.

接下來,我們先看幾個宣告的函式:

addVuePreviewAttr

/** * `<
pre>
<
/pre>
` =>
`<
pre v-pre>
<
/pre>
` * `<
code>
<
/code>
` =>
`<
code v-pre>
<
/code>
` * @param {string
} str * @return {string
} */
var addVuePreviewAttr = function(str) {
return str.replace(/(<
pre|<
code)/g
, '$1 v-pre');

};
複製程式碼

這個函式就是查詢 html 標籤中所有 <
pre
或者 <
code
,替換成 <
pre v-pre
<
code v-pre

renderHighlight

/** * renderHighlight * @param  {string
} str * @param {string
} lang */
var renderHighlight = function(str, lang) {
if (!(lang &
&
hljs.getLanguage(lang))) {
return '';

} return hljs.highlight(lang, str, true).value;

};
複製程式碼

返回通過 highlight.js 高亮後的資料

renderVueTemplate

/** * html =>
vue file template * @param {[type]
} html [description] * @return {[type]
} [description] */
var renderVueTemplate = function(html, wrapper) {
// 用 cheerio 提取引數傳進來的要進行處理的 html var $ = cheerio.load(html, {
decodeEntities: false, // 是否解碼文件實體,預設為 false lowerCaseAttributeNames: false, // 是否將所有屬性名設定成小寫,會對速度有影響,預設為 false lowerCaseTags: false // 是否將所有標籤轉換成小寫
});
// 將原 html 中的 style 和第一個 script 標籤快取起來 var output = {
style: $.html('style'), // get only the first script child. Causes issues if multiple script files in page. script: $.html($('script').first())
};
var result;
$('style').remove();
$('script').remove();
// 生成最後的結果 result = `<
template>
<
${wrapper
}
>
`
+ $.html() + `<
/${wrapper
}
>
<
/template>
\n`
+ output.style + '\n' + output.script;
return result;

};
複製程式碼

這是整個 loader 的核心功能,就是把 html 包裹一層 vue 的語法變成一個 vue 的元件,然後再讓後面的 vue-loader 接收。這裡用到了 cheerio 來做一些簡單的 DOM 操作。

說完了函式宣告,就繼續來看整個處理過程(帶序號的註釋是我自己加的,對應下面的解釋):

module.exports = function(source) { 
// (1) 是否可快取 this.cacheable &
&
this.cacheable();
var parser, preprocess;
// (2)獲取引數,此時把外面傳進來的解析成 Object {raw: true
}(來自core.js)
var params = loaderUtils.getOptions(this) || {
};
// (3)獲取 __vueMarkdownOptions__(來自core.js) var vueMarkdownOptions = this._compilation.__vueMarkdownOptions__;
// (4)繼承 vueMarkdownOptions 的原型,賦值給opts var opts = vueMarkdownOptions ? Object.create(vueMarkdownOptions.__proto__) : {
};
// inherit prototype var preventExtract = false;
// (5)合併所有來源的引數、屬性,彙總給opts opts = Object.assign(opts, params, vueMarkdownOptions);
// assign attributes // (6)判斷 options 中 preventExtract 是都為 true if (opts.preventExtract) {
delete opts.preventExtract;
preventExtract = true;

} // (7)判斷 options 中 render 的型別 if (typeof opts.render === 'function') {
// (8)如果是 function,parser 就是 opts parser = opts;

} else {
// (9)如果不是 function,為 opts 新增一些屬性,以及後面的一系列操作。opts 最後是作為 option 傳入 markdown-it 中的。 opts = Object.assign( {
preset: 'default', html: true, highlight: renderHighlight, wrapper: 'section'
}, opts );
// (10)初始化 外掛 plugins var plugins = opts.use;
// (11)初始化 預處理 preprocess preprocess = opts.preprocess;
delete opts.use;
delete opts.preprocess;
// (12)在這裡初始化 markdown-it parser = markdown(opts.preset, opts);
// (13)新增 ruler:從 html token 中提取 script 和 style //add ruler:extract script and style tags from html token content !preventExtract &
&
parser.core.ruler.push('extract_script_or_style', function replace( state ) {
let tag_reg = new RegExp('<
(script|style)(?:[^<
]|<
)+<
/\\1>
'
, 'g');
let newTokens = [];
state.tokens .filter(token =>
token.type == 'fence' &
&
token.info == 'html') .forEach(token =>
{
let tokens = (token.content.match(tag_reg) || []).map(content =>
{
let t = new Token('html_block', '', 0);
t.content = content;
return t;

});
if (tokens.length >
0) {
newTokens.push.apply(newTokens, tokens);

}
});
state.tokens.push.apply(state.tokens, newTokens);

});
// (14)如果有外掛,就應用一下 if (plugins) {
plugins.forEach(function(plugin) {
if (Array.isArray(plugin)) {
parser.use.apply(parser, plugin);

} else {
parser.use(plugin);

}
});

}
} // (15)覆蓋預設的 parser rules,在 'code' 和 'pre' 標籤上新增 v-pre 屬性 /** * override default parser rules by adding v-pre attribute on 'code' and 'pre' tags * @param {Array<
string>

} rules rules to override */
function overrideParserRules(rules) {
if (parser &
&
parser.renderer &
&
parser.renderer.rules) {
var parserRules = parser.renderer.rules;
rules.forEach(function(rule) {
if (parserRules &
&
parserRules[rule]) {
var defaultRule = parserRules[rule];
parserRules[rule] = function() {
return addVuePreviewAttr(defaultRule.apply(this, arguments));

};

}
});

}
} // (16)覆蓋這三種預設規則 overrideParserRules(['code_inline', 'code_block', 'fence']);
// (17)如果有預處理,執行一下 if (preprocess) {
source = preprocess.call(this, parser, source);

} // (18)將 source 中所有的 @ 替換成 '__at__' source = source.replace(/@/g, '__at__');
var content = parser.render(source).replace(/__at__/g, '@');
var result = renderVueTemplate(content, opts.wrapper);
if (opts.raw) {
return result;

} else {
return 'module.exports = ' + JSON.stringify(result);

}
};
複製程式碼

(6)preventExtract

preventExtract 是 vue-markdown-loader 提供的一個選項

Since v2.0.0, this loader will automatically extract script and style tags from html token content (#26). If you do not need, you can set this option

(13)新增 ruler:從 html token 中提取 script 和 style

走到這裡,就需要對 token 有一個認識才行,所以建議先看一下這篇文章。我摘錄一部分:

當你建立了一個 md = require('markdown-it')() 物件之後,就可以用它來渲染 MD 文件了,例如: md.render("# I'm H1 ")。這個渲染過程分為主要的兩步:

  1. 將 MD 文件 Parsing 為 Tokens。
  2. 渲染這個 Tokens。

Parsing 的過程是,首先建立一個 Core Parser,這個 Core Parser 包含一系列的預設 Rules。這些 Rules 將順序執行,每個 Rule 都在前面的 Tokens 的基礎上,要麼修改原來的 Token,要麼新增新的 Token。這個 Rules 的鏈條被稱為 Core Chain。

在所有 Tokens 都獲得之後,就可以渲染了。渲染就是把特定 Token 轉變為特定的 HTML 的過程。

Markdown-It 允許你為特定的 Token Type 掛載自己的渲染函式,這個函式稱為 Renderer Rule。Markdown-It 已經定義了幾個 預設的 Renderer Rules

(14)如果有外掛,就應用一下

MarkdownIt.use

在當前的解析例項中應用指定的外掛。

最終輸出的結果如下:

"<
template>
<
section>
<
h1>
Hello<
/h1>
<
p>
<
code v-pre="
">
&
lt;
span&
gt;
{{sss
}
}&
lt;
/span&
gt;
<
/code>
<
/p>
<
blockquote>
<
p>
This is test.<
/p>
<
/blockquote>
<
ul>
<
li>
How are you?<
/li>
<
li>
Fine, Thank you, and you?<
/li>
<
li>
I'm fine, too. Thank you.<
/li>
<
li>
?<
/li>
<
/ul>
<
pre v-pre="
">
<
code v-pre="
" class="language-javascript">
<
span class="
hljs-keyword">
import<
/span>
Vue <
span class="
hljs-keyword">
from<
/span>
<
span class="
hljs-string">
'vue'<
/span>
Vue.config.debug = <
span class="
hljs-literal">
true<
/span>
<
/code>
<
/pre>
<
div class="
test">
{{
model
}
} test<
/div>
<
p>
<
compo>
{{
model
}
}<
/compo>
<
/p>
<
div class="
abc" @click="show = false">
啊哈哈哈<
/div>
<
blockquote>
<
p>
All script or style tags in html mark will be extracted.Script will be excuted, and style will be added to document head.Notice if there is a string instance which contains special word &
quot;
&
lt;
/script&
gt;
&
quot;
, it will fetch a SyntaxError.Due to the complexity to solve it, just don't do that.<
/p>
<
/blockquote>
<
pre v-pre="
">
<
code v-pre="
" class="language-html">
<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
style<
/span>
<
span class="
hljs-attr">
scoped<
/span>
&
gt;
<
/span>
<
span class="
css">
<
span class="
hljs-selector-class">
.test<
/span>
{
<
span class="
hljs-attribute">
background-color<
/span>
: green;

}<
/span>
<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
style<
/span>
&
gt;
<
/span>
<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
style<
/span>
<
span class="
hljs-attr">
scoped<
/span>
&
gt;
<
/span>
<
span class="
css">
<
span class="
hljs-selector-class">
.abc<
/span>
{
<
span class="
hljs-attribute">
background-color<
/span>
: yellow;

}<
/span>
<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
style<
/span>
&
gt;
<
/span>
<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
script<
/span>
&
gt;
<
/span>
<
span class="
javascript">
<
span class="
hljs-keyword">
let<
/span>
a=<
span class="
hljs-number">
1<
/span>
&
lt;
<
span class="
hljs-number">
2<
/span>
;
<
span class="
hljs-keyword">
let<
/span>
b=<
span class="
hljs-string">
"
&
lt;
-forget it-/script&
gt;
"<
/span>
;
<
span class="
hljs-built_in">
console<
/span>
.log(<
span class="
hljs-string">
"
***This script tag is successfully extracted and excuted.***"<
/span>
) <
span class="
hljs-built_in">
module<
/span>
.exports = {
<
span class="
hljs-attr">
components<
/span>
: {
<
span class="
hljs-attr">
compo<
/span>
: {
render(h) {
<
span class="
hljs-keyword">
return<
/span>
h(<
span class="
hljs-string">
'div'<
/span>
, {
<
span class="
hljs-attr">
style<
/span>
: {
<
span class="
hljs-attr">
background<
/span>
: <
span class="
hljs-string">
'red'<
/span>

}
}, <
span class="
hljs-keyword">
this<
/span>
.$slots.default);

}
}
}, data () {
<
span class="
hljs-keyword">
return<
/span>
{
<
span class="
hljs-attr">
model<
/span>
: <
span class="
hljs-string">
'abc'<
/span>

}
}
}<
/span>
<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
script<
/span>
&
gt;
<
/span>
jjjjjjjjjjjjjjjjjjjjjj<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
template<
/span>
&
gt;
<
/span>
<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
div<
/span>
&
gt;
<
/span>
<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
div<
/span>
&
gt;
<
/span>
<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
template<
/span>
&
gt;
<
/span>
<
/code>
<
/pre>
<
div>
<
/div>
<
p>
sadfsfs<
/p>
<
p>
大家哦哦好啊誰都發生地方上的馮紹峰s<
/p>
<
blockquote>
<
p>
sahhhh<
/p>
<
/blockquote>
<
p>
<
compo>
{{
model
}
}<
/compo>
<
/p>
<
pre v-pre="
">
<
code v-pre="
" class="language-html">
<
span class="
hljs-tag">
&
lt;
<
span class="
hljs-name">
compo<
/span>
&
gt;
<
/span>
{{model
}
}{{model
}
}{{model
}
}{{model
}
}{{
model
}
}<
span class="
hljs-tag">
&
lt;
/<
span class="
hljs-name">
compo<
/span>
&
gt;
<
/span>
<
/code>
<
/pre>
<
h2>
引入 style 檔案<
/h2>
<
div class="
custom">
原諒色<
/div>
<
/section>
<
/template>
<
style src="
./custom.css">
<
/style>
<
style scoped>
.test {
background-color: green;

}<
/style>
<
style scoped>
.abc {
background-color: yellow;

}<
/style>
<
script>
let a=1<
2;
let b="
<
-forget it-/script>
";
console.log("
***This script tag is successfully extracted and excuted.***") module.exports = {
components: {
compo: {
render(h) {
return h('div', {
style: {
background: 'red'
}
}, this.$slots.default);

}
}
}, data () {
return {
model: 'abc'
}
}
}<
/script>
"
複製程式碼

如果中間哪步不太明白,也可以自行斷點除錯。總的思路還是很清晰的,就是把 .md 檔案通過 markdown-it 轉成 html,中間通過選項設定高亮,然後再包裹上 vue 元件的語法形式即可,後續再應用 vue-loader 做後面的處理。

來源:https://juejin.im/post/5c08d022f265da613074ab74

相關文章