Element的markdown-loader源码解析

3,232 阅读5分钟

Element md-loader源码地址

为何要抄md-loader

最近打算重写自己组件库的官网,在写展示组件部分的时候遇到了一个问题,所有组件的功能展示都在一个.vue文件里面写的话,会很麻烦。如果只用简单的md就可以转成需要的页面并且有代码高亮、demo展示框和页面样式,那该多好。

转换逻辑

事先修改webpack配置

module: {
        rules: [
            //.....
            {
                test: /\.md$/,
                use: [
                    {
                        loader: 'vue-loader',
                        options: {
                            compilerOptions: {
                                preserveWhitespace: false
                            }
                        }
                    },
                    {
                        loader: path.resolve(__dirname, './md-loader/index.js')
                    }
                ]
            },
        ]
    },

Element md-loader 目录

目录 大体功能
index.js 入口文件
config.js markdown-it的配置文件
containers.js render添加自定义输出配置
fence 修改fence渲染策略
util 一些处理解析md数据的函数

md-loader需要完成的功能

先看看demo


//demo.md
## Table 表格

用于展示多条结构类似的数据,可对数据进行排序、筛选、对比或其他自定义操作。

### 基础表格

基础的表格展示用法。

:::demo 当`el-table`元素中注入`data`对象数组后,在`el-table-column`中用`prop`属性来对应对象中的键名即可填入数据,用`label`属性来定义表格的列名。可以使用`width`属性来定义列宽。
```html
  <template>
    <el-table
      :data="tableData"
      style="width: 100%">
      <el-table-column
        prop="date"
        label="日期"
        width="180">
      </el-table-column>
      <el-table-column
        prop="name"
        label="姓名"
        width="180">
      </el-table-column>
      <el-table-column
        prop="address"
        label="地址">
      </el-table-column>
    </el-table>
  </template>

  <script>
    export default {
      data() {
        return {
          tableData: [{
            date: '2016-05-02',
            name: '王小虎',
            address: '上海市普陀区金沙江路 1518 弄'
          }
        }
      }
    }
  </script>
 
:::
(```)
  • 一套code两个用途,一个是作为示例展示,一个是作为示例代码,全部放进demo-block组件里面,也就是只写一套代码就够了

一个这样的demo-block就包含三样东西,标题、示例组件、示例代码。

  • 锚点

好了,下面就是一步步搞懂element的md-loader是如何做到这些的

准备工作

首先需要安装这两个依赖

yarn add markdown-it-chain markdown-it-anchor -D

config.js对markdown-it做配置

//config.js


const Config = require('markdown-it-chain');
const anchorPlugin = require('markdown-it-anchor');//给页眉添加锚点
const config = new Config();

config
  .options.html(true).end()

  .plugin('anchor').use(anchorPlugin, [
    {
      level: 2,
      slugify: slugify,
      permalink: true,
      permalinkBefore: true
    }
  ]).end()


const md = config.toMd();

module.exports = md;

markdown-it-anchor给页眉添加锚点

markdown-it-chain的链式配置参考文档

现在在index.js里面引入config.js


const md = require('./config');

module.exports = function(source) {
  const content = md.render(source) //得到来自.md的解析出来的数据,请记住这个content
           //....
}

既然是包装成.vue,那么最终输出的肯定是和平常见到的一模一样吧

//大概是这样
<template>
</template>

<script>
export default {
  
}
</script>

于是修改index.js

module.exports = function(source) {
  const content = md.render(source) //得到来自.md的解析出来的数据
           //....
     let script = `
           <script>
      export default {
        name: 'component-doc',
      }
    </script>`
     //script标签
     
     
     //输出这个template和script合并的字符串
          return `
    <template>
      <section class="content element-doc">  //template
      </section>
    </template>`
    ${pageScript};
           
}

现在需要考虑一个问题,一个md里面有很多类似的demo,最终输出的肯定是一个vue对象,那么这些demo就必须包装成一个个组件

使用渲染函数创建组件

很明显这里不能用模板创建组件,于是需要用到渲染函数的形式。

需要用到的插件

名字 大体功能
vue-template-compiler template转为render函数(作为配置项)
component-compiler-utils 编译Vue单文件组件的工具

(需要知道的)vue-loader 会借助 component-compiler-utils 的工具编译Vue单文件组件

现在在util.js里面引入他们

const { compileTemplate } = require('@vue/component-compiler-utils');
const compiler = require('vue-template-compiler');

在源码里面这个功能在util.js里面的genInlineComponentText函数完成。(这个函数挺复杂和冗长的,只能拆分说明)


function genInlineComponentText(template, script) {}

首先这个函数接受两个参数 templatescript,他们由各自对应的处理函数处理的, 数据来源便是一开始就解析出来的content

function stripScript(content) {
  const result = content.match(/<(script)>([\s\S]+)<\/\1>/);
  return result && result[2] ? result[2].trim() : '';
}  //只输出带有script标签的

function stripTemplate(content) {
  content = content.trim();
  if (!content) return content;
  return content.replace(/<(script|style)[\s\S]+<\/\1>/g, '').trim();
}//过滤清空掉script和style,输出自然是template

