阅读 467

趁storybook还没支持vue3 来撸个自己的md-loader

简述:为了保证各人群的观感,本文一共分三大块,分别对应了详细的分析+开发过程,只有代码版本以及避坑大赏,可以自行跳转各取所需(毕竟详细版本实在太长了)。

详细版本

element3作为组件库,其核心作用就是让用户可以根据文档正确使用组件,所以文档自然也应是重点之一。

既然是文档,那么作为可以有组织与高效的快速构建UI组件的storybook必然是作为首选。但是好巧不巧storybook现在并无法用于vue3的文档构建,我收到两种说法但基本都是保证在三月份之前是可以支持的。所以就趁着storybook还不支持,再来聊聊实现文档的另一套方案,也是当前还在使用的方案。

通过markdown-it编写md-loader实现对md文件的解析。

那我们不妨自己搞一个element3里面真实在使用的md-loader。不过在开始编写md-loader之前我们肯定先要知道markdown-it的使用方式。

markdown-it

markdown-it本身只是用于解析md语法,其导出一个函数,该函数既可以作为构造函数通过new进行创建实例,也可以直接作为普通函数调用并返回实例。创建出的实例包含一个叫做render的方法,这个方法就是markdown-it的核心方法。该方法可将markdown语法解析为html标签并使其可以在页面中正常渲染。

// markdown-it/lib/index.js
function MarkdownIt(presetName, options) {
  if (!(this instanceof MarkdownIt)) {
    return new MarkdownIt(presetName, options);
  }
  ...
}

const Markdown = require('markdown-it')
const md = Markdown() // const md = new Markdown()效果相同
const content = md.render('## 这是一个二级标题')
console.log(content) // <h2>这是一个二级标题</h2>
复制代码

但是我们是要用于文档使用,就显得太不够用了。所以在这里我们要再了解两个插件。

markdown-it-chain

首先是markdown-it-chain,这个插件是一个辅助插件。其作用等效于webpack-chain,也就是让markdown-it支持链式操作。

// 引入markdown-it-chain模块,该模块导出一个用于创建配置的构造函数。
const Config = require('markdown-it-chain')

// 通过new进行实例化得到配置实例
const config = new Config()

// 将配置结构改为链式操作
// 所有API调用时都将追踪到被存储的配置并将其改变
config
  // 作用于new Markdown时的options配置
  // Ref: https://markdown-it.github.io/markdown-it/#MarkdownIt.new
  .options
    .html(true) // 等同于 .set('html', true)
    .linkify(true)
    .end()

  // 作用于'plugins'
  .plugin('toc')
    // 第一个参数是插件模块,可以是一个函数
    // 第二个参数是该插件所接收的参数数组
    .use(require('markdown-it-table-of-contents'), [{
      includeLevel: [2, 3]
    }])
    // 和JQuery中的.end()类似
    .end()

  .plugin('anchor')
    .use(require('markdown-it-anchor'), [{
      permalink: true,
      permalinkBefore: true,
      permalinkSymbol: '$'
    }])
    // 在toc之前接受插件
    .before('toc')

// 使用上面的配置创建Markdown实例
const md = config.toMd()
md.render('[[TOC]] \n # h1 \n ## h2 \n ## h3 ')
复制代码

markdown-it-container

第二个插件可以说是md-loader的第一个重点了。这个插件是markdown-it-container,其用于创建可被markdown-it解析的自定义块级容器。

::: warning
*here be dragons*
:::
复制代码

这就是一个块级容器,如果我们没有给予它一个渲染器,那么它会被默认解析成下面这样

<div class="warning">
<em>here be dragons</em>
</div>
复制代码

不过这么说肯定还是没有办法理解这是什么意思,所以我们上代码实例。

const md = require('markdown-it')();

md.use(require('markdown-it-container'), 'spoiler', {
	// validate为校验方法 需要返回布尔值 为true时则校验成功
  validate: function(params) {
    // params为:::后面的内容 可以理解为:::后面的内容均为参数
    return params.trim().match(/^spoiler\s+(.*)$/);
  },
  // 渲染函数 根据返回值进行渲染
  render: function (tokens, index) {
    // token数组 包含所有解析出来的token 大致分为起始标签、结束标签和内容 它长下面这样
    /*
    	[
        Token {
          type: 'container_spoiler_open',
          tag: 'div',
          attrs: null,
          map: [ 0, 2 ],
          nesting: 1,
          level: 0,
          children: null,
          content: '',
          markup: ':::',
          info: ' spoiler click me',
          meta: null,
          block: true,
          hidden: false
        },
        Token {
          type: 'paragraph_open',
          tag: 'p',
          attrs: null,
          map: [ 1, 2 ],
          nesting: 1,
          level: 1,
          children: null,
          content: '',
          markup: '',
          info: '',
          meta: null,
          block: true,
          hidden: false
        },
        Token {
          type: 'inline',
          tag: '',
          attrs: null,
          map: [ 1, 2 ],
          nesting: 0,
          level: 2,
          children: [ [Token], [Token], [Token] ],
          content: '*content*',
          markup: '',
          info: '',
          meta: null,
          block: true,
          hidden: false
        },
        Token {
          type: 'paragraph_close',
          tag: 'p',
          attrs: null,
          map: null,
          nesting: -1,
          level: 1,
          children: null,
          content: '',
          markup: '',
          info: '',
          meta: null,
          block: true,
          hidden: false
        },
        Token {
          type: 'container_spoiler_close',
          tag: 'div',
          attrs: null,
          map: null,
          nesting: -1,
          level: 0,
          children: null,
          content: '',
          markup: ':::',
          info: '',
          meta: null,
          block: true,
          hidden: false
        }
      ]
    */
    console.log(tokens, 'tokens')
    // 当前token对应下标 只会是块起始与结束标签对应下标
    console.log(index, 'index')
    /* 匹配结果:
    		[
          'demo click me',
          'click me',
          index: 0,
          input: 'demo click me',
          groups: undefined
        ]
    */
    const m = tokens[index].info.trim().match(/^spoiler\s+(.*)$/);

    if (tokens[index].nesting === 1) {
      // opening tag
      return '<details><summary>' + md.utils.escapeHtml(m[1]) + '</summary>\n';
    } else {
      // closing tag
      return '</details>\n';
    }
  }
});

console.log(md.render('::: spoiler click me\n*content*\n:::\n'));

// 输出:
// <details><summary>click me</summary>
// <p><em>content</em></p>
// </details>
复制代码

正式开始

接下来我们创建一个markdown-loader文件夹,进入目录执行yarn init初始化package.json文件,同时再创建一个包含index.jsconfig.js文件的src目录

├── src
│ ├── config.js
│ └── index.js
└── package.json
复制代码

接下来我们在config.js文件当中书写配置文件并将生成的md实例通过module.exports导出

// src/config.js
const Config = require('markdown-it-chain')

const config = new Config()

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

const md = config.toMd()

module.exports = md

复制代码

然后我们在index.js中 导入md实例并且调用render方法先尝试渲染一个二级标题试试

// src/index.js
const md = require('./config.js')

console.log(md.render('## 二级标题'))
复制代码

结果我们竟然收获了一个报错?

$ node src/index
/Users/zhangyuxuan/Desktop/for github/markdown-loader/node_modules/markdown-it-chain/src/index.js:38
    return plugins.reduce((md, { plugin, args }) => md.use(plugin, ...args), md)
                   ^

TypeError: Cannot read property 'reduce' of undefined
    at MarkdownItChain.toMd (/Users/zhangyuxuan/Desktop/for github/markdown-loader/node_modules/markdown-it-chain/src/index.js:38:20)
    at Object.<anonymous> (/Users/zhangyuxuan/Desktop/for github/markdown-loader/src/config.js:9:19)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Module.require (internal/modules/cjs/loader.js:952:19)
    at require (internal/modules/cjs/helpers.js:88:18)
    at Object.<anonymous> (/Users/zhangyuxuan/Desktop/for github/markdown-loader/src/index.js:1:12)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
复制代码

看到这个报错让我无语了一阵,因为我明确我没有什么使用上的问题才对。没办法,我只好追到源码里面去。经过我的一阵探索,我迷茫了。我确实找到了bug的原因,但也就是因为我知道了原因才真正导致了我的迷茫。

