vue-markdown-loader源碼解析

項目中遇到了須要單獨加載某個 markdown 文件顯示在頁面中,相似於操做指引的感受,因而找到了 vue-markdown-loader 這個工具,以爲很好用,因而我打算開個專題看一下里面都作了些什麼,有助於對 webpack loader 的理解。javascript

準備工做

這裏須要先了解 webpack loader 的原理,後面的代碼須要配合 webpack 的官網對照着來看。中文版在這裏css

首先咱們須要知道 loader 是什麼,那麼 loader 其實就是一個 JavaScript module,導出的是一個函數。loader runner 會調用這個函數,將前一個 loader 執行的結果或者源文件做爲參數傳遞進來。函數中的 this 上下文由 webpack 填充,loader runner 有一些實用的方法能夠容許 loader 改變其觸發方式爲異步,或者獲取 query 參數。html

在這裏摘錄一下 loader 的特性:vue

  • 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 可以產生額外的任意文件。

瞭解原理以後,咱們還要再看一篇文檔,如何編寫一個loaderjava

調試工具

若是你想更方便的調試代碼,須要配置一下調試環境,這裏我用的 VSCode 自帶的調試工具。node

在 debug 模式下點擊配置按鈕,就會生成一個 .vscode/launch.json 的文件,裏面的配置改爲以下便可:webpack

{
      "type": "node",
      "request": "launch",
      "name": "啓動程序",
      "program": "${workspaceFolder}/node_modules/webpack/bin/webpack.js",
      "cwd": "${workspaceFolder}/example"
    }
複製代碼

其中 program 設置成 webpack 自己的文件執行的位置,cwd 設置成執行 webpack 所在的根目錄。點擊啓動程序按鈕就能夠斷點調試了,想看啥看啥。git

下面就能夠開始看源碼了。github

目錄結構

首先看下目錄結構:web

.
├── 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 作後面的處理。

相關文章
相關標籤/搜索