再来请点开上面table里面工具的github文档说明,对应里面的options配置,反正照着文档来就行

  const options = {
    source: `<div>${template}</div>`,
    filename: 'inline-component',
    compiler      // 这个compiler即是vue-template-compiler
  }

利用上面引入的compileTemplate编译

const compiled = compileTemplate(options)

如果有则抛出编译过程中的报错和警告

if (compiled.tips && compiled.tips.length) {
    compiled.tips.forEach(tip => {
      console.warn(tip);
    });
  }
  // errors
  if (compiled.errors && compiled.errors.length) {
    console.error(
     //.....
    );
  }

最后拿到编译后的code

  let demoComponentContent = `${compiled.code}`

现在处理script

 script = script.trim() //去掉两边空格
  if (script) {
    script = script.replace(/export\s+default/, 'const democomponentExport =') 
  } else {
    script = 'const democomponentExport = {}';
  }

这部分是把字符串里面的export default 替换为const democomponentExport =便于后面的解构赋值

最后return出去

demoComponentContent = `(function() {
    ${demoComponentContent}   // 跑了一遍
    ${script}
    return {
      render, //下面有说
      staticRenderFns, //这里不需要关注这个
      ...democomponentExport //解构出上面的对象的属性
    }
  })()`
  return demoComponentContent;

上面的render实际上在函数里面的${demoComponentContent}跑了一遍的时候就已经赋值了 可以看看这里demo编译出来compiled.code的结果

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h     //可以把下面return的_c当做是$createElement
  return _c(
    "div", // 一个 HTML 标签名
    [//.....] // 子节点数组 ,实际上也是由由$createElement构建而成
  )
}
var staticRenderFns = []
render._withStripped = true

那么现在就很明了了,重新看回之前的index.js

let script = ` <script> export default {
        name: 'component-doc',
        components: {
          'demo-components':(function() {
               var render = function() {
                    //.....
                     return {
                        render,
                        staticRenderFns,
                        ...democomponentExport
                       }
               })()
             }
      }
      </script>
      `

关于render._withStripped = true

如果未定义,则不会被get所拦截,这意味着在访问不存在的值后不会抛错。 这里使用了@component-compiler-utils,所以是自动加上的,可以不用关心。

回顾一下上面做了什么

  • markdown-it解析.md里面的数据
  • 抽离templatescript,通过插件编译成render Functioon,利用他创建组件。

现在的问题是一个content里面包含着所有的demo的数据,如何分辨并对每个demo做以上的操作,而且components里面的组件名字是不能重名的,如何解决这一点。

给每个Demo打上‘标记’

首先需要下载依赖

yarn add markdown-it-container -D

markdown-it-container地址

直接看源码

文档示例

element源码

const mdContainer = require('markdown-it-container');
module.exports = md => {
  md.use(mdContainer, 'demo', {
    validate(params) {
      return params.trim().match(/^demo\s*(.*)$/);
    },
    render(tokens, idx) {
      const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/);
      if (tokens[idx].nesting === 1) {
        const description = m && m.length > 1 ? m[1] : '';
        const content = tokens[idx + 1].type === 'fence' ? tokens[idx + 1].content : '';
        return `<demo-block>
        ${description ? `<div>${md.render(description)}</div>` : ''}
        <!--element-demo: ${content}:element-demo-->
        `;
      }
      return '</demo-block>';
    }
  })
};

需要了解的,下面的是一个块级容器,用(:::符号包裹),这个插件可以自定义这个块级所渲染的东西

::: demo  我是description(1)
  *demo*(2)
:::
  • tokens是一个数组,里面是块级容器里面所有md代码的code,按照一定规则分割,例如

tokens[idx].type === 'fence'是块级内容

  • 块级容器默认返回的是:::符号包裹的内容,也就是说即使是写在同一行的我是description默认是不会在content里面的,这里render所返回的就是插在content返回值里面的,至于是在包裹内容的前还是后,取决于页初还是页尾,也就是tokens[idx].nesting是否等于1,这一点在文档的Examples可以知道。

  • 于是现在这段代码的功能就很明显了,页初添加<demo-block>,页尾添加</ demo-block>,组成一个 <demo-block />组件, <demo-block/>是一个全局注册的组件,是示例展示用的,后面会提到。

在这个demo-block里面就是三样东西

${description}  //(1) description即为demo的开头说明,请返回demo.md查看
 <!--element-demo: ${content}:element-demo--> //(2) 展示组件 前面和后面的便是标记,在index.js会根据这个标记找到这段内容
${content} // (3) 展示的代码  这个东西会在fence.js文件里面做渲染覆盖,修改它的标签内容