还记得前面说markdown-it-chain的使用的时候,是包含config.plugin('toc')这样一段配置插件的代码。我万万没想到,这个插件是必须要有的。因为源代码里面并没有配置不传入插件的配置,那么我们就有了两条路,要么改源码,要么加上插件,很显然我们要选择最简单的那条路。加个插件不就好了,本来我们也是要使用markdown-it-container的。

// src/config.js
const Config = require('markdown-it-chain')

const config = new Config()

config
	.options
		.html(true)
		.end()
	.plugin('containers')
		.use(mdContainer, ['warning'])

const md = config.toMd()

module.exports = md
复制代码

这样就配置好了,但是你品,我们咋么可能用一个warning就能完成我们的文档展示。所以我们现在要开始很重要的一步,我们在src目录下新建一个containers.js文件,我们将会在这里自定义我们真正需要的块级容器。

编写containers.js

因为我们后续是要将containers作为插件直接传入plugin.use()中,所以这里我们需要通过module.exports直接导出一个函数,函数接收md实例作为参数。因为后续我们还需要保留warning和tip两种块级容器,所以记得调用md.use将两种块级容器挂载。

// src/containers.js
const mdContainer = require('markdown-it-container')

module.exports = function(md) {
	// 这里保留warning和tip 这两个文档里面随时可能会用到
  md.use(mdContainer, 'warning')
  md.use(mdContainer, 'tip')
}
复制代码

同时我们还要进行对我们来说最重要的demo容器的编写。首先我们在validator方法中需要校验的是demo字段这个是已经明确的,不过应该会有小伙伴觉得之前的判断方法是会复杂一些,我们其实可以直接使用RegExp.prototype.test方法进行判断就好了,并且test方法本身返回的就是布尔值。接下来我们就只需要把目光聚焦在render函数的编写就好。

// src/containers.js
const mdContainer = require('markdown-it-container')

module.exports = function(md) {
  md.use(mdContainer, 'demo', {
    validator(params) {
      return /^demo\s*(.*)$/.test(params)
    }
  })
	// 这里保留warning和tip 这两个文档里面随时可能会用到
  md.use(mdContainer, 'warning')
  md.use(mdContainer, 'tip')
}
复制代码

根据上面的实例我们可以知道,我们可以通过render函数中的匹配返回结果m[1]拿到demo后面的内容,那么我们就可以把这段文字作为当前demo的描述。我们先来进行起始标签的编写,上面的示例中我们已经知道,其实标签的判断方法就是来判断token[index].nesting === 1。所以首先我们加上这个判断,并在其中声明一个description常量,这就是我们上面所提到的demo的描述。我们需要判断我们是否成功匹配到了demo,如果匹配成功并且他的第1位存在,我们就是用m[1]作为描述,否则取空。所以我们的description应该是这样的:const description = m?.[1] || ''

// src/containers.js
const mdContainer = require('markdown-it-container')

module.exports = function(md) {
  md.use(mdContainer, 'demo', {
    validator(params) {
      return /^demo\s*(.*)$/.test(params)
    },
    render(tokens, index) {
      const m = tokens[index].info.trim().match(/^demo\s+(.*)$/)
      if (tokens[index].nesting === 1) {
        const description = m?.[1] || ''
      }
    }
  })
	// 这里保留warning和tip 这两个文档里面随时可能会用到
  md.use(mdContainer, 'warning')
  md.use(mdContainer, 'tip')
}
复制代码

有了描述,我们自然是需要把它渲染出来,但是我们还需要思考一个问题。在真实文档中的demo是会被实际渲染成组件的,所以最终我们要真是渲染出一个vue模板才可以,那么我们在render渲染的标签上就要做些手脚。我们先用一个div标签让他作为自定义标签正常的渲染出来,然后在其内部,添加一个div,在div当中展示我们的描述信息。

// src/containers.js
const mdContainer = require('markdown-it-container')

module.exports = function(md) {
  md.use(mdContainer, 'demo', {
    validator(params) {
      return /^demo\s*(.*)$/.test(params)
    },
    render(tokens, index) {
      const m = tokens[index].info.trim().match(/^demo\s+(.*)$/)
      if (tokens[index].nesting === 1) {
        const description = m?.[1] || ''
        return `
						<div>
							<div>${md.render(description)}</div>
				`
      }
    }
  })
	// 这里保留warning和tip 这两个文档里面随时可能会用到
  md.use(mdContainer, 'warning')
  md.use(mdContainer, 'tip')
}
复制代码

现在我们有了起始标签,只需要在简单的返回一个结束标签就可以看一下渲染结果了。

// src/containers.js
const mdContainer = require('markdown-it-container')

module.exports = function(md) {
  md.use(mdContainer, 'demo', {
    validator(params) {
      return /^demo\s*(.*)$/.test(params)
    },
    render(tokens, index) {
      const m = tokens[index].info.trim().match(/^demo\s+(.*)$/)
      if (tokens[index].nesting === 1) {
        const description = m?.[1] || ''
        return `
						<div>
							<div>${md.render(description)}</div>
				`
      }
      return `</div>`
    }
  })
	// 这里保留warning和tip 这两个文档里面随时可能会用到
  md.use(mdContainer, 'warning')
  md.use(mdContainer, 'tip')
}

console.log(md.render('::: demo click me\n*content*\n:::\n'))
// 输出结果
// <div>
// 		<div><p>click me</p></div>
// 		<p><em>content</em></p>
// </div>
复制代码

渲染真实文档内容

先别急,虽然现在已经可以渲染出这部分内容了,但是如果我们用一个真实的md文档当中的代码来试一试呢。

## Button 按钮

常用的操作按钮。

### 基础用法

基础的按钮用法。

:::demo 使用`type``plain``round``circle`属性来定义 Button 的样式。

​```html
<template>
  <el-row>
    <el-button>默认按钮</el-button>
    <el-button type="primary">主要按钮</el-button>
    <el-button type="success">成功按钮</el-button>
    <el-button type="info">信息按钮</el-button>
    <el-button type="warning">警告按钮</el-button>
    <el-button type="danger">危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button plain>朴素按钮</el-button>
    <el-button type="primary" plain>主要按钮</el-button>
    <el-button type="success" plain>成功按钮</el-button>
    <el-button type="info" plain>信息按钮</el-button>
    <el-button type="warning" plain>警告按钮</el-button>
    <el-button type="danger" plain>危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button round>圆角按钮</el-button>
    <el-button type="primary" round>主要按钮</el-button>
    <el-button type="success" round>成功按钮</el-button>
    <el-button type="info" round>信息按钮</el-button>
    <el-button type="warning" round>警告按钮</el-button>
    <el-button type="danger" round>危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button icon="el-icon-search" circle></el-button>
    <el-button type="primary" icon="el-icon-edit" circle></el-button>
    <el-button type="success" icon="el-icon-check" circle></el-button>
    <el-button type="info" icon="el-icon-message" circle></el-button>
    <el-button type="warning" icon="el-icon-star-off" circle></el-button>
    <el-button type="danger" icon="el-icon-delete" circle></el-button>
  </el-row>
</template>
​```

:::
复制代码

我们还是来打印通过md.render渲染上面这段md的结果

<demo-block>
  <div><p>使用<code>type</code><code>plain</code><code>round</code><code>circle</code>属性来定义 Button 的样式。</p></div>
  <pre><code class="language-html">&lt;template&gt;
  &lt;el-row&gt;
    &lt;el-button&gt;默认按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;primary&quot;&gt;主要按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;success&quot;&gt;成功按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;info&quot;&gt;信息按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;warning&quot;&gt;警告按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;danger&quot;&gt;危险按钮&lt;/el-button&gt;
  &lt;/el-row&gt;

  &lt;el-row&gt;
    &lt;el-button plain&gt;朴素按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;primary&quot; plain&gt;主要按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;success&quot; plain&gt;成功按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;info&quot; plain&gt;信息按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;warning&quot; plain&gt;警告按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;danger&quot; plain&gt;危险按钮&lt;/el-button&gt;
  &lt;/el-row&gt;

  &lt;el-row&gt;
    &lt;el-button round&gt;圆角按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;primary&quot; round&gt;主要按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;success&quot; round&gt;成功按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;info&quot; round&gt;信息按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;warning&quot; round&gt;警告按钮&lt;/el-button&gt;
    &lt;el-button type=&quot;danger&quot; round&gt;危险按钮&lt;/el-button&gt;
  &lt;/el-row&gt;

  &lt;el-row&gt;
    &lt;el-button icon=&quot;el-icon-search&quot; circle&gt;&lt;/el-button&gt;
    &lt;el-button type=&quot;primary&quot; icon=&quot;el-icon-edit&quot; circle&gt;&lt;/el-button&gt;
    &lt;el-button type=&quot;success&quot; icon=&quot;el-icon-check&quot; circle&gt;&lt;/el-button&gt;
    &lt;el-button type=&quot;info&quot; icon=&quot;el-icon-message&quot; circle&gt;&lt;/el-button&gt;
    &lt;el-button type=&quot;warning&quot; icon=&quot;el-icon-star-off&quot; circle&gt;&lt;/el-button&gt;
    &lt;el-button type=&quot;danger&quot; icon=&quot;el-icon-delete&quot; circle&gt;&lt;/el-button&gt;
  &lt;/el-row&gt;
&lt;/template&gt;
</code></pre>
</demo-block>
复制代码

不能说离谱吧,反正是毫无头绪。就算我们大概能猜到他是把标签和引号全部通过转移字符串进行了替换,也没有办法分析出来什么。

所以接下来我们要做的事情,就是把loader用起来,用到vue项目里面去。不过因为storybook就快支持vue3了,咱们也不搞什么复杂的东西了。直接创建一个vue项目,然后把我们的md-loader包丢进去。之后再在vue.config.js文件里面配置一下使用loader就好了。

// vue.config.js
const path = require('path')

module.exports = {
  chainWebpack: config => {
    config.module
      .rule('md2vue')
      .test(/\.md$/)
      .use('vue-loader')
      .loader('vue-loader')
      .end()
      .use('md-loader')
      .loader(path.resolve(__dirname, 'src/md-loader/index.js'))
      .end()
  }
}
复制代码

接下来我们只需要再稍微配置一下路由,把我们的md文件渲染到页面上即可,像这样

// router/index
import { createRouter, createWebHashHistory } from 'vue-router'

const Button = () => import('../docs/button.md')

const routes = [
  {
    path: '/',
    name: 'button',
    component: Button
  }
]

const router = createRouter({
  history: createWebHashHistory(),
  routes
})

export default router
复制代码

哦,突然发现忘了一件事,我们还需要在index.js里面正确导出我们的loader才可以,很简单的

// src/index.js
module.exports = source => {
  return `<template>
    ${md.render(source)}
  </template>`
}
复制代码

现在没问题了,我们只需要把vue项目启动,就可以看到md渲染出来的结果了。

什么?你报错了?是不是vue-loader忘了安装?你安装了?还是报错?

那,报错信息是不是parseComponent is not defined?这块非常恶心人,vue-loader版本一定要是16.0.0以上的,否则就会出现这个错误,并且安装的时候默认会是15.9.6版本(就令人迷惑的一波)。现在,是不是可以访问到页面了。

image-20210225110213533.png

看见这一坨,令我欣慰的是他确实渲染成功了,但是令我头痛的是这也长得太难看了点。所以我们现在的目标很明确,有三点:第一,我们要把代码高亮;第二,我们要让demo作为组件可以展示出来;第三,给这个页面加点样式,就像element3似的那种收缩。

代码高亮 highlight.js

我们先来处理代码高亮的问题,这个相对比较好解决,就是使用highlight.js来实现就好了,markdown-it 本身也是支持通过它来实现代码高亮的

// src/config.js
const hljs = require('highlight.js')
const highlight = (str, lang) => {
  if (!lang || !hljs.getLanguage(lang)) {
    return `<pre><code class="hljs">${str}</code></pre>`
  }
  const html = hljs.highlight(lang, str, true).value
  return `<pre><code class="hljs language-${lang}">${html}</code></pre>`
}

config.options
  .html(true)
  .highlight(highlight)
  .end()
  .plugin('containers')
  .use(containers)
  .end()

// public/index.html
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/highlight.js/10.5.0/styles/default.min.css'>
复制代码

这个就是highlight.js的用法,需要关注的其实也就是hljs.getLanguagehljs.highlight。前者是获取语言种类,也就是当前高亮代码是哪种语言,后者就是对代码进行高亮处理了。我们现在再来看一下效果,可能,你会发现没有效果。具体是什么原因我也还不太清楚,但是玩这个loader的时候,最好在package.json里面加个自定义的脚本"clean": "rm -rf node_modules && yarn cache clean",删掉依赖清除缓存然后重新安装一下依赖,大多问题就都解决了。

image-20210225114809343.png

看完这个效果,我属实也是不太淡定,我们还是把public/index.html里面的样式删掉后面我们自己改吧。怎么改呢?偷个鸡咯,加上<link rel="stylesheet" href="//shadow.elemecdn.com/npm/highlight.js@9.3.0/styles/color-brewer.css"/>,瞬间好看多了。

这样第一步暂时算是完成了,我们继续搞第二步,给demo块显示出来。达成这个目的我们需要先改动一下之前container.js的内容,不过改动并不大,只是把渲染的div改成一个demo-block组件,后续很多内容我们都需要在该组件中去编写。

// src/containers.js render
if (tokens[index].nesting === 1) {
  const description = m?.[1] || ''
  return `<demo-block>
  	<div>${md.render(description)}</div>
  `
}
return `</demo-block>`
复制代码

搞个demo-block

暂时先跳出我们的md-loader,因为接下来我们要写vue组件啦,不过我认为这并不是大家关心的点,所以我在这儿直接放出组件的代码。后续使用到哪个地方的时候我会加以描述的。

<!-- DemoBlock.vue -->
<template>
  <div class="demo-block">
    <div class="source">
      <slot name="source"></slot>
    </div>
    <div class="meta" ref="meta">
      <div class="description" v-if="$slots.default">
        <slot></slot>
      </div>
      <div class="highlight">
        <slot name="highlight"></slot>
      </div>
    </div>
    <div
      class="demo-block-control"
      ref="control"
      @click="isExpanded = !isExpanded"
    >
      <span>{{ controlText }}</span>
    </div>
  </div>
</template>

<script>
import { ref, computed, watchEffect, onMounted } from 'vue'
export default {
  setup() {
    const meta = ref(null)
    const isExpanded = ref(false)
    const controlText = computed(() =>
      isExpanded.value ? '隐藏代码' : '显示代码'
    )
    const codeAreaHeight = computed(() =>
      [...meta.value.children].reduce((t, i) => i.offsetHeight + t, 56)
    )
    onMounted(() => {
      watchEffect(() => {
        meta.value.style.height = isExpanded.value
          ? `${codeAreaHeight.value}px`
          : '0'
      })
    })

    return {
      meta,
      isExpanded,
      controlText
    }
  }
}
</script>

<style lang="scss">
.demo-block {
  border: solid 1px #ebebeb;
  border-radius: 3px;
  transition: 0.2s;

  &.hover {
    box-shadow: 0 0 8px 0 rgba(232, 237, 250, 0.6),
      0 2px 4px 0 rgba(232, 237, 250, 0.5);
  }

  code {
    font-family: Menlo, Monaco, Consolas, Courier, monospace;
  }

  .demo-button {
    float: right;
  }

  .source {
    padding: 24px;
  }

  .meta {
    background-color: #fafafa;
    border-top: solid 1px #eaeefb;
    overflow: hidden;
    transition: height 0.2s;
  }

  .description {
    padding: 20px;
    box-sizing: border-box;
    border: solid 1px #ebebeb;
    border-radius: 3px;
    font-size: 14px;
    line-height: 22px;
    color: #666;
    word-break: break-word;
    margin: 10px;
    background-color: #fff;

    p {
      margin: 0;
      line-height: 26px;
    }

    code {
      color: #5e6d82;
      background-color: #e6effb;
      margin: 0 4px;
      display: inline-block;
      padding: 1px 5px;
      font-size: 12px;
      border-radius: 3px;
      height: 18px;
      line-height: 18px;
    }
  }

  .highlight {
    pre {
      margin: 0;
    }

    code.hljs {
      margin: 0;
      border: none;
      max-height: none;
      border-radius: 0;

      &::before {
        content: none;
      }
    }
  }

  .demo-block-control {
    border-top: solid 1px #eaeefb;
    height: 44px;
    box-sizing: border-box;
    background-color: #fff;
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
    text-align: center;
    margin-top: -1px;
    color: #d3dce6;
    cursor: pointer;
    position: relative;

    i {
      font-size: 16px;
      line-height: 44px;
      transition: 0.3s;
      &.hovering {
        transform: translateX(-40px);
      }
    }

    > span {
      position: absolute;
      transform: translateX(-30px);
      font-size: 14px;
      line-height: 44px;
      transition: 0.3s;
      display: inline-block;
    }

    &:hover {
      color: #409eff;
      background-color: #f9fafc;
    }

    & .text-slide-enter,
    & .text-slide-leave-active {
      opacity: 0;
      transform: translateX(10px);
    }

    .control-button {
      line-height: 26px;
      position: absolute;
      top: 0;
      right: 0;
      font-size: 14px;
      padding-left: 5px;
      padding-right: 25px;
    }
  }
} 
.hljs {
  line-height: 1.8;
  font-family: Menlo, Monaco, Consolas, Courier, monospace;
  font-size: 12px;
  padding: 18px 24px;
  background-color: #fafafa;
  border: solid 1px #eaeefb;
  margin-bottom: 25px;
  border-radius: 4px;
  -webkit-font-smoothing: auto;
}
</style>
复制代码