根据标记找到并拼装组件

 const startTag = '<!--element-demo:';
  const startTagLen = startTag.length;
  const endTag = ':element-demo-->';
  const endTagLen = endTag.length;

  let componenetsString = '';
  let id = 0; // demo 的 id
  let output = []; // 输出的内容
  let start = 0; // 字符串开始位置

  let commentStart = content.indexOf(startTag);
  let commentEnd = content.indexOf(endTag, commentStart + startTagLen);
  while (commentStart !== -1 && commentEnd !== -1) {
    output.push(content.slice(start, commentStart));

    const commentContent = content.slice(commentStart + startTagLen, commentEnd);
    const html = stripTemplate(commentContent);
    const script = stripScript(commentContent);
    let demoComponentContent = genInlineComponentText(html, script);
    const demoComponentName = `element-demo${id}`;
    output.push(`<template slot="source"><${demoComponentName} /></template>`);
    componenetsString += `${JSON.stringify(demoComponentName)}: ${demoComponentContent},`;


    //这里start会是前一个标记结尾的地方,常规说来就是前一个demo的代码展示的开头
    id++;  //id用于拼接名字
    start = commentEnd + endTagLen;
    commentStart = content.indexOf(startTag, start);
    commentEnd = content.indexOf(endTag, commentStart + startTagLen);
  }
  output.push(content.slice(start)) //把后面余下的拼接上去

源码这一段有点多,首先需要搞懂各个变量的作用

  • demoComponentNameid,命名每个demo组件名字用的,id会在while每一次循环+1,这样子组件从第一个到最后一个都不会重名。

  • output是一个数组,最终会拼接放入输出vue文件的template里面,也就是这一页的HTML。 从上面代码可以看到,对output进行操作的只有三个地方,while循环开头会把description推进去,然后是推入展示组件字符串,

 output.push(`<template slot="source"><${demoComponentName} /></template>`)

这里面的demoComponentName会在最终输出的类vue对象字符串里面进行局部注册,也就是最上面提到的。

为什么最后有个output.push(content.slice(start))

let content = `
Description1  //description
Component1   // 展示组件
componentCode1//展示代码

Description2
Component2
componentCode2
`

content就是上面这样的结构,那么output实际上就是经历了以下过程

1·第一次循环

output.push(Description1)

output.push(Component1)

2.第二次循环

output.push(componentCode1)

output.push(Description2)

output.push(Component2)

3.循环结束

也就是说循环结束后,componentCode2是没有推入output的,而componentCode2包含 </ demo-block>,这样子在最后拼接的时候,HTML结构是有问题的。

  • demoComponentContent前面有讲过是返回的render FunctioncomponenetsString的结构类似下面的代码
`componentName1:(renderFn1)(),componentName2:(renderFn2)()`

最终在script的代码就是

script = `<script>
      export default {
        name: 'component-doc',
        components: {
          component1:(function() {*render1* })(),
          component2:(function() {*render2* })(),
          component3:(function() {*render3* })(),
        }
      }
    </script>`;

然后index.js里就可以返回这个啦,

 return `
    <template>
      <section class="content element-doc">
        ${output.join('')}
      </section>
    </template>
    ${script}
  `;

最后输出的大致代码就是

<h3>我是demo1</h3>
<template slot="source"><demo1/></template> 
<template slot="highlight"><pre v-pre><code class="html">${md.utils.escapeHtml(content)}</code></pre></template>
//第三个其实就是展示代码,在fence.js里面对其做了修改,以此对应具名插槽。

fence.js的操作大致就和上面我写的注释是一样的,主要代码如下

if (token.info === 'html' && isInDemoContainer) {
      return `<template slot="highlight"><pre v-pre><code class="html">${md.utils.escapeHtml(token.content)}</code></pre></template>`;
    }

通过覆盖修改默认输出,添加<template slot="highlight">便于在demo-block分发内容。

利用具名插槽分发内容

<template slot="source"><${demoComponentName} /></template> //展示组件

打开Element的源码,在example=>components里面可以找到demo-block.vue, 里面有这样的代码

   <div class="source">
      <slot name="source"></slot>
    </div>

在 template 上使用特殊的 slot 特性,可以将内容从父级传给具名插槽 。

添加锚点

yarn add markdown-it-anchor

使用

const Config = require('markdown-it-chain')
const anchor = require('markdown-it-anchor')
const containers = require('./containers')
const config = new Config()

config
    .options.html(true).end()
    .plugin('anchor').use(anchor, [
    {
        permalinkSymbol:'#', //修改默认链接的图案,默认是¶
        permalink: true, //是否在标题旁添加链接
        permalinkBefore: true,//将链接放在标题的左边
    }
]).end()

添加好配置后,需要在mounted钩子里给链接跳转添加基础路由

 renderAnchorHref() {
        if (/changelog/g.test(location.href)) return;
        const anchors = document.querySelectorAll('h2 a,h3 a,h4 a,h5 a');
        const basePath = location.href.split('#').splice(0, 2).join('#');

        [].slice.call(anchors).forEach(a => {
          const href = a.getAttribute('href');
          a.href = basePath + href;
        });
      },

至此,Elementmd-loader的大部分代码都及功能都看完了,感谢Element团队贡献出的源码,让我获益良多。

希望能对你理解源码有帮助,如果有什么不对的地方,欢迎批评指正。