记得在main.jsDemoBlock注册为组件。这时候我们再来看一下效果。

image-20210304143446684.png

很明显现在就剩下两个问题了。第一,上面的source也就是我们会真实被渲染出来的demo还没有;第二,description展示的很好,但是下面代码并没有渲染出来。那么我们现在就搞定这两点,先来搞定代码展示,毕竟这个比较简单嘛。

说到代码展示,我们需要回到demo-block组件看一下,这里放了一个具名插槽highlight,这个插槽就是为了后续渲染展示代码使用的。所以我们可以先明确一点,就是我们需要在展示代码外面,加上对应的template #highlight

替换fence渲染规则

要做到这点还是简单的,还记得之前我们打印tokens的时候见过一个type: fencetoken么?fence直译栅格,其实就是md语法的```也就是代码块,我们其实就是要修改它的渲染规则。你说巧不巧,在markdown-it中暴露出了修改方法md.renderer.rules.fence。所以我们只要有md实例就可以进行修改了。

那我们找个简单的途径,搞个函数传参进去不就好了。

// fence.js 覆盖默认的 fence 渲染策略
module.exports = md => {

}

复制代码

接下来我们就开始编写覆盖渲染的逻辑,首先我们要了解的是md.renderer.rules.fence是一个函数,他一共需要接受五个参数tokens, idx, options, env, slf。第一二个参数大家应该已经很熟悉了,第三个参数应该也有些许印象,它其实就是md的options配置。从第四个参数应该会较为陌生,env,这货我也不知道是干嘛的,因为fence里面根本用不上它,我们要用的是slf,但是函数嘛,你也懂。这最后一个参数slf其实就是renderer实例。

看到这儿是不是还挺迷茫的,其实我想告诉你,这五个参数我们只需要关心前两个,因为后三个参数是我们为了相对简单,要保留原有的渲染逻辑而必须传进去的参数(有没有很绝望,反正这三个咱们是暂时用不上了)。

// fence.js 覆盖默认的 fence 渲染策略
module.exports = md => {
	const defaultRender = md.renderer.rules.fence
  md.renderer.rules.fence = (tokens, idx, options, env, self) => {
    
  }
}
复制代码

这部分渲染逻辑改写其实蛮简单的,我们只需要判断一下我们当前这个fence是否是在一个自定义块容器当中,所以我们只需要获取一下当前index前一位来判断一下就好了

// fence.js 覆盖默认的 fence 渲染策略
module.exports = md => {
	const defaultRender = md.renderer.rules.fence
  md.renderer.rules.fence = (tokens, idx, options, env, self) => {
    const token = tokens[idx]
    // 判断该 fence 是否在 :::demo 内
    const prevToken = tokens[idx - 1]
    // 前面提过nesting === 1为起始标签,如果同时符合demo的正则匹配表明它是我们的目标fence
    const isInDemoContainer =
      prevToken &&
      prevToken.nesting === 1 &&
      /^demo\s*(.*)$/.test(prevToken.info)
  }
}
复制代码

最后我们只需要再判断一下我们的目标fence是否为html语言的就好了,我们就可以对原有内容进行改写了,最后记得调用我们的defaultRender渲染一下

// fence.js 覆盖默认的 fence 渲染策略
module.exports = md => {
	const defaultRender = md.renderer.rules.fence
  md.renderer.rules.fence = (tokens, idx, options, env, self) => {
    const token = tokens[idx]
    // 判断该 fence 是否在 :::demo 内
    const prevToken = tokens[idx - 1]
    // 前面提过nesting === 1为起始标签,如果同时符合demo的正则匹配表明它是我们的目标fence
    const isInDemoContainer =
      prevToken &&
      prevToken.nesting === 1 &&
      /^demo\s*(.*)$/.test(prevToken.info)
  }
  if (token.info === 'html' && isInDemoContainer) {
    return `<template #highlight><pre v-pre><code class='html'>${md.utils.escapeHtml(
      token.content
    )}</code></pre></template>`
  }
  return defaultRender(tokens, idx, options, env, self)
}
复制代码

接下来只需要在md-loader/src/config.js里面调用就好了

// md-loader/src/config.js
const overwriteFenceRule = require('./fence')

...

const md = config.toMd()
overwriteFenceRule(md)
module.exports = md
复制代码

蜜汁bug

如果你正确的按照前面的步骤走到这里,你一定会发现之前添加的highligh消失了,代码恢复了最丑的模样。说实话这个点其实我还蛮迷惑的,因为我极其不解到底是怎么回事,我甚至扒到了源码里面看了下

// markdown-it/lib/renderer.js
if (options.highlight) {
  highlighted = options.highlight(token.content, langName, langAttrs) || escapeHtml(token.content);
} else {
  highlighted = escapeHtml(token.content);
}
复制代码

这段代码在不替换fence渲染逻辑的情况下必定会被执行,这也就是之前代码高亮变得非常好看的原因。

可是替换了fence渲染逻辑之后,这段代码好似无法执行了,甚至不论怎么通关断点调试都无法证明此段代码有被执行。没办法,我们只能换个方式进行处理了。highligh.js作为一个代码高亮的插件,它是一个相当完善的插件。最完善的点,就是他对多种环境均有支持,所以,我们不妨用在vue组件内直接通过highligh进行处理

// md-loader/src/index.js
module.exports = source => {
  const content = md.render(source)
  return `
    <template>
      <section class='content element-doc'>
        ${content}
      </section>
    </template>
    <script>
      import hljs from 'highlight.js'
      export default {
        mounted(){
          this.$nextTick(()=>{
            const blocks = document.querySelectorAll('pre code:not(.hljs)')
            Array.prototype.forEach.call(blocks, hljs.highlightBlock)
          })
        }
      }
    </script>
  `
}
复制代码

好了,现在它又回到原来漂漂亮亮的样子了,不过这段代码,后面我们会改的,稍后再说。

接近尾声

现在我们就差最后一个最麻烦的步骤,我们就成功啦!这最难实现的也就是demo块中将组件真实渲染出来。

我们先来打印一下我们的content

<h2>Button 按钮</h2>
<p>常用的操作按钮。</p>
<h3>基础用法</h3>
<p>基础的按钮用法。</p>
<demo-block>
  <div>
    <p>使用
      <code>type</code><code>plain</code><code>round</code><code>circle</code>
      属性来定义 Button 的样式。
    </p>
  </div>
  <template #highlight>
    <pre v-pre>
    	<code class='html'>
        &lt;template&gt;
          &lt;el-row&gt;
            &lt;el-button&gt;默认按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;primary&quot;&gt;主要按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;success&quot;&gt;成功按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;info&quot;&gt;信息按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;warning&quot;&gt;警告按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;danger&quot;&gt;危险按钮&lt;/el-button&gt;
          &lt;/el-row&gt;

          &lt;el-row&gt;
            &lt;el-button plain&gt;朴素按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;primary&quot; plain&gt;主要按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;success&quot; plain&gt;成功按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;info&quot; plain&gt;信息按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;warning&quot; plain&gt;警告按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;danger&quot; plain&gt;危险按钮&lt;/el-button&gt;
          &lt;/el-row&gt;

          &lt;el-row&gt;
            &lt;el-button round&gt;圆角按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;primary&quot; round&gt;主要按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;success&quot; round&gt;成功按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;info&quot; round&gt;信息按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;warning&quot; round&gt;警告按钮&lt;/el-button&gt;
            &lt;el-button type=&quot;danger&quot; round&gt;危险按钮&lt;/el-button&gt;
          &lt;/el-row&gt;

          &lt;el-row&gt;
            &lt;el-button icon=&quot;el-icon-search&quot; circle&gt;&lt;/el-button&gt;
            &lt;el-button type=&quot;primary&quot; icon=&quot;el-icon-edit&quot; circle&gt;&lt;/el-button&gt;
            &lt;el-button type=&quot;success&quot; icon=&quot;el-icon-check&quot; circle&gt;&lt;/el-button&gt;
            &lt;el-button type=&quot;info&quot; icon=&quot;el-icon-message&quot; circle&gt;&lt;/el-button&gt;
            &lt;el-button type=&quot;warning&quot; icon=&quot;el-icon-star-off&quot; circle&gt;&lt;/el-button&gt;
            &lt;el-button type=&quot;danger&quot; icon=&quot;el-icon-delete&quot; circle&gt;&lt;/el-button&gt;
          &lt;/el-row&gt;
        &lt;/template&gt;
			</code>
    </pre>
  </template>
</demo-block>
复制代码

emmmm,我敢说就靠上面这个东西写渲染要麻烦死哦,那我们来给自己省点事儿怎么样?如果我们能想办法搞一份没有经过处理的template,并且我们给它一个特定的标识,我们是不是就能省很多事呢?

按照这样的思路,回到我们的md-loader/src/containers.js里面,回忆一下我们之前打印过的tokens。当nesting===1的时候,他的下一个token是不是就是type为fence的那一个?

// md-loader/src/containers.js
render(tokens, idx) {
  const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/)
  if (tokens[idx].nesting === 1) {
    const description = m?.[1] || ''
    // console.log(description, 'description')
    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>`
}
复制代码

有了标记以后我们再回到index.js里面完成对代码的解析。现在我们就要用上之前我们添加的标记了

// md-loader/src/index.js
const md = require('./config.js')

module.exports = source => {
  // 声明标记的开始与结束以及长度 后续我们要使用它来对代码进行切割
	const commentStart = '<!--element-demo:'
  const commentStartLen = commentStart.length
  const commentEnd = ':element-demo-->'
  const commentEndLen = commentEnd.length
  // 有了起始和结束的标志 我们就可以拿到真实代码的起始位置与结束位置了
  let demoStart = content.indexOf(commentStart)
  let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
  // 根据起始与结束位置获取到真实组件代码部分
  const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
}
复制代码

剥离模板(template)与脚本(script)

拿到组件代码之后我们还是先暂停再分析一波。我们有了代码,怎么才能让他渲染出来呢?而且代码是有可能包含script标签也有可能没有的(比如简单组件示例当中并不需要包含各种响应式数据,单纯的进行了展示),那么假设我们对templatescript进行一个拆分的话,再丢进index.js中的那个大模板里面,是不是有点可行?为此,我们再搞出来一个utils.js专门用来写剥离templatescript`的方法

// md-loader/src/utils.js
const stripTemplate = content => {
  // 先对content的前后空格处理一下,以免后面有什么影响
  content = content.trim()
  // 如果处理空格之后为空,直接把这货返回出去就好了
  if (!content) {
    return content
  }
  // 因为这里是剥离template,所以我们直接把script以及style这些无用的标签去掉
  content = content.replace(/<(script|style)>[\s\S]+<\/1>/g, '').trim()
  // 接下来就是匹配我们想要的部分
  const res = content.match(/<(template)\s*>([\s\S]+)<\/\1>/)
  // 我们肯定是不想要template标签的,所以在这里判断一下是否匹配到了,如果匹配到的话再对结果去一下空格并返回,否则依然是直接把content返回出去
  return res ? res[2]?.trim() : content
}

const stripScript = content => {
  // 这部分就简单了,其实就是上面的翻版,我们只要script就好了
  const res = content.match(/<(script)\s*>([\s\S]+)<\/\1>/)
  return res ? res[2]?.trim() : ''
}
复制代码

现在我们就可以再我们的index.js当中对content进行处理了,不过我们拿到的script标签内容现在还是export default {}的形式,并且可能会包含import ... from ...。这个形式并不利于我们用到index.js当中导出的模板去,所以我们顺便处理一下

// md-loader/src/index.js
const md = require('./config.js')
const { stripTemplate, stripScript } = require('./utils.js')

module.exports = source => {
  // 声明标记的开始与结束以及长度 后续我们要使用它来对代码进行切割
	const commentStart = '<!--element-demo:'
  const commentStartLen = commentStart.length
  const commentEnd = ':element-demo-->'
  const commentEndLen = commentEnd.length
  // 有了起始和结束的标志 我们就可以拿到真实代码的起始位置与结束位置了
  let demoStart = content.indexOf(commentStart)
  let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
  // 根据起始与结束位置获取到真实组件代码部分
  const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
  const template = stripTemplate(componentContent)
  let script = stripScript(componentContent)
	script = script.trim()
  if (script) {
    script = script
    	// 将export default 转成声明一个变量进行保存
      .replace(/export\s+default/, 'const demoComponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, (match, p1, p2) => {
        if (p2 === "'vue'") {
          // 由于全局使用的vue为大写的Vue,所以这里需要专门处理一下
          return `const ${p1} = Vue`
        }
      })
  } else {
    // 若script标签内部为空,直接声明一个空对象即可
    script = 'const demoComponentExport = {}'
  }
}
复制代码

现在我们需要的代码剥离出来了,但是我们还需要放回去,而且原本的代码我们已经不需要了。所以这里我们通过声明一个output数组,用它来存放我们真正要输出出去的内容

// md-loader/src/index.js
const md = require('./config.js')
const { stripTemplate, stripScript } = require('./utils.js')

module.exports = source => {
  // 声明标记的开始与结束以及长度 后续我们要使用它来对代码进行切割
	const commentStart = '<!--element-demo:'
  const commentStartLen = commentStart.length
  const commentEnd = ':element-demo-->'
  const commentEndLen = commentEnd.length
  // 有了起始和结束的标志 我们就可以拿到真实代码的起始位置与结束位置了
  let demoStart = content.indexOf(commentStart)
  let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
  // 输出内容
  const output = []
  // 把我们标记之前的内容push到output中
  output.push(content.slice(0, demoStart))
  // 根据起始与结束位置获取到真实组件代码部分
  const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
  const template = stripTemplate(componentContent)
  let script = stripScript(componentContent)
  // 在这里我们把剥离出来的template放到之前预留的source插槽中后也push进去
  output.push(`<template #source>${template}</template>`)
  // 同时把标记内容之后的部分也push进去
  output.push(content.slice(demoEnd + commentEndLen))
	script = script.trim()
  if (script) {
    script = script
    	// 将export defalut 转成声明一个变量进行保存
      .replace(/export\s+default/, 'const demoComponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, (match, p1, p2) => {
        if (p2 === "'vue'") {
          // 由于全局使用的vue为大写的Vue,所以这里需要专门处理一下
          return `const ${p1} = Vue`
        }
      })
  } else {
    // 若script标签内部为空,直接声明一个空对象即可
    script = 'const demoComponentExport = {}'
  }
  // 在我们默认导出的对象当中把script的内容展开进去,同时把template当中的content替换为我们的output进行输出
  return `
    <template>
      <section class='content element-doc'>
        ${output.join('')}
      </section>
    </template>
    <script>
      import hljs from 'highlight.js'
      export default {
        mounted(){
          this.$nextTick(()=>{
            const blocks = document.querySelectorAll('pre code:not(.hljs)')
            Array.prototype.forEach.call(blocks, hljs.highlightBlock)
          })
        },
				...script
      }
    </script>
  `
}
复制代码

好了,我们成功渲染了,也就是说我们完成了。但是终归还是需要优化一下的,为什么?不要忘了我们现在才只有一个block,所以就只有一个标记出来的组件内容,如果我们使用真正的button.md

## Button 按钮

常用的操作按钮。

### 基础用法

基础的按钮用法。

:::demo 使用`type``plain``round``circle`属性来定义 Button 的样式。

```html
<template>
  <el-row>
    <el-button>默认按钮</el-button>
    <el-button type="primary">主要按钮</el-button>
    <el-button type="success">成功按钮</el-button>
    <el-button type="info">信息按钮</el-button>
    <el-button type="warning">警告按钮</el-button>
    <el-button type="danger">危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button plain>朴素按钮</el-button>
    <el-button type="primary" plain>主要按钮</el-button>
    <el-button type="success" plain>成功按钮</el-button>
    <el-button type="info" plain>信息按钮</el-button>
    <el-button type="warning" plain>警告按钮</el-button>
    <el-button type="danger" plain>危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button round>圆角按钮</el-button>
    <el-button type="primary" round>主要按钮</el-button>
    <el-button type="success" round>成功按钮</el-button>
    <el-button type="info" round>信息按钮</el-button>
    <el-button type="warning" round>警告按钮</el-button>
    <el-button type="danger" round>危险按钮</el-button>
  </el-row>

  <el-row>
    <el-button icon="el-icon-search" circle></el-button>
    <el-button type="primary" icon="el-icon-edit" circle></el-button>
    <el-button type="success" icon="el-icon-check" circle></el-button>
    <el-button type="info" icon="el-icon-message" circle></el-button>
    <el-button type="warning" icon="el-icon-star-off" circle></el-button>
    <el-button type="danger" icon="el-icon-delete" circle></el-button>
  </el-row>
</template>
```

:::

### 禁用状态

按钮不可用状态。

:::demo 你可以使用`disabled`属性来定义按钮是否可用,它接受一个`Boolean`值。

```html
<el-row>
  <el-button disabled>默认按钮</el-button>
  <el-button type="primary" disabled>主要按钮</el-button>
  <el-button type="success" disabled>成功按钮</el-button>
  <el-button type="info" disabled>信息按钮</el-button>
  <el-button type="warning" disabled>警告按钮</el-button>
  <el-button type="danger" disabled>危险按钮</el-button>
</el-row>

<el-row>
  <el-button plain disabled>朴素按钮</el-button>
  <el-button type="primary" plain disabled>主要按钮</el-button>
  <el-button type="success" plain disabled>成功按钮</el-button>
  <el-button type="info" plain disabled>信息按钮</el-button>
  <el-button type="warning" plain disabled>警告按钮</el-button>
  <el-button type="danger" plain disabled>危险按钮</el-button>
</el-row>
```

:::

### 文字按钮

没有边框和背景色的按钮。

:::demo

```html
<el-button type="text">文字按钮</el-button>
<el-button type="text" disabled>文字按钮</el-button>
```

:::

### 图标按钮

带图标的按钮可增强辨识度(有文字)或节省空间(无文字)。

:::demo 设置`icon`属性即可,icon 的列表可以参考 Element3 的 icon 组件,也可以设置在文字右边的 icon ,只要使用`i`标签即可,可以使用自定义图标。

```html
<el-button type="primary" icon="el-icon-edit"></el-button>
<el-button type="primary" icon="el-icon-share"></el-button>
<el-button type="primary" icon="el-icon-delete"></el-button>
<el-button type="primary" icon="el-icon-search">搜索</el-button>
<el-button type="primary"
  >上传<i class="el-icon-upload el-icon--right"></i
></el-button>
```

:::

### 按钮组

以按钮组的方式出现,常用于多项类似操作。

:::demo 使用`<el-button-group>`标签来嵌套你的按钮。

```html
<el-button-group>
  <el-button type="primary" icon="el-icon-arrow-left">上一页</el-button>
  <el-button type="primary"
    >下一页<i class="el-icon-arrow-right el-icon--right"></i
  ></el-button>
</el-button-group>
<el-button-group>
  <el-button type="primary" icon="el-icon-edit"></el-button>
  <el-button type="primary" icon="el-icon-share"></el-button>
  <el-button type="primary" icon="el-icon-delete"></el-button>
</el-button-group>
```

:::

### 加载中

点击按钮后进行数据加载操作,在按钮上显示加载状态。

:::demo 要设置为 loading 状态,只要设置`loading`属性为`true`即可。

```html
<el-button type="primary" :loading="true">加载中</el-button>
```

:::

### 不同尺寸

Button 组件提供除了默认值以外的三种尺寸,可以在不同场景下选择合适的按钮尺寸。

:::demo 额外的尺寸:`medium``small``mini`,通过设置`size`属性来配置它们。

```html
<el-row>
  <el-button>默认按钮</el-button>
  <el-button size="medium">中等按钮</el-button>
  <el-button size="small">小型按钮</el-button>
  <el-button size="mini">超小按钮</el-button>
</el-row>
<el-row>
  <el-button round>默认按钮</el-button>
  <el-button size="medium" round>中等按钮</el-button>
  <el-button size="small" round>小型按钮</el-button>
  <el-button size="mini" round>超小按钮</el-button>
</el-row>
```

:::

### Attributes

| 参数        | 说明           | 类型    | 可选值                                             | 默认值 |
| ----------- | -------------- | ------- | -------------------------------------------------- | ------ |
| size        | 尺寸           | string  | medium / small / mini                              | —      |
| type        | 类型           | string  | primary / success / warning / danger / info / text | —      |
| plain       | 是否朴素按钮   | boolean | —                                                  | false  |
| round       | 是否圆角按钮   | boolean | —                                                  | false  |
| circle      | 是否圆形按钮   | boolean | —                                                  | false  |
| loading     | 是否加载中状态 | boolean | —                                                  | false  |
| disabled    | 是否禁用状态   | boolean | —                                                  | false  |
| icon        | 图标类名       | string  | —                                                  | —      |
| autofocus   | 是否默认聚焦   | boolean | —                                                  | false  |
| native-type | 原生 type 属性 | string  | button / submit / reset                            | button |
复制代码

现在这个量级可就不是在开玩笑了,我们自然得想点对策处理一波

最后的优化

我们肯定能想到用循环来处理这部分逻辑,但是我们循环的是谁呢?我们仔细品一下,之前我们有一个demoStartdemoEnd作为组件起始与结束位置对不对?那么,如果找不到的时候这个值会是-1,我们只需要找到两者均为-1的情况,这个时候一定是不再包含我们需要处理的组件逻辑的对不?所以我单独拿这一部分逻辑出来通过循环搞一下

// md-loader/src/index.js
...
// output肯定依然还是在循环之外
const output = []
// 这里需要一个start起始index,下面说明为什么需要他
let start = 0
// 因为有多个,后续循环的时候肯定需要去改变他的值了,这里改成let先
let demoStart = content.indexOf(commentStart)
let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
while(demoStart !== -1 && demoEnd !== -1) {
	// 因为slice是不会改变原字符串的,所以我们在这里需要持续改变start及demoStart来保证我们一直切割的都是从头/上一个组件结束到下一个组件开始之前的无需处理代码部分
  output.push(content.slice(start, demoStart))
  // 获取组件代码部分肯定也要挪进来 两个剥离方法自然是同理
  const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
  const template = stripTemplate(componentContent)
  let script = stripScript(componentContent)
}
复制代码

好嘞,当我们做到script的时候就发现了问题了,我们总归不能把这么多script标签累加到一个字符串当中吧,那下面简直就是没法写了。所以我们要搞一个操作,就是弄出来一个提取真实组件的方法。该方法直接返回一个可以作为组件使用的字符串,然后呢?我们把这些组件注册进去不就好了

// md-loader/src/utils.js
// 这里我们要返回组件代码,那template和script是必不可少的
const getRealComponentCode = (template, script) => {
  // 把我们之前处理script标签的代码全都挪进这里
  script = script.trim()
  if (script) {
    script = script
      // 将export defalut 转成声明一个变量进行保存
      .replace(/export\s+default/, 'const demoComponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, (match, p1, p2) => {
        if (p2 === "'vue'") {
          // 由于全局使用的vue为大写的Vue,所以这里需要专门处理一下
          return `const ${p1} = Vue`
        }
      })
  } else {
    // 若script标签内部为空,直接声明一个空对象即可
    script = 'const demoComponentExport = {}'
  }
  // 这里我们返回一个自执行函数就好了,后续引入进去的时候会帮我们把该返回的东西返回出去,如果想问我为什么不用对象,那仔细看代码你就懂了
  return `(function() {
		// 不会忘了我们的script内容是用来声明变量的吧,搞个对象可咋返回哟
		${script}
		return {
			template: \`${template}\`,
			...demoComponentExport
    }
	})()`
}
复制代码

好了,现在我们继续修改index.js的内容,我们现在是可以拿到真实的组件代码了,那我们下一步努力自然是把这一堆组件统统注册到实例当中去

// md-loader/src/index.js
...
// output肯定依然还是在循环之外
const output = []
// 这里需要一个start起始index,下面说明为什么需要他
let start = 0
// 这里声明一个字符串,我们一会就把注册用的内容保存到这里
let componentsString = ''
// 顺便声明一个id,我们既然有多个组件自然就要区分一下名称
let id = 0
// 因为有多个,后续循环的时候肯定需要去改变他的值了,这里改成let先
let demoStart = content.indexOf(commentStart)
let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
while(demoStart !== -1 && demoEnd !== -1) {
	// 因为slice是不会改变原字符串的,所以我们在这里需要持续改变start及demoStart来保证我们一直切割的都是从头/上一个组件结束到下一个组件开始之前的无需处理代码部分
  output.push(content.slice(start, demoStart))
  // 获取组件代码部分肯定也要挪进来 两个剥离方法自然是同理
  const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
  const template = stripTemplate(componentContent)
  // 因为修改已经放到方法里了,这里换成const声明
  const script = stripScript(componentContent)
  const demoComponent = getRealComponentCode(template, script)
  // 这里声明一个名字,通过id进行变化
  const demoComponentName = `element-demo-${id}`
  // 这里记得push一个自定义组件进去哦
  output.push(`<template #source><${demoComponentName}/></template>`)
  // 这里才是真正用来注册的地方 里面的demoComponentName需要通过JSON.stringify处理一下,不然后续会识别不了的
  componentsString += `
		${JSON.stringify(demoComponentName)}: ${demoComponent},
	`
  // 都搞完之后记得把start那些挨个处理一下
  id++
  start = demoEnd + commentEndLen
  demoStart = content.indexOf(commentStart, start)
  demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
}
// 现在我们的script需要拎出来处理一下了,毕竟要注册组件了
// pageScript用来存储后面真实输出的script标签
let pageScript = ''
// 如果我们是有组件的
if (componentsString) {
  // 不要忘记把hljs和vue都引入一下
  pageScript = `<script>
      import hljs from 'highlight.js'
      import * as Vue from 'vue'
      export default {
        name: 'component-doc',
        components: {
          ${componentsString}
        },
				mounted() {
					// 这里也不要忘了我们之前的高亮处理
					this.$nextTick(()=>{
            const blocks = document.querySelectorAll('pre code:not(.hljs)')
            Array.prototype.forEach.call(blocks, hljs.highlightBlock)
          })
				}
      }
    </script>`
// 这种情况就是只有script标签,基本上就等同是没有组件代码的 所以我们只需要用之前的剥离script方法处理一下就好
} else if (content.indexOf('<script>') === 0) {
  pageScript = stripScript(content)
}
// 这里是真的真的不要忘记 循环完了以后还有好多东西没有push到output中呢
output.push(content.slice(start))

return `
	<template>
		<section class="content element-doc">
			${output.join('')}
		</section>
	</template>
	${pageScript}
`
复制代码

让我们跑起来!(指代码)

开不开心!是不是看见警告然后渲染不出来!(别打我)

当头一棒

我们看警告就知道其实是vue的运行时的问题,这块我们就不再用template去处理了,毕竟vue3当中分包分的还是很开的,我们直接安装@vue/compiler-dom,通过vue3自己的compiler帮我们拿到render函数就好了

// md-loader/src/utils.js
const compiler = require('@vue/compiler-dom')

// 这里我们要返回组件代码,那template和script是必不可少的
const getRealComponentCode = (template, script) => {
  // 后面这个配置参数就是根据module/function模式不同区切换不同的语句的,我们可以不用过多考虑
  const compiled = compiler.compile(template, { prefixIdentifiers: true })
  // 在这里我们把原本的return给替换掉 code中会包含一个render方法,我们后面在返回的iife当中直接插入进去让他执行,拿到render放进return的对象中就好了
  const code = compiled.code.replace(/return\s+/, '')
  // 把我们之前处理script标签的代码全都挪进这里
  script = script.trim()
  if (script) {
    script = script
      // 将export defalut 转成声明一个变量进行保存
      .replace(/export\s+default/, 'const demoComponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, (match, p1, p2) => {
        if (p2 === "'vue'") {
          // 由于全局使用的vue为大写的Vue,所以这里需要专门处理一下
          return `const ${p1} = Vue`
        }
      })
  } else {
    // 若script标签内部为空,直接声明一个空对象即可
    script = 'const demoComponentExport = {}'
  }
  // 这里我们返回一个自执行函数就好了,后续引入进去的时候会帮我们把该返回的东西返回出去,如果想问我为什么不用对象,那仔细看代码你就懂了
  return `(function() {
		${code}
		// 不会忘了我们的script内容是用来声明变量的吧,搞个对象可咋返回哟
		${script}
		return {
			...demoComponentExport,
			render
    }
	})()`
}
复制代码

这将是我们最后一次重启项目了,没错,我们完成了!剩下还有一些样式问题只要在文档的那个vue项目中去编写就好了~

欢庆时刻

我们终于结束啦!给自己鼓鼓掌吧(呱唧呱唧)

代码GKD

这部分就是纯纯的代码了,供给伸手党直接拿走还有后续回顾找代码的人用

md-loader/src/index.js

const { stripTemplate, stripScript, getRealComponentCode } = require('./util')

const md = require('./config.js')
console.log(md.render)
module.exports = source => {
  let content = md.render(source)

  const commentStart = '<!--element-demo:'
  const commentStartLen = commentStart.length
  const commentEnd = ':element-demo-->'
  const commentEndLen = commentEnd.length

  const output = []
  let start = 0
  let id = 0
  let componentsString = ''

  let demoStart = content.indexOf(commentStart)
  let demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)

  while (demoStart !== -1 && demoEnd !== -1) {
    output.push(content.slice(start, demoStart))

    const componentContent = content.slice(demoStart + commentStartLen, demoEnd)
    const template = stripTemplate(componentContent)
    const script = stripScript(componentContent)

    const demoComponent = getRealComponentCode(template, script)
    const demoComponentName = `element-demo-${id}`
    output.push(`<template #source><${demoComponentName}/></template>`)
    componentsString += `${JSON.stringify(
      demoComponentName
    )}: ${demoComponent},`

    id++
    start = demoEnd + commentEndLen
    demoStart = content.indexOf(commentStart, start)
    demoEnd = content.indexOf(commentEnd, demoStart + commentStartLen)
  }
  let pageScript = ''
  if (componentsString) {
    pageScript = `<script>
      import hljs from 'highlight.js'
      import * as Vue from "vue"
      export default {
        name: 'component-doc',
        components: {
          ${componentsString}
        }
      }
    </script>`
  } else if (content.indexOf('<script>') === 0) {
    pageScript = stripScript(content)
  }
  output.push(content.slice(start))

  return `
    <template>
      <section class="content element-doc">
        ${output.join('')}
      </section>
    </template>
    ${pageScript}
  `
}
复制代码

md-loader/src/config.js

const Config = require('markdown-it-chain')
const containers = require('./containers')
const hljs = require('highlight.js')
const overwriteFenceRule = require('./fence')

const config = new Config()

const highlight = (str, lang) => {
  if (!lang || !hljs.getLanguage(lang)) {
    return `<pre><code class="hljs">${str}</code></pre>`
  }
  const html = hljs.highlight(lang, str, true).value
  return `<pre><code class="hljs language-${lang}">${html}</code></pre>`
}

config.options
  .html(true)
  .highlight(highlight)
  .end()
  .plugin('containers')
  .use(containers)
  .end()

const md = config.toMd()
overwriteFenceRule(md)
module.exports = md
复制代码

md-loader/src/containers.js

const mdContainer = require('markdown-it-container')

module.exports = md => {
  md.use(mdContainer, 'demo', {
    validate(params) {
      return /^demo\s*(.*)$/.test(params)
    },
    render(tokens, idx) {
      const m = tokens[idx].info.trim().match(/^demo\s*(.*)$/)
      if (tokens[idx].nesting === 1) {
        const description = m?.[1] || ''
        // console.log(description, 'description')
        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>`
    }
  })

  md.use(mdContainer, 'tip')
  md.use(mdContainer, 'warning')
}
复制代码

md-loader/src/fence.js

// 覆盖默认的 fence 渲染策略
module.exports = md => {
  const defaultRender = md.renderer.rules.fence
  md.renderer.rules.fence = (tokens, idx, options, env, self) => {
    const token = tokens[idx]
    // 判断该 fence 是否在 :::demo 内
    const prevToken = tokens[idx - 1]
    const isInDemoContainer =
      prevToken &&
      prevToken.nesting === 1 &&
      /^demo\s*(.*)$/.test(prevToken.info)
    if (token.info === 'html' && isInDemoContainer) {
      return `<template #highlight><pre v-pre><code class='html'>${md.utils.escapeHtml(
        token.content
      )}</code></pre></template>`
    }
    return defaultRender(tokens, idx, options, env, self)
  }
}
复制代码

md-loader/src/util.js

const compiler = require('@vue/compiler-dom')

const stripTemplate = content => {
  content = content.trim()
  if (!content) {
    return content
  }
  content = content.replace(/<(script|style)>[\s\S]+<\/1>/g, '').trim()
  const res = content.match(/<(template)\s*>([\s\S]+)<\/\1>/)
  return res ? res[2]?.trim() : content
}

const stripScript = content => {
  const res = content.match(/<(script)\s*>([\s\S]+)<\/\1>/)
  return res && res[2] ? res[2].trim() : ''
}

const getRealComponentCode = (template, script) => {
  const compiled = compiler.compile(template, { prefixIdentifiers: true })
  let code = compiled.code.replace(/return\s+/, '')

  script = script.trim()
  if (script) {
    script = script
      .replace(/export\s+default/, 'const demoComponentExport =')
      .replace(/import ([,{}\w\s]+) from (['"\w]+)/g, (match, p1, p2) => {
        if (p2 === "'vue'") {
          return `const ${p1} = Vue`
        }
      })
  } else {
    script = 'const demoComponentExport = {}'
  }

  code = `(function() {
    ${code}
    ${script}
    return {
      mounted(){
        this.$nextTick(()=>{
          const blocks = document.querySelectorAll('pre code:not(.hljs)')
          Array.prototype.forEach.call(blocks, hljs.highlightBlock)
        })
      },
      render,
      ...demoComponentExport
    }
  })()`
  return code
}

module.exports = {
  stripScript,
  stripTemplate,
  getRealComponentCode
}
复制代码

vue项目/demo-block.vue

<template>
  <div class="demo-block">
    <div class="source">
      <slot name="source"></slot>
    </div>
    <div class="meta" ref="meta">
      <div class="description" v-if="$slots.default">
        <slot></slot>
      </div>
      <div class="highlight">
        <slot name="highlight"></slot>
      </div>
    </div>
    <div
      class="demo-block-control"
      ref="control"
      @click="isExpanded = !isExpanded"
    >
      <span>{{ controlText }}</span>
    </div>
  </div>
</template>

<script>
import { ref, computed, watchEffect, onMounted } from 'vue'
export default {
  setup() {
    const meta = ref(null)
    const isExpanded = ref(false)
    const controlText = computed(() =>
      isExpanded.value ? '隐藏代码' : '显示代码'
    )
    const codeAreaHeight = computed(() =>
      [...meta.value.children].reduce((t, i) => i.offsetHeight + t, 56)
    )
    onMounted(() => {
      watchEffect(() => {
        meta.value.style.height = isExpanded.value
          ? `${codeAreaHeight.value}px`
          : '0'
      })
    })

    return {
      meta,
      isExpanded,
      controlText
    }
  }
}
</script>

<style lang="scss">
.demo-block {
  border: solid 1px #ebebeb;
  border-radius: 3px;
  transition: 0.2s;

  &.hover {
    box-shadow: 0 0 8px 0 rgba(232, 237, 250, 0.6),
      0 2px 4px 0 rgba(232, 237, 250, 0.5);
  }

  code {
    font-family: Menlo, Monaco, Consolas, Courier, monospace;
  }

  .demo-button {
    float: right;
  }

  .source {
    padding: 24px;
  }

  .meta {
    background-color: #fafafa;
    border-top: solid 1px #eaeefb;
    overflow: hidden;
    transition: height 0.2s;
  }

  .description {
    padding: 20px;
    box-sizing: border-box;
    border: solid 1px #ebebeb;
    border-radius: 3px;
    font-size: 14px;
    line-height: 22px;
    color: #666;
    word-break: break-word;
    margin: 10px;
    background-color: #fff;

    p {
      margin: 0;
      line-height: 26px;
    }

    code {
      color: #5e6d82;
      background-color: #e6effb;
      margin: 0 4px;
      display: inline-block;
      padding: 1px 5px;
      font-size: 12px;
      border-radius: 3px;
      height: 18px;
      line-height: 18px;
    }
  }

  .highlight {
    pre {
      margin: 0;
    }

    code.hljs {
      margin: 0;
      border: none;
      max-height: none;
      border-radius: 0;

      &::before {
        content: none;
      }
    }
  }

  .demo-block-control {
    border-top: solid 1px #eaeefb;
    height: 44px;
    box-sizing: border-box;
    background-color: #fff;
    border-bottom-left-radius: 4px;
    border-bottom-right-radius: 4px;
    text-align: center;
    margin-top: -1px;
    color: #d3dce6;
    cursor: pointer;
    position: relative;

    i {
      font-size: 16px;
      line-height: 44px;
      transition: 0.3s;
      &.hovering {
        transform: translateX(-40px);
      }
    }

    > span {
      position: absolute;
      transform: translateX(-30px);
      font-size: 14px;
      line-height: 44px;
      transition: 0.3s;
      display: inline-block;
    }

    &:hover {
      color: #409eff;
      background-color: #f9fafc;
    }

    & .text-slide-enter,
    & .text-slide-leave-active {
      opacity: 0;
      transform: translateX(10px);
    }

    .control-button {
      line-height: 26px;
      position: absolute;
      top: 0;
      right: 0;
      font-size: 14px;
      padding-left: 5px;
      padding-right: 25px;
    }
  }
} 
.hljs {
  line-height: 1.8;
  font-family: Menlo, Monaco, Consolas, Courier, monospace;
  font-size: 12px;
  padding: 18px 24px;
  background-color: #fafafa;
  border: solid 1px #eaeefb;
  margin-bottom: 25px;
  border-radius: 4px;
  -webkit-font-smoothing: auto;
}
</style>
复制代码

避坑大全

说是大全,其实我也只能列举出来我踩过的坑,所以后续如果有朋友也遇到了某些坑欢迎联系我进行补充哈~

  1. markdown-it-chain插件必需调用plugin()传入插件,否则一定会报错,这个坑说来也好解决,因为一是插件肯定是要用的,不然没必要用chain这个插件;二是稍微改改源码就好了,所以说是坑,也只是学习或者说写教程的时候才会遇到的一个坑罢了。
  2. vue-loader一定要是16.0.0版本以上,这个倒也是不能完全算坑。但是我通过yarn add vue-loader -D安装的时候发现默认就是15.9.6版本的,所以还是写在这里以防万一,这个坑会出现的bug很好判断,parseComponent is not defined基本上控制台提示前面这个报错,就极大概率是你的loader版本有问题,确认一下就好了。
  3. 第三个就真实是个坑了,说实话对于loader的运行机制我还不算很清晰,所以这个问题我只能说会遇到,也知道怎么能解决,但是我的解决方法会比较麻烦。那么这个坑是啥呢?写loader的时候,经常性发现修改之后没有效果。并且你会发现,重装依赖,没有用;清缓存,没用;强制刷新,也没啥用。有用的方法是什么?是删掉依赖清缓存再重装,对,你需要完整走完这一复杂的流程,不然真就没用你敢信?
文章分类
前端
文章